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

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

View File

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

View File

@@ -202,6 +202,12 @@ export function ExperimentalFeatures() {
>
<Trans>Crossover Report</Trans>
</FeatureToggle>
<FeatureToggle
flag="customThemes"
feedbackLink="https://github.com/actualbudget/actual/issues/6607"
>
<Trans>Custom themes</Trans>
</FeatureToggle>
{showServerPrefs && (
<ServerFeatureToggle
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 { Menu } from '@actual-app/components/menu';
import { Select } from '@actual-app/components/select';
import { Text } from '@actual-app/components/text';
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 { ThemeInstaller } from './ThemeInstaller';
import { Column, Setting } from './UI';
import { useSidebar } from '@desktop-client/components/sidebar/SidebarProvider';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import {
themeOptions,
useTheme,
usePreferredDarkTheme,
darkThemeOptions,
} from '@desktop-client/style';
import {
type InstalledTheme,
parseInstalledTheme,
serializeInstalledTheme,
} from '@desktop-client/style/customThemes';
const INSTALL_NEW_VALUE = '__install_new__';
export function ThemeSettings() {
const { t } = useTranslation();
const sidebar = useSidebar();
const [theme, switchTheme] = useTheme();
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 (
<Setting
@@ -34,44 +120,60 @@ export function ThemeSettings() {
flexDirection: 'column',
gap: '1em',
width: '100%',
[`@media (min-width: ${
sidebar.floating
? tokens.breakpoint_small
: tokens.breakpoint_medium
})`]: {
flexDirection: 'row',
},
}}
>
<Column title={t('Theme')}>
<Select<Theme>
onChange={value => {
switchTheme(value);
}}
value={theme}
options={themeOptions}
className={css({
'&[data-hovered]': {
backgroundColor: themeStyle.buttonNormalBackgroundHover,
{!showInstaller && (
<View
style={{
flexDirection: 'column',
gap: '1em',
width: '100%',
[`@media (min-width: ${
sidebar.floating
? tokens.breakpoint_small
: 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 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,
currency: false,
crossoverReport: false,
customThemes: false,
};
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 type { DarkTheme, Theme } from 'loot-core/types/prefs';
import { validateThemeCss, parseInstalledTheme } from './customThemes';
import * as darkTheme from './themes/dark';
import * as developmentTheme from './themes/development';
import * as lightTheme from './themes/light';
import * as midnightTheme from './themes/midnight';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
const themes = {
@@ -97,3 +99,38 @@ export function ThemeStyle() {
.join('\n');
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 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 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 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 calendarCellBackground = colorPalette.navy900;
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';