Add custom themes installation feature (#6612)

* Add custom themes feature with GitHub installation support

- Add ThemeInstaller component for installing themes from GitHub
- Implement custom theme validation and CSS parsing
- Add support for installed custom themes in preferences
- Add CustomThemeStyle component with CSS validation
- Update theme system to support custom overlay backgrounds
- Add comprehensive tests for theme installation and validation
- Add documentation and release notes for custom themes feature

* Update custom theme catalog: remove several themes and add 'Miami Beach' from a new repository.

* Enhance CSS validation in custom themes

- Refactor `validatePropertyValue` to implement an allowlist approach for CSS property values, rejecting complex constructs and functions except for rgb/rgba/hsl/hsla.
- Add comprehensive tests for various invalid CSS scenarios, including function calls and at-rules.
- Improve error messages for better clarity on validation failures.
- Ensure property name validation checks for format and allowed characters.

* Update custom theme catalog: rename theme from 'Miami Beach' to 'Shades of Coffee'.

* Remove 'forceReload' feature flag and related code from the application settings and feature flag definitions.

* Enhance ThemeInstaller component to support installed themes

- Add `installedTheme` prop to `ThemeInstaller` for managing custom themes.
- Implement logic to populate the text box with installed custom theme CSS when reopening.
- Update tests to verify behavior for installed themes with and without repositories.
- Improve CSS validation to allow additional CSS keywords and ensure proper structure.
This commit is contained in:
Matiss Janis Aboltins
2026-01-15 19:04:35 +01:00
committed by GitHub
parent bb70074f35
commit d01d0eacb8
21 changed files with 2745 additions and 37 deletions

View File

@@ -201,4 +201,5 @@ export const theme = {
tooltipBackground: 'var(--color-tooltipBackground)', tooltipBackground: 'var(--color-tooltipBackground)',
tooltipBorder: 'var(--color-tooltipBorder)', tooltipBorder: 'var(--color-tooltipBorder)',
calendarCellBackground: 'var(--color-calendarCellBackground)', calendarCellBackground: 'var(--color-calendarCellBackground)',
overlayBackground: 'var(--color-overlayBackground)',
}; };

View File

@@ -41,6 +41,7 @@ import { installPolyfills } from '@desktop-client/polyfills';
import { loadGlobalPrefs } from '@desktop-client/prefs/prefsSlice'; import { loadGlobalPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch, useSelector, useStore } from '@desktop-client/redux'; import { useDispatch, useSelector, useStore } from '@desktop-client/redux';
import { import {
CustomThemeStyle,
hasHiddenScrollbars, hasHiddenScrollbars,
ThemeStyle, ThemeStyle,
useTheme, useTheme,
@@ -240,6 +241,7 @@ export function App() {
<AppInner /> <AppInner />
</ErrorBoundary> </ErrorBoundary>
<ThemeStyle /> <ThemeStyle />
<CustomThemeStyle />
<ErrorBoundary FallbackComponent={FatalError}> <ErrorBoundary FallbackComponent={FatalError}>
<Modals /> <Modals />
</ErrorBoundary> </ErrorBoundary>

View File

@@ -43,12 +43,14 @@ type ExternalLinkProps = {
children?: ReactNode; children?: ReactNode;
to?: string; to?: string;
linkColor?: keyof typeof externalLinkColors; linkColor?: keyof typeof externalLinkColors;
onClick?: MouseEventHandler;
}; };
const ExternalLink = ({ const ExternalLink = ({
children, children,
to, to,
linkColor = 'blue', linkColor = 'blue',
onClick,
}: ExternalLinkProps) => { }: ExternalLinkProps) => {
return ( return (
// we can't use <ExternalLink /> here for obvious reasons // we can't use <ExternalLink /> here for obvious reasons
@@ -57,6 +59,7 @@ const ExternalLink = ({
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ color: externalLinkColors[linkColor] }} style={{ color: externalLinkColors[linkColor] }}
onClick={onClick}
> >
{children} {children}
</a> </a>

View File

@@ -202,6 +202,12 @@ export function ExperimentalFeatures() {
> >
<Trans>Crossover Report</Trans> <Trans>Crossover Report</Trans>
</FeatureToggle> </FeatureToggle>
<FeatureToggle
flag="customThemes"
feedbackLink="https://github.com/actualbudget/actual/issues/6607"
>
<Trans>Custom themes</Trans>
</FeatureToggle>
{showServerPrefs && ( {showServerPrefs && (
<ServerFeatureToggle <ServerFeatureToggle
prefName="flags.plugins" prefName="flags.plugins"

View File

@@ -0,0 +1,515 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { ThemeInstaller } from './ThemeInstaller';
import {
fetchThemeCss,
validateThemeCss,
} from '@desktop-client/style/customThemes';
vi.mock('@desktop-client/style/customThemes', async () => {
const actual = await vi.importActual('@desktop-client/style/customThemes');
return {
...actual,
fetchThemeCss: vi.fn(),
validateThemeCss: vi.fn(),
normalizeGitHubRepo: vi.fn((repo: string) =>
repo.startsWith('http') ? repo : `https://github.com/${repo}`,
),
getThemeScreenshotUrl: vi.fn(
(repo: string) =>
`https://raw.githubusercontent.com/${repo}/refs/heads/main/screenshot.png`,
),
};
});
vi.mock('@desktop-client/data/customThemeCatalog.json', () => ({
default: [
{
name: 'Demo Theme',
repo: 'actualbudget/demo-theme',
},
{
name: 'Ocean Blue',
repo: 'actualbudget/ocean-theme',
},
{
name: 'Forest Green',
repo: 'actualbudget/forest-theme',
},
],
}));
describe('ThemeInstaller', () => {
const mockOnInstall = vi.fn();
const mockOnClose = vi.fn();
const mockValidCss = `:root {
--color-primary: #007bff;
--color-secondary: #6c757d;
}`;
beforeEach(() => {
vi.clearAllMocks();
mockOnInstall.mockClear();
mockOnClose.mockClear();
vi.mocked(fetchThemeCss).mockResolvedValue(mockValidCss);
vi.mocked(validateThemeCss).mockImplementation(css => css.trim());
});
describe('rendering', () => {
it('renders the component with title and close button', () => {
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
expect(screen.getByText('Install Custom Theme')).toBeVisible();
expect(screen.getByRole('button', { name: 'Close' })).toBeVisible();
});
it('renders catalog themes', () => {
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
expect(screen.getByRole('button', { name: 'Demo Theme' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Ocean Blue' })).toBeVisible();
expect(
screen.getByRole('button', { name: 'Forest Green' }),
).toBeVisible();
});
it('does not render error message initially', () => {
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
// Error messages have specific styling, check they're not present
const errorElements = screen.queryAllByText(/Failed/i);
expect(errorElements.length).toBe(0);
});
});
describe('catalog theme selection', () => {
it('calls onInstall when a catalog theme is clicked', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
await waitFor(() => {
expect(mockOnInstall).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Demo Theme',
repo: 'https://github.com/actualbudget/demo-theme',
cssContent: mockValidCss,
}),
);
});
});
it('clears pasted CSS when a catalog theme is selected', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
const cssText = ':root { --color-primary: #ff0000; }';
await user.click(textArea);
await user.paste(cssText);
expect(textArea).toHaveValue(cssText);
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
expect(textArea).toHaveValue('');
});
it('clears error when a catalog theme is selected', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
// First, create an error by pasting invalid CSS
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
await user.click(textArea);
await user.paste('invalid css');
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
expect(screen.queryByText(/Failed/i)).not.toBeInTheDocument();
});
});
describe('error handling for catalog themes', () => {
it('displays error when fetchThemeCss fails', async () => {
const user = userEvent.setup();
const errorMessage = 'Network error';
const testOnInstall = vi.fn();
// Override the mock to reject directly BEFORE rendering
vi.mocked(fetchThemeCss).mockImplementationOnce(() =>
Promise.reject(new Error(errorMessage)),
);
render(
<ThemeInstaller onInstall={testOnInstall} onClose={mockOnClose} />,
);
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
// Wait for the error to be displayed - this confirms the rejection worked
await waitFor(
() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
},
{ timeout: 2000 },
);
// Verify fetchThemeCss was called with correct argument
expect(fetchThemeCss).toHaveBeenCalledWith('actualbudget/demo-theme');
// Since error is displayed, onInstall should NOT be called
expect(testOnInstall).not.toHaveBeenCalled();
});
it('displays generic error when fetchThemeCss fails with non-Error object', async () => {
const user = userEvent.setup();
vi.mocked(fetchThemeCss).mockRejectedValue('String error');
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
await waitFor(() => {
expect(screen.getByText('Failed to load theme')).toBeInTheDocument();
});
expect(mockOnInstall).not.toHaveBeenCalled();
});
it('displays error when validateThemeCss throws', async () => {
const user = userEvent.setup();
const validationError = 'Invalid CSS format';
vi.mocked(validateThemeCss).mockImplementation(() => {
throw new Error(validationError);
});
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
await waitFor(() => {
expect(screen.getByText(validationError)).toBeInTheDocument();
});
expect(mockOnInstall).not.toHaveBeenCalled();
});
});
describe('pasted CSS installation', () => {
it('calls onInstall when valid CSS is pasted and Apply is clicked', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
await user.click(textArea);
await user.paste(mockValidCss);
const applyButton = screen.getByText('Apply');
await user.click(applyButton);
await waitFor(() => {
expect(validateThemeCss).toHaveBeenCalledWith(mockValidCss);
expect(mockOnInstall).toHaveBeenCalledTimes(1);
expect(mockOnInstall).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Custom Theme',
repo: '',
cssContent: mockValidCss,
}),
);
});
});
it('trims whitespace from pasted CSS before validation', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
const cssWithWhitespace = ` ${mockValidCss} `;
await user.click(textArea);
await user.paste(cssWithWhitespace);
const applyButton = screen.getByText('Apply');
await user.click(applyButton);
expect(validateThemeCss).toHaveBeenCalledWith(cssWithWhitespace.trim());
});
it('does not call onInstall when Apply is clicked with empty CSS', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const applyButton = screen.getByText('Apply');
expect(applyButton).toBeDisabled();
await user.click(applyButton);
expect(mockOnInstall).not.toHaveBeenCalled();
});
it('does not call onInstall when Apply is clicked with whitespace-only CSS', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
await user.click(textArea);
await user.paste(' ');
const applyButton = screen.getByText('Apply');
expect(applyButton).toBeDisabled();
await user.click(applyButton);
});
it('populates text box with installed custom theme CSS when reopening', () => {
const installedCustomTheme = {
id: 'theme-abc123',
name: 'Custom Theme',
repo: '',
cssContent: mockValidCss,
};
render(
<ThemeInstaller
onInstall={mockOnInstall}
onClose={mockOnClose}
installedTheme={installedCustomTheme}
/>,
);
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
expect(textArea).toHaveValue(mockValidCss);
});
it('does not populate text box when installed theme has a repo', () => {
const installedCatalogTheme = {
id: 'theme-xyz789',
name: 'Demo Theme',
repo: 'https://github.com/actualbudget/demo-theme',
cssContent: mockValidCss,
};
render(
<ThemeInstaller
onInstall={mockOnInstall}
onClose={mockOnClose}
installedTheme={installedCatalogTheme}
/>,
);
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
expect(textArea).toHaveValue('');
});
it('clears selected catalog theme when CSS is pasted', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
// First select a theme (which should be selected/highlighted)
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
await waitFor(() => {
expect(fetchThemeCss).toHaveBeenCalled();
});
// Then paste CSS
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
await user.click(textArea);
await user.paste(mockValidCss);
// Selection should be cleared (we can't easily test visual state,
// but the handler should clear it)
expect(textArea).toHaveValue(mockValidCss);
});
it('clears error when CSS is pasted', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
// First create an error
vi.mocked(fetchThemeCss).mockRejectedValueOnce(new Error('Test error'));
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
await waitFor(() => {
expect(screen.getByText('Test error')).toBeInTheDocument();
});
// Then paste CSS
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
await user.click(textArea);
await user.paste(mockValidCss);
// Error should be cleared
expect(screen.queryByText('Test error')).not.toBeInTheDocument();
});
});
describe('error handling for pasted CSS', () => {
it('displays error when validateThemeCss throws for pasted CSS', async () => {
const user = userEvent.setup();
const validationError = 'Theme CSS must contain exactly :root';
vi.mocked(validateThemeCss).mockImplementation(() => {
throw new Error(validationError);
});
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const textArea = screen.getByRole('textbox', {
name: 'Custom Theme CSS',
});
await user.click(textArea);
await user.paste('invalid css');
const applyButton = screen.getByText('Apply');
await user.click(applyButton);
await waitFor(() => {
expect(screen.getByText(validationError)).toBeInTheDocument();
});
expect(mockOnInstall).not.toHaveBeenCalled();
});
});
describe('loading states', () => {
it('disables Apply button when loading', async () => {
const user = userEvent.setup();
vi.mocked(fetchThemeCss).mockImplementation(
() =>
new Promise(resolve => {
setTimeout(() => resolve(mockValidCss), 100);
}),
);
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
const applyButton = screen.getByText('Apply');
expect(applyButton).toBeDisabled();
});
it('disables Apply button when pasted CSS is empty during loading', async () => {
const user = userEvent.setup();
vi.mocked(fetchThemeCss).mockImplementation(
() =>
new Promise(resolve => {
setTimeout(() => resolve(mockValidCss), 100);
}),
);
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
// Trigger loading state
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
const applyButton = screen.getByText('Apply');
expect(applyButton).toBeDisabled();
});
});
describe('close button', () => {
it('calls onClose when close button is clicked', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const closeButton = screen.getByText('Close');
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('catalog theme display', () => {
it('displays theme screenshot URL correctly', () => {
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const images = screen.getAllByRole('img');
expect(images.length).toBeGreaterThan(0);
// Check that images have the correct src pattern
images.forEach(img => {
expect(img).toHaveAttribute(
'src',
expect.stringContaining('raw.githubusercontent.com'),
);
});
});
it('displays theme author correctly', () => {
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
// Author should be displayed as "by actualbudget"
const authorTexts = screen.getAllByText(/by/i);
expect(authorTexts.length).toBeGreaterThan(0);
});
it('displays source link for themes', () => {
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const sourceLinks = screen.getAllByText('Source');
expect(sourceLinks.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,371 @@
import React, { useState, useCallback, useEffect } from 'react';
import { TextArea } from 'react-aria-components';
import { useTranslation, Trans } from 'react-i18next';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Button } from '@actual-app/components/button';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { SpaceBetween } from '@actual-app/components/space-between';
import { Text } from '@actual-app/components/text';
import { theme as themeStyle } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { Link } from '@desktop-client/components/common/Link';
import { FixedSizeList } from '@desktop-client/components/FixedSizeList';
import customThemeCatalog from '@desktop-client/data/customThemeCatalog.json';
import {
type CatalogTheme,
type InstalledTheme,
fetchThemeCss,
validateThemeCss,
generateThemeId,
normalizeGitHubRepo,
getThemeScreenshotUrl,
extractRepoOwner,
} from '@desktop-client/style/customThemes';
// Theme item fixed dimensions
const THEME_ITEM_HEIGHT = 140;
const THEME_ITEM_WIDTH = 140;
const THEME_ITEM_GAP = 12;
const CATALOG_MAX_HEIGHT = 300;
type ThemeInstallerProps = {
onInstall: (theme: InstalledTheme) => void;
onClose: () => void;
installedTheme?: InstalledTheme | null;
};
export function ThemeInstaller({
onInstall,
onClose,
installedTheme,
}: ThemeInstallerProps) {
const { t } = useTranslation();
const [selectedCatalogTheme, setSelectedCatalogTheme] =
useState<CatalogTheme | null>(null);
const [pastedCss, setPastedCss] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize pastedCss with installed custom theme CSS if it exists
useEffect(() => {
// If there's an installed theme with empty repo (custom pasted CSS), restore it
if (installedTheme && !installedTheme.repo) {
setPastedCss(installedTheme.cssContent);
}
}, [installedTheme]);
// TODO: inlined for now, but eventually we will fetch this from github directly
const catalog = customThemeCatalog as CatalogTheme[];
// Calculate items per row based on container width
const getItemsPerRow = useCallback((containerWidth: number) => {
const padding = 8; // 4px on each side
const availableWidth = containerWidth - padding;
return Math.max(
1,
Math.floor(
(availableWidth + THEME_ITEM_GAP) / (THEME_ITEM_WIDTH + THEME_ITEM_GAP),
),
);
}, []);
const installTheme = useCallback(
async (options: {
css: string | Promise<string>;
name: string;
repo: string;
id: string;
errorMessage: string;
}) => {
setError(null);
setIsLoading(true);
try {
const css =
typeof options.css === 'string' ? options.css : await options.css;
const validatedCss = validateThemeCss(css);
const installedTheme: InstalledTheme = {
id: options.id,
name: options.name,
repo: options.repo,
cssContent: validatedCss,
};
onInstall(installedTheme);
} catch (err) {
setError(err instanceof Error ? err.message : options.errorMessage);
} finally {
setIsLoading(false);
}
},
[onInstall],
);
const handleCatalogThemeClick = useCallback(
async (theme: CatalogTheme) => {
setSelectedCatalogTheme(theme);
setPastedCss('');
const normalizedRepo = normalizeGitHubRepo(theme.repo);
await installTheme({
css: fetchThemeCss(theme.repo),
name: theme.name,
repo: normalizedRepo,
id: generateThemeId(normalizedRepo),
errorMessage: t('Failed to load theme'),
});
},
[installTheme, t],
);
const handlePastedCssChange = useCallback((value: string) => {
setPastedCss(value);
setSelectedCatalogTheme(null);
setError(null);
}, []);
const handleInstallPastedCss = useCallback(() => {
if (!pastedCss.trim()) return;
installTheme({
css: pastedCss.trim(),
name: t('Custom Theme'),
repo: '',
id: generateThemeId(`pasted-${Date.now()}`),
errorMessage: t('Failed to validate theme CSS'),
});
}, [pastedCss, installTheme, t]);
return (
<View
style={{
padding: 16,
backgroundColor: themeStyle.tableBackground,
borderRadius: 8,
border: `1px solid ${themeStyle.tableBorder}`,
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Text style={{ fontWeight: 600, fontSize: 14 }}>
<Trans>Install Custom Theme</Trans>
</Text>
<Button variant="bare" onPress={onClose}>
<Trans>Close</Trans>
</Button>
</View>
{/* Catalog Virtualized List */}
<Text style={{ marginBottom: 8, color: themeStyle.pageTextSubdued }}>
<Trans>Choose from catalog:</Trans>
</Text>
<View
style={{
height: CATALOG_MAX_HEIGHT,
marginBottom: 16,
}}
>
<AutoSizer>
{({ width, height }) => {
if (width === 0 || height === 0) {
return null;
}
const itemsPerRow = getItemsPerRow(width);
const rows: CatalogTheme[][] = [];
for (let i = 0; i < catalog.length; i += itemsPerRow) {
rows.push(catalog.slice(i, i + itemsPerRow));
}
return (
<FixedSizeList
width={width}
height={height}
itemCount={rows.length}
itemSize={THEME_ITEM_HEIGHT + THEME_ITEM_GAP}
itemKey={index => `row-${index}`}
renderRow={({ index, style }) => {
const rowThemes = rows[index];
return (
<div
style={{
...style,
display: 'flex',
gap: THEME_ITEM_GAP,
padding: '0 4px',
}}
>
{rowThemes.map((theme, themeIndex) => {
const isSelected =
selectedCatalogTheme?.name === theme.name &&
selectedCatalogTheme?.repo === theme.repo;
const isLoadingSelected = isLoading && isSelected;
return (
<Button
key={`${theme.name}-${index}-${themeIndex}`}
variant="bare"
aria-label={theme.name}
onPress={() => handleCatalogThemeClick(theme)}
style={{
width: THEME_ITEM_WIDTH,
height: THEME_ITEM_HEIGHT,
padding: 8,
borderRadius: 6,
border: `2px solid ${
isSelected
? themeStyle.buttonPrimaryBackground
: themeStyle.tableBorder
}`,
backgroundColor: isSelected
? themeStyle.tableRowBackgroundHover
: 'transparent',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
flexShrink: 0,
position: 'relative',
}}
>
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 6,
backgroundColor: themeStyle.overlayBackground,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
opacity: isLoadingSelected ? 1 : 0,
pointerEvents: isLoadingSelected
? 'auto'
: 'none',
transition: 'opacity 0.2s ease-in-out',
}}
>
<AnimatedLoading
style={{
width: 24,
height: 24,
color: themeStyle.pageText,
}}
/>
</View>
<img
src={getThemeScreenshotUrl(theme.repo)}
alt={theme.name}
style={{
width: '100%',
height: 60,
objectFit: 'cover',
borderRadius: 4,
}}
/>
<Text
style={{
fontSize: 12,
fontWeight: 500,
textAlign: 'center',
}}
>
{theme.name}
</Text>
<SpaceBetween
direction="horizontal"
align="center"
gap={4}
style={{ fontSize: 10 }}
>
<Text
style={{ color: themeStyle.pageTextSubdued }}
>
{t('by')}{' '}
<Text style={{ fontWeight: 'bold' }}>
{extractRepoOwner(theme.repo)}
</Text>
</Text>
<Link
variant="external"
to={normalizeGitHubRepo(theme.repo)}
onClick={e => e.stopPropagation()}
>
<Trans>Source</Trans>
</Link>
</SpaceBetween>
</Button>
);
})}
</div>
);
}}
/>
);
}}
</AutoSizer>
</View>
{/* Paste CSS Input */}
<View
style={{
borderTop: `1px solid ${themeStyle.tableBorder}`,
paddingTop: 16,
marginBottom: 16,
}}
>
<Text style={{ marginBottom: 8, color: themeStyle.pageTextSubdued }}>
<Trans>or paste CSS directly:</Trans>
</Text>
<TextArea
value={pastedCss}
onChange={e => handlePastedCssChange(e.target.value)}
placeholder={t(':root {\n --color-sidebarItemSelected: #007bff;\n}')}
aria-label={t('Custom Theme CSS')}
style={{
height: 120,
}}
/>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 8,
}}
>
<Button
variant="normal"
onPress={handleInstallPastedCss}
isDisabled={!pastedCss.trim() || isLoading}
>
<Trans>Apply</Trans>
</Button>
</View>
</View>
{/* Error Message */}
{error && (
<Text
style={{
color: themeStyle.errorText,
marginBottom: 12,
fontSize: 12,
}}
>
{error}
</Text>
)}
</View>
);
}

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React, { useState, useCallback } from 'react';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
import { Menu } from '@actual-app/components/menu';
import { Select } from '@actual-app/components/select'; import { Select } from '@actual-app/components/select';
import { Text } from '@actual-app/components/text'; import { Text } from '@actual-app/components/text';
import { theme as themeStyle } from '@actual-app/components/theme'; import { theme as themeStyle } from '@actual-app/components/theme';
@@ -10,21 +11,106 @@ import { css } from '@emotion/css';
import { type DarkTheme, type Theme } from 'loot-core/types/prefs'; import { type DarkTheme, type Theme } from 'loot-core/types/prefs';
import { ThemeInstaller } from './ThemeInstaller';
import { Column, Setting } from './UI'; import { Column, Setting } from './UI';
import { useSidebar } from '@desktop-client/components/sidebar/SidebarProvider'; import { useSidebar } from '@desktop-client/components/sidebar/SidebarProvider';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { import {
themeOptions, themeOptions,
useTheme, useTheme,
usePreferredDarkTheme, usePreferredDarkTheme,
darkThemeOptions, darkThemeOptions,
} from '@desktop-client/style'; } from '@desktop-client/style';
import {
type InstalledTheme,
parseInstalledTheme,
serializeInstalledTheme,
} from '@desktop-client/style/customThemes';
const INSTALL_NEW_VALUE = '__install_new__';
export function ThemeSettings() { export function ThemeSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const sidebar = useSidebar(); const sidebar = useSidebar();
const [theme, switchTheme] = useTheme(); const [theme, switchTheme] = useTheme();
const [darkTheme, switchDarkTheme] = usePreferredDarkTheme(); const [darkTheme, switchDarkTheme] = usePreferredDarkTheme();
const [showInstaller, setShowInstaller] = useState(false);
const customThemesEnabled = useFeatureFlag('customThemes');
// Global prefs for custom themes
const [installedThemeJson, setInstalledThemeJson] = useGlobalPref(
'installedCustomTheme',
);
const installedTheme = parseInstalledTheme(installedThemeJson);
// Build the options list
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) {
options.push([
`custom:${installedTheme.id}`,
installedTheme.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]);
// Determine current value for the select
const getCurrentValue = useCallback(() => {
if (customThemesEnabled && installedTheme) {
return `custom:${installedTheme.id}`;
}
return theme;
}, [customThemesEnabled, installedTheme, theme]);
// Handle theme selection
const handleThemeChange = useCallback(
(value: string) => {
if (value === INSTALL_NEW_VALUE) {
setShowInstaller(true);
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));
switchTheme(value as Theme);
}
},
[setInstalledThemeJson, switchTheme],
);
// Handle theme installation
const handleInstall = useCallback(
(newTheme: InstalledTheme) => {
setInstalledThemeJson(serializeInstalledTheme(newTheme));
},
[setInstalledThemeJson],
);
// Handle installer close
const handleInstallerClose = useCallback(() => {
setShowInstaller(false);
}, []);
return ( return (
<Setting <Setting
@@ -34,44 +120,60 @@ export function ThemeSettings() {
flexDirection: 'column', flexDirection: 'column',
gap: '1em', gap: '1em',
width: '100%', width: '100%',
[`@media (min-width: ${
sidebar.floating
? tokens.breakpoint_small
: tokens.breakpoint_medium
})`]: {
flexDirection: 'row',
},
}} }}
> >
<Column title={t('Theme')}> {!showInstaller && (
<Select<Theme> <View
onChange={value => { style={{
switchTheme(value); flexDirection: 'column',
}} gap: '1em',
value={theme} width: '100%',
options={themeOptions} [`@media (min-width: ${
className={css({ sidebar.floating
'&[data-hovered]': { ? tokens.breakpoint_small
backgroundColor: themeStyle.buttonNormalBackgroundHover, : tokens.breakpoint_medium
})`]: {
flexDirection: 'row',
}, },
})} }}
>
<Column title={t('Theme')}>
<Select<string>
onChange={handleThemeChange}
value={getCurrentValue()}
options={buildOptions()}
className={css({
'&[data-hovered]': {
backgroundColor: themeStyle.buttonNormalBackgroundHover,
},
})}
/>
</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>
)}
</View>
)}
{customThemesEnabled && showInstaller && (
<ThemeInstaller
onInstall={handleInstall}
onClose={handleInstallerClose}
installedTheme={installedTheme}
/> />
</Column>
{theme === 'auto' && (
<Column title={t('Dark theme')}>
<Select<DarkTheme>
onChange={value => {
switchDarkTheme(value);
}}
value={darkTheme}
options={darkThemeOptions}
className={css({
'&[data-hovered]': {
backgroundColor: themeStyle.buttonNormalBackgroundHover,
},
})}
/>
</Column>
)} )}
</View> </View>
} }

View File

@@ -0,0 +1,14 @@
[
{
"name": "Demo Theme",
"repo": "actualbudget/demo-theme"
},
{
"name": "Shades of Coffee",
"repo": "Juulz/shades-of-coffee"
},
{
"name": "Miami Beach",
"repo": "Juulz/miami-beach"
}
]

View File

@@ -9,6 +9,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
formulaMode: false, formulaMode: false,
currency: false, currency: false,
crossoverReport: false, crossoverReport: false,
customThemes: false,
}; };
export function useFeatureFlag(name: FeatureFlag): boolean { export function useFeatureFlag(name: FeatureFlag): boolean {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
/**
* Custom theme utilities: fetch, validation, and storage helpers.
*/
export type CatalogTheme = {
name: string;
repo: string;
};
export type InstalledTheme = {
id: string;
name: string;
repo: string;
cssContent: string; // CSS content stored when theme is installed (required)
};
/**
* Safely extract the owner from a GitHub repo string.
* Handles malformed repo strings by returning "Unknown" when owner cannot be determined.
*/
export function extractRepoOwner(repo: string): string {
if (!repo || typeof repo !== 'string' || !repo.includes('/')) {
return 'Unknown';
}
const parts = repo.split('/');
const owner = parts[0]?.trim();
return owner || 'Unknown';
}
/**
* Normalize a GitHub repo identifier to a full GitHub URL.
* Accepts "owner/repo" format.
* Returns "https://github.com/owner/repo".
* @throws {Error} If repo is invalid or missing owner/repo.
*/
export function normalizeGitHubRepo(repo: string): string {
const trimmed = repo.trim();
if (!trimmed.includes('/')) {
throw new Error('Invalid repo: must be in "owner/repo" format');
}
const parts = trimmed.split('/');
const owner = parts[0]?.trim();
const repoName = parts[1]?.trim();
if (!owner || !repoName) {
throw new Error('Invalid repo: must include both owner and repo name');
}
return `https://github.com/${owner}/${repoName}`;
}
/**
* Get the screenshot URL for a theme repo.
* Returns a safe fallback URL for malformed repos.
*/
export function getThemeScreenshotUrl(repo: string): string {
if (
!repo ||
typeof repo !== 'string' ||
!repo.trim() ||
!repo.includes('/')
) {
// Return a placeholder or empty data URL for malformed repos
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTQwIiBoZWlnaHQ9IjYwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxNDAiIGhlaWdodD0iNjAiIGZpbGw9IiNmNWY1ZjUiLz48L3N2Zz4=';
}
const trimmed = repo.trim();
return `https://raw.githubusercontent.com/${trimmed}/refs/heads/main/screenshot.png`;
}
/**
* Try fetching actual.css from main branch.
*/
export function fetchThemeCss(repo: string): Promise<string> {
return fetchDirectCss(
`https://raw.githubusercontent.com/${repo}/refs/heads/main/actual.css`,
);
}
/**
* Fetch CSS from a direct URL (not a GitHub repo).
*/
export async function fetchDirectCss(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch CSS from ${url}: ${response.status} ${response.statusText}`,
);
}
return response.text();
}
/**
* Validate that a CSS property value only contains allowed content (allowlist approach).
* Only simple, safe CSS values are allowed - no functions (except rgb/rgba/hsl/hsla), no URLs, no complex constructs.
* Explicitly rejects var() and other function calls to prevent variable references and complex expressions.
*/
function validatePropertyValue(value: string, property: string): void {
if (!value || value.length === 0) {
return; // Empty values are allowed
}
const trimmedValue = value.trim();
// Allowlist: Only allow specific safe CSS value patterns
// 1. Hex colors: #RGB, #RRGGBB, or #RRGGBBAA (3, 6, or 8 hex digits)
const hexColorPattern = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?([0-9a-fA-F]{2})?$/;
// 2. RGB/RGBA functions: rgb(...) or rgba(...) with simple numeric/percentage values
// Allow optional whitespace and support both integers and decimals
const rgbRgbaPattern =
/^rgba?\(\s*\d+%?\s*,\s*\d+%?\s*,\s*\d+%?\s*(,\s*[\d.]+)?\s*\)$/;
// 3. HSL/HSLA functions: hsl(...) or hsla(...) with simple numeric/percentage values
const hslHslaPattern =
/^hsla?\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*(,\s*[\d.]+)?\s*\)$/;
// 4. Length values with units: number (including decimals) followed by valid CSS unit
const lengthPattern =
/^(\d+\.?\d*|\d*\.\d+)(px|em|rem|%|vh|vw|vmin|vmax|cm|mm|in|pt|pc|ex|ch)$/;
// 5. Unitless numbers (integers or decimals)
const numberPattern = /^(\d+\.?\d*|\d*\.\d+)$/;
// 6. CSS keywords: common safe keywords
const keywordPattern =
/^(inherit|initial|unset|revert|transparent|none|auto|normal)$/i;
// Check if value matches any allowed pattern
if (
hexColorPattern.test(trimmedValue) ||
rgbRgbaPattern.test(trimmedValue) ||
hslHslaPattern.test(trimmedValue) ||
lengthPattern.test(trimmedValue) ||
numberPattern.test(trimmedValue) ||
keywordPattern.test(trimmedValue)
) {
return; // Value is allowed
}
// If none of the allowlist patterns match, reject the value
throw new Error(
`Invalid value "${trimmedValue}" for property "${property}". Only simple CSS values are allowed (colors, lengths, numbers, or keywords). Functions (including var()), URLs, and other complex constructs are not permitted.`,
);
}
/**
* Validate that CSS contains only :root { ... } with CSS custom property (variable) declarations.
* Must contain exactly :root { ... } and nothing else.
* Returns the validated CSS or throws an error.
*/
export function validateThemeCss(css: string): string {
// Strip multi-line comments before validation
// Note: Single-line comments (//) are not stripped to avoid corrupting CSS values like URLs
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
// Must contain exactly :root { ... } and nothing else
// Find :root { ... } and extract content, then check there's nothing after
const rootMatch = cleaned.match(/^:root\s*\{/);
if (!rootMatch) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Find the opening brace after :root
const rootStart = cleaned.indexOf(':root');
const openBrace = cleaned.indexOf('{', rootStart);
if (openBrace === -1) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Find the first closing brace (nested blocks will be caught by the check below)
const closeBrace = cleaned.indexOf('}', openBrace + 1);
if (closeBrace === -1) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Extract content inside :root { ... }
const rootContent = cleaned.substring(openBrace + 1, closeBrace).trim();
// Check for forbidden at-rules first (before nested block check, since at-rules with braces would trigger that)
// Comprehensive list of CSS at-rules that should not be allowed
// This includes @import, @media, @keyframes, @font-face, @supports, @charset,
// @namespace, @page, @layer, @container, @scope, and any other at-rules
if (/@[a-z-]+/i.test(rootContent)) {
throw new Error(
'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.',
);
}
// Check for nested blocks (additional selectors) - should not have any { after extracting :root content
if (/\{/.test(rootContent)) {
throw new Error(
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
);
}
// Check that there's nothing after the closing brace
const afterRoot = cleaned.substring(closeBrace + 1).trim();
if (afterRoot.length > 0) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Parse declarations and validate each one
const declarations = rootContent
.split(';')
.map(d => d.trim())
.filter(d => d.length > 0);
for (const decl of declarations) {
const colonIndex = decl.indexOf(':');
if (colonIndex === -1) {
throw new Error(`Invalid CSS declaration: "${decl}"`);
}
const property = decl.substring(0, colonIndex).trim();
// Property must start with --
if (!property.startsWith('--')) {
throw new Error(
`Invalid property "${property}". Only CSS custom properties (starting with --) are allowed.`,
);
}
// Validate property name format
// CSS custom property names must:
// - Start with --
// - Not be empty (not just --)
// - Not end with a dash
// - Contain only valid characters (letters, digits, underscore, dash, but not at start/end positions)
if (property === '--' || property === '-') {
throw new Error(
`Invalid property "${property}". Property name cannot be empty or contain only dashes.`,
);
}
// Check for invalid characters in property name (no brackets, spaces, special chars except dash/underscore)
// Property name after -- should only contain: letters, digits, underscore, and dashes (not consecutive dashes at start/end)
const propertyNameAfterDashes = property.substring(2);
if (propertyNameAfterDashes.length === 0) {
throw new Error(
`Invalid property "${property}". Property name cannot be empty after "--".`,
);
}
// Check for invalid characters (no brackets, no special characters except underscore and dash)
if (!/^[a-zA-Z0-9_-]+$/.test(propertyNameAfterDashes)) {
throw new Error(
`Invalid property "${property}". Property name contains invalid characters. Only letters, digits, underscores, and dashes are allowed.`,
);
}
// Check that property doesn't end with a dash (after the -- prefix)
if (property.endsWith('-')) {
throw new Error(
`Invalid property "${property}". Property name cannot end with a dash.`,
);
}
// Extract and validate the value
const value = decl.substring(colonIndex + 1).trim();
validatePropertyValue(value, property);
}
// Return the original CSS (with :root wrapper) so it can be injected properly
return css.trim();
}
/**
* Generate a unique ID for a theme based on its repo URL or direct CSS URL.
*/
export function generateThemeId(urlOrRepo: string): string {
// Simple hash-like ID from the URL
let hash = 0;
for (let i = 0; i < urlOrRepo.length; i++) {
const char = urlOrRepo.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return `theme-${Math.abs(hash).toString(36)}`;
}
/**
* Parse the installed theme JSON from global prefs.
* Returns a single InstalledTheme or null if none is installed.
*/
export function parseInstalledTheme(
json: string | undefined,
): InstalledTheme | null {
if (!json) return null;
try {
const parsed = JSON.parse(json);
if (
parsed &&
typeof parsed === 'object' &&
typeof parsed.id === 'string' &&
typeof parsed.name === 'string' &&
typeof parsed.repo === 'string' &&
typeof parsed.cssContent === 'string'
) {
return {
id: parsed.id,
name: parsed.name,
repo: parsed.repo,
cssContent: parsed.cssContent,
} satisfies InstalledTheme;
}
return null;
} catch {
return null;
}
}
/**
* Serialize installed theme to JSON for global prefs.
*/
export function serializeInstalledTheme(theme: InstalledTheme | null): string {
return JSON.stringify(theme);
}

View File

@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { isNonProductionEnvironment } from 'loot-core/shared/environment'; import { isNonProductionEnvironment } from 'loot-core/shared/environment';
import type { DarkTheme, Theme } from 'loot-core/types/prefs'; import type { DarkTheme, Theme } from 'loot-core/types/prefs';
import { validateThemeCss, parseInstalledTheme } from './customThemes';
import * as darkTheme from './themes/dark'; import * as darkTheme from './themes/dark';
import * as developmentTheme from './themes/development'; import * as developmentTheme from './themes/development';
import * as lightTheme from './themes/light'; import * as lightTheme from './themes/light';
import * as midnightTheme from './themes/midnight'; import * as midnightTheme from './themes/midnight';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
const themes = { const themes = {
@@ -97,3 +99,38 @@ export function ThemeStyle() {
.join('\n'); .join('\n');
return <style>{`:root {\n${css}}`}</style>; return <style>{`:root {\n${css}}`}</style>;
} }
/**
* CustomThemeStyle injects CSS from the installed custom theme (if any).
* This is rendered after ThemeStyle to allow custom themes to override base theme variables.
*/
export function CustomThemeStyle() {
const customThemesEnabled = useFeatureFlag('customThemes');
const [installedThemeJson] = useGlobalPref('installedCustomTheme');
// 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;
}
try {
return validateThemeCss(cssContent);
} catch (error) {
console.error('Invalid custom theme CSS', { error, cssContent });
return null;
}
}, [customThemesEnabled, cssContent]);
if (!validatedCss) {
return null;
}
return <style id="custom-theme-active">{validatedCss}</style>;
}

View File

@@ -217,3 +217,5 @@ export const tooltipBackground = colorPalette.navy800;
export const tooltipBorder = colorPalette.navy700; export const tooltipBorder = colorPalette.navy700;
export const calendarCellBackground = colorPalette.navy900; export const calendarCellBackground = colorPalette.navy900;
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';

View File

@@ -217,3 +217,5 @@ export const tooltipBackground = colorPalette.navy50;
export const tooltipBorder = colorPalette.navy150; export const tooltipBorder = colorPalette.navy150;
export const calendarCellBackground = colorPalette.navy100; export const calendarCellBackground = colorPalette.navy100;
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';

View File

@@ -219,3 +219,5 @@ export const tooltipBackground = colorPalette.white;
export const tooltipBorder = colorPalette.navy150; export const tooltipBorder = colorPalette.navy150;
export const calendarCellBackground = colorPalette.navy100; export const calendarCellBackground = colorPalette.navy100;
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';

View File

@@ -219,3 +219,5 @@ export const tooltipBackground = colorPalette.gray800;
export const tooltipBorder = colorPalette.gray600; export const tooltipBorder = colorPalette.gray600;
export const calendarCellBackground = colorPalette.navy900; export const calendarCellBackground = colorPalette.navy900;
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';

View File

@@ -218,6 +218,7 @@ const sidebars = {
'experimental/formulas', 'experimental/formulas',
'experimental/pluggyai', 'experimental/pluggyai',
'experimental/crossover-point-report', 'experimental/crossover-point-report',
'experimental/custom-themes',
], ],
}, },
'getting-started/tips-tricks', 'getting-started/tips-tricks',

View File

@@ -0,0 +1,180 @@
# Custom Themes
:::warning
This is an **experimental feature**. That means we're still working on finishing it. There may be bugs, missing functionality or incomplete documentation, and we may decide to remove the feature in a future release. If you have any feedback, please [post a comment on GitHub](https://github.com/actualbudget/actual/issues/6607) or post a message in the Discord.
:::
:::warning
All functionality described here may not be available in the latest stable release. See [Experimental Features](/docs/experimental/) for instructions to enable experimental features. Use the `edge` images for the latest implementation.
:::
Custom themes allow you to personalize the appearance of Actual by installing custom color schemes. You can choose from a catalog of community-created themes or create your own by defining CSS variables that override the default theme colors.
## Using Custom Themes
### Enabling the Feature
Before you can use custom themes, you need to enable the experimental feature:
1. Go to **Settings****Show advanced settings****Experimental features**
2. Click "I understand the risks, show experimental features"
3. Enable the **Custom themes** toggle
### Installing a Theme from the Catalog
The easiest way to install a custom theme is to choose one from the catalog:
1. Go to **Settings****Themes**
2. Select **Custom theme** from the theme dropdown
3. The theme installer will open showing available themes from the catalog
4. Click on any theme to install it immediately
Themes in the catalog are hosted on GitHub and are automatically fetched when you select them. Each theme shows a preview screenshot and includes a link to its source repository.
### Installing a Theme by Pasting CSS
You can also install a custom theme by pasting CSS directly:
1. Go to **Settings****Themes****Custom theme**
2. Scroll down to the "or paste CSS directly" section
3. Paste your theme CSS into the text area
4. Click **Apply**
The CSS will be validated before installation. If there are any errors, they will be displayed below the text area.
## Publishing Custom Themes
If you want to create your own custom theme and publish it for the community to use, you'll need to understand how Actual's theming system works.
### Theme Format
Custom themes must be written as CSS using the `:root` selector with CSS custom properties (variables). The format is:
```css
:root {
--color-pageBackground: #1a1a1a;
--color-pageText: #ffffff;
--color-buttonPrimaryBackground: #007bff;
/* ... more variables ... */
}
```
**Important requirements:**
- The CSS must contain **exactly** `:root { ... }` and nothing else
- Only custom properties starting with `--` are allowed; Actual uses `--color-*` variables for theming
- No other selectors, at-rules (@import, @media, etc.), or nested blocks are allowed
- Comments are allowed and will be stripped during validation
### Available CSS Variables
Custom themes can override any of the CSS variables defined in Actual's base themes. These variables correspond to the theme color keys found in the theme files:
- `packages/desktop-client/src/style/themes/light.ts`
- `packages/desktop-client/src/style/themes/dark.ts`
- `packages/desktop-client/src/style/themes/midnight.ts`
Common variables include:
**Page Colors:**
- `--color-pageBackground` - Main page background
- `--color-pageText` - Primary text color
- `--color-pageTextSubdued` - Secondary/subdued text
- `--color-pageTextPositive` - Positive/action text color
- `--color-pageTextLink` - Link text color
**Table Colors:**
- `--color-tableBackground` - Table background
- `--color-tableText` - Table text
- `--color-tableBorder` - Table borders
- `--color-tableRowBackgroundHover` - Row hover background
**Button Colors:**
- `--color-buttonPrimaryBackground` - Primary button background
- `--color-buttonPrimaryText` - Primary button text
- `--color-buttonNormalBackground` - Normal button background
- `--color-buttonNormalText` - Normal button text
**Sidebar Colors:**
- `--color-sidebarBackground` - Sidebar background
- `--color-sidebarItemText` - Sidebar item text
- `--color-sidebarItemTextSelected` - Selected sidebar item text
And many more! To see all available variables, check the theme files in the source code or look at an existing theme.
### Validation Rules
When you paste CSS or install from a catalog, the theme is validated to ensure it meets the requirements:
1. Must contain exactly `:root { ... }`
2. Only custom properties starting with `--` are allowed; Actual uses `--color-*` variables for theming
3. No at-rules (@import, @media, @keyframes, etc.)
4. No nested selectors or blocks
5. No content outside the `:root` block
If validation fails, you'll see an error message explaining what's wrong.
### Creating a GitHub-Hosted Theme
To share your theme with others or add it to the catalog, you can host it on GitHub:
1. Create a new GitHub repository
2. Create a file named `actual.css` in the root directory (on the `main` branch)
3. Add your theme CSS to this file
4. Add a `screenshot.png` file for catalog display (recommended size: 140x60px or similar)
**Example repository structure:**
```text
your-theme-repo/
├── actual.css # Your theme CSS
└── screenshot.png # Preview image
```
**Example `actual.css`:**
```css
:root {
--color-pageBackground: #0d1117;
--color-pageText: #c9d1d9;
--color-pageTextSubdued: #8b949e;
--color-buttonPrimaryBackground: #238636;
--color-buttonPrimaryText: #ffffff;
/* Add all other variables you want to customize */
}
```
The theme can then be referenced in the catalog using the format `owner/repo` (e.g., `actualbudget/demo-theme`).
### Example Theme
For a complete example of a custom theme, check out the [demo theme repository](https://github.com/actualbudget/demo-theme). This repository contains multiple theme variations and demonstrates the proper structure and format.
The demo theme includes examples of:
- Proper CSS variable naming
- Complete theme definitions
- Screenshot assets for catalog display
You can use this as a template for creating your own themes.
### Tips for Theme Development
1. **Start with a base theme**: Copy the CSS variables from one of Actual's built-in themes (light, dark, or midnight) and modify the colors you want to change
2. **Test incrementally**: Make small changes and test them in the app to see the results
3. **Use the paste CSS feature**: During development, use the paste CSS feature to quickly test your theme without needing to host it on GitHub
4. **Check variable names**: Make sure variable names match exactly (case-sensitive) - they should start with `--color-` followed by the theme key name
5. **Consider accessibility**: Ensure sufficient contrast between text and background colors for readability
### Getting Your Theme in the Catalog
To have your theme added to the official catalog, you'll need to:
1. Host your theme on GitHub following the structure above
2. Open an issue or pull request on the Actual repository requesting your theme be added to the catalog
3. Provide the repository name in `owner/repo` format
The catalog is maintained in `packages/desktop-client/src/data/customThemeCatalog.json`.

View File

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

View File

@@ -4,7 +4,8 @@ export type FeatureFlag =
| 'actionTemplating' | 'actionTemplating'
| 'formulaMode' | 'formulaMode'
| 'currency' | 'currency'
| 'crossoverReport'; | 'crossoverReport'
| 'customThemes';
/** /**
* Cross-device preferences. These sync across devices when they are changed. * Cross-device preferences. These sync across devices when they are changed.
@@ -114,6 +115,7 @@ export type GlobalPrefs = Partial<{
colors: Record<string, string>; colors: Record<string, string>;
} }
>; // Complete plugin theme metadata >; // Complete plugin theme metadata
installedCustomTheme?: string; // JSON string of installed custom theme
documentDir: string; // Electron only documentDir: string; // Electron only
serverSelfSignedCert: string; // Electron only serverSelfSignedCert: string; // Electron only
syncServerConfig?: { syncServerConfig?: {
@@ -142,6 +144,7 @@ export type GlobalPrefsJson = Partial<{
language?: GlobalPrefs['language']; language?: GlobalPrefs['language'];
theme?: GlobalPrefs['theme']; theme?: GlobalPrefs['theme'];
'preferred-dark-theme'?: GlobalPrefs['preferredDarkTheme']; 'preferred-dark-theme'?: GlobalPrefs['preferredDarkTheme'];
'installed-custom-theme'?: GlobalPrefs['installedCustomTheme'];
plugins?: string; // "true" or "false" plugins?: string; // "true" or "false"
'plugin-theme'?: string; // JSON string of complete plugin theme (current selected plugin theme) 'plugin-theme'?: string; // JSON string of complete plugin theme (current selected plugin theme)
'server-self-signed-cert'?: GlobalPrefs['serverSelfSignedCert']; 'server-self-signed-cert'?: GlobalPrefs['serverSelfSignedCert'];

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [MatissJanis]
---
Ability to install custom color themes