mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 10:14:53 -05:00
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:
committed by
GitHub
parent
bb70074f35
commit
d01d0eacb8
@@ -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)',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
14
packages/desktop-client/src/data/customThemeCatalog.json
Normal file
14
packages/desktop-client/src/data/customThemeCatalog.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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 {
|
||||||
|
|||||||
1121
packages/desktop-client/src/style/customThemes.test.ts
Normal file
1121
packages/desktop-client/src/style/customThemes.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
328
packages/desktop-client/src/style/customThemes.ts
Normal file
328
packages/desktop-client/src/style/customThemes.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
180
packages/docs/docs/experimental/custom-themes.md
Normal file
180
packages/docs/docs/experimental/custom-themes.md
Normal 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`.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
6
upcoming-release-notes/6612.md
Normal file
6
upcoming-release-notes/6612.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Features
|
||||||
|
authors: [MatissJanis]
|
||||||
|
---
|
||||||
|
|
||||||
|
Ability to install custom color themes
|
||||||
Reference in New Issue
Block a user