Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ release/
node_modules/
.worktrees/
.claude/
.remember/
.parallel-code/
.enzyme/
.omc/
.planning/
Expand Down
3 changes: 3 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export enum IPC {
// Persistence
SaveAppState = 'save_app_state',
LoadAppState = 'load_app_state',
LoadCustomThemes = 'load_custom_themes',
SaveCustomTheme = 'save_custom_theme',
DeleteCustomTheme = 'delete_custom_theme',

// Keybindings
LoadKeybindings = 'load_keybindings',
Expand Down
49 changes: 49 additions & 0 deletions electron/ipc/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,55 @@ export function saveAppState(json: string): void {
}
}

function getThemesDir(): string {
return path.join(getStateDir(), 'themes');
}

const VALID_THEME_ID = /^[a-zA-Z0-9_-]+$/;

export function loadCustomThemeFiles(): { id: string; css: string }[] {
const dir = getThemesDir();
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((f) => f.endsWith('.css'))
.flatMap((f) => {
const id = f.slice(0, -4);
if (!VALID_THEME_ID.test(id)) return [];
try {
return [{ id, css: fs.readFileSync(path.join(dir, f), 'utf8') }];
} catch {
return [];
}
});
}

export function saveCustomThemeFile(id: string, css: string): void {
const dir = getThemesDir();
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${id}.css`);
const tmpPath = filePath + '.tmp';
try {
fs.writeFileSync(tmpPath, css, 'utf8');
fs.renameSync(tmpPath, filePath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
/* temp may not exist */
}
throw err;
}
}

export function deleteCustomThemeFile(id: string): void {
try {
fs.unlinkSync(path.join(getThemesDir(), `${id}.css`));
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
}
}

export function loadAppState(): string | null {
const statePath = getStatePath();
const bakPath = statePath + '.bak';
Expand Down
20 changes: 19 additions & 1 deletion electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ import {
} from './git.js';
import { createTask, deleteTask } from './tasks.js';
import { listAgents } from './agents.js';
import { saveAppState, loadAppState } from './persistence.js';
import {
saveAppState,
loadAppState,
loadCustomThemeFiles,
saveCustomThemeFile,
deleteCustomThemeFile,
} from './persistence.js';
import { loadKeybindings, saveKeybindings } from './keybindings.js';
import { spawn } from 'child_process';
import { askAboutCode, cancelAskAboutCode } from './ask-code.js';
Expand Down Expand Up @@ -553,6 +559,18 @@ export function registerAllHandlers(win: BrowserWindow): void {
if (json) syncTaskNamesFromJson(json);
return json;
});
ipcMain.handle(IPC.LoadCustomThemes, () => loadCustomThemeFiles());
ipcMain.handle(IPC.SaveCustomTheme, (_e, args) => {
assertString(args.id, 'id');
assertString(args.css, 'css');
if (!/^[a-zA-Z0-9_-]+$/.test(args.id)) throw new Error('Invalid theme id');
saveCustomThemeFile(args.id, args.css);
});
ipcMain.handle(IPC.DeleteCustomTheme, (_e, args) => {
assertString(args.id, 'id');
if (!/^[a-zA-Z0-9_-]+$/.test(args.id)) throw new Error('Invalid theme id');
deleteCustomThemeFile(args.id);
});

// --- Keybindings ---
function getKeybindingsDir(): string {
Expand Down
4 changes: 4 additions & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const ALLOWED_CHANNELS = new Set([
// Persistence
'save_app_state',
'load_app_state',
// Custom themes
'load_custom_themes',
'save_custom_theme',
'delete_custom_theme',
// Keybindings
'load_keybindings',
'save_keybindings',
Expand Down
35 changes: 35 additions & 0 deletions openspec/changes/custom-themes/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Custom Themes

## Why

Users cannot currently personalize the app's color scheme beyond choosing a
built-in preset. Sharing or adapting themes created by others requires editing
source files. There is no path to light/dark-aware theming for users who prefer
a system-matched appearance.

## What changes

- Add an **Appearance** settings tab with a Light / Dark / System mode selector.
- Add **custom themes** stored as `.css` files in the user's config directory.
Each theme uses a standard CSS header comment for metadata (`name:`,
`description:`, `terminalBackground:`) and `:root { }` for CSS variable
overrides.
- Custom themes appear alongside built-in presets in the theme grid, filtered by
auto-detected tone (luminance of `--bg-elevated`). An **Edit** hover button
distinguishes them from built-in presets (which show **Clone**).
- A **+ Create New** button opens a dialog with a copyable AI prompt, a CSS
paste area, live validation, and WCAG AA contrast warnings.
- The terminal emulator background respects `terminalBackground` from the active
custom theme.
- Structural layout rules (`data-look`) are preserved when a custom theme is
active; color variables are injected via a separate `data-custom-theme`
attribute so cloned themes retain the base preset's chrome.

## Impact

- New IPC channels: `load_custom_themes`, `save_custom_theme`,
`delete_custom_theme` (allowlisted in preload).
- New persistence: `~/.config/parallel-code/themes/<id>.css`.
- New store fields: `appearanceMode`, `lightThemePreset`, `darkThemePreset`,
`lightThemeCustomId`, `darkThemeCustomId`, `activeCustomThemeId`,
`customThemes`.
34 changes: 34 additions & 0 deletions openspec/changes/custom-themes/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Tasks — Custom Themes

- [x] Add `CustomTheme` type and CSS parse/serialize helpers in
`src/lib/custom-theme.ts` (`parseThemeCss`, `themeToCss`,
`buildCustomThemeCss`, `detectThemeTone`, `generateThemePrompt`,
`checkThemeContrast`).
- [x] Add IPC channels `load_custom_themes`, `save_custom_theme`,
`delete_custom_theme` to `electron/ipc/channels.ts`, implement handlers
in `electron/ipc/persistence.ts`, register in `electron/ipc/register.ts`,
and allowlist in `electron/preload.cjs`.
- [x] Persist custom themes as `~/.config/parallel-code/themes/<id>.css` files;
load via `loadCustomThemes()` in `src/store/persistence.ts`.
- [x] Add store fields: `appearanceMode`, `lightThemePreset`, `darkThemePreset`,
`lightThemeCustomId`, `darkThemeCustomId`, `activeCustomThemeId`,
`customThemes` to `src/store/types.ts` and `src/store/core.ts`.
- [x] Add `Appearance` settings tab (Light / Dark / System mode selector) and
merge custom themes into the built-in preset grid in
`src/components/SettingsDialog.tsx`.
- [x] Add `CustomThemeDialog.tsx` with CSS paste area, live validation, WCAG AA
contrast warnings, AI prompt template, and delete button.
- [x] Apply active custom theme via `data-custom-theme` attribute on
`<html>` in `src/App.tsx`; preserve `data-look` for structural rules.
- [x] Wire terminal background to active custom theme in
`src/components/TerminalView.tsx`; handle light custom themes with
appropriate ANSI palette in `getTerminalThemeForCustom`.
- [x] Add `applyAppearanceMode()` store action and call it after
`loadCustomThemes()` on startup.
- [x] Migrate any `customThemes` entries found in `state.json` (from pre-CSS
builds) to individual CSS files on load; remove `customThemes` from
`saveAppState()` output.
- [x] Update unit tests in `src/lib/custom-theme.test.ts` and
`src/store/appearance-mode.test.ts`.
- [ ] Validate with `npm run typecheck`, `npm test`, and
`openspec validate --all --strict`.
154 changes: 154 additions & 0 deletions openspec/specs/custom-themes/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Custom Themes Specification

## Purpose

Allow users to create, edit, share, and apply custom color schemes without
editing source files, with automatic light/dark tone detection and WCAG AA
contrast validation.

## Requirements

### Requirement: CSS theme format

Custom themes SHALL be stored as `.css` files with a mandatory header comment
and a `:root {}` block of CSS variable overrides.

#### Scenario: Valid theme is parsed

- **GIVEN** a CSS file with a `/* name: … terminalBackground: … */` header
comment and a `:root { --bg: …; … }` block
- **WHEN** `parseThemeCss` is called with that content
- **THEN** it returns `{ name, description, terminalBackground, vars }` with no
error

#### Scenario: Missing header comment is rejected

- **GIVEN** CSS that begins with `:root {` and has no `/* */` comment
- **WHEN** `parseThemeCss` is called
- **THEN** it throws with a message containing `"header comment block"`

#### Scenario: Missing name is rejected

- **GIVEN** a header comment that contains `terminalBackground:` but not `name:`
- **WHEN** `parseThemeCss` is called
- **THEN** it throws with a message containing `"name"`

#### Scenario: Missing terminalBackground is rejected

- **GIVEN** a header comment that contains `name:` but not `terminalBackground:`
- **WHEN** `parseThemeCss` is called
- **THEN** it throws with a message containing `"terminalBackground"`

#### Scenario: Inline comments are stripped before parsing vars

- **GIVEN** a declaration like `--bg: #0f0e17; /* App background */`
- **WHEN** `parseThemeCss` processes that block
- **THEN** `vars['--bg']` equals `'#0f0e17'` and the comment is discarded

#### Scenario: Unknown CSS variables are silently ignored

- **GIVEN** a `:root` block containing `--unknown-key: #fff`
- **WHEN** `parseThemeCss` processes it
- **THEN** `--unknown-key` does not appear in the returned `vars`

### Requirement: Theme file persistence

Custom themes SHALL be stored as individual `.css` files under
`~/.config/parallel-code/themes/<id>.css`. The `customThemes` object SHALL NOT
be written to `state.json`.

#### Scenario: Save creates a CSS file

- **WHEN** the renderer sends `save_custom_theme` with a valid `id` and `css`
- **THEN** the main process writes `<configDir>/themes/<id>.css` with that content

#### Scenario: Load returns all CSS files

- **WHEN** the renderer sends `load_custom_themes`
- **THEN** the main process returns an array of `{ id, css }` for every `.css`
file in the themes directory

#### Scenario: Delete removes the file

- **WHEN** the renderer sends `delete_custom_theme` with an `id`
- **THEN** the main process removes `<configDir>/themes/<id>.css`

#### Scenario: Path traversal is rejected

- **WHEN** `save_custom_theme` or `delete_custom_theme` is called with an `id`
that contains characters outside `[a-zA-Z0-9_-]`
- **THEN** the main process throws an error and does not touch the filesystem

### Requirement: Tone detection and theme grid integration

Custom themes SHALL appear in the same preset grid as built-in themes,
filtered by auto-detected tone (light or dark).

#### Scenario: Dark custom theme appears in dark slot

- **GIVEN** a custom theme whose `--bg-elevated` has luminance ≤ 0.5
- **WHEN** the Themes settings tab is shown in dark appearance mode
- **THEN** the theme card appears in the dark preset grid

#### Scenario: Light custom theme appears in light slot

- **GIVEN** a custom theme whose `--bg-elevated` has luminance > 0.5
- **WHEN** the Themes settings tab is shown in light appearance mode
- **THEN** the theme card appears in the light preset grid

#### Scenario: Custom cards show Edit; built-in cards show Clone

- **GIVEN** a mix of built-in and custom theme cards in the grid
- **WHEN** the user hovers a custom card
- **THEN** an **Edit** button is shown (not Clone)
- **AND WHEN** the user hovers a built-in card
- **THEN** a **Clone** button is shown

### Requirement: Structural layout preservation

The `data-look` HTML attribute SHALL always reflect the active base preset so
that structural CSS rules (layout, spacing, radius) are never lost when a
custom theme overrides color variables.

#### Scenario: Custom theme does not clobber data-look

- **GIVEN** a built-in preset `indigo` is selected and a custom theme is active
- **WHEN** the app renders
- **THEN** `document.documentElement.dataset.look` equals `"indigo"`
- **AND** `document.documentElement.dataset.customTheme` equals the custom
theme's `id`

### Requirement: Terminal readability for light custom themes

When a custom theme's `terminalBackground` has luminance > 0.5, the terminal
emulator SHALL use a dark foreground color and a GitHub-light-compatible ANSI
palette so that colored output remains legible.

#### Scenario: Light background gets dark foreground

- **GIVEN** a custom theme with `terminalBackground: #ffffff`
- **WHEN** `getTerminalThemeForCustom` is called with that value
- **THEN** the returned object includes `foreground: '#1f2329'`

#### Scenario: Dark background gets default foreground

- **GIVEN** a custom theme with `terminalBackground: #1e1e2e`
- **WHEN** `getTerminalThemeForCustom` is called
- **THEN** the returned object does NOT include a `foreground` key

### Requirement: WCAG AA contrast validation

The theme dialog SHALL report contrast warnings for pairs that fail WCAG AA
thresholds so users can correct them before saving.

#### Contrast pairs checked

| Foreground | Background | Required ratio |
| --------------- | --------------- | -------------- |
| `--fg` | `--bg-elevated` | 4.5 : 1 |
| `--fg-muted` | `--bg-elevated` | 3.0 : 1 |
| `--fg` | `--bg-selected` | 4.5 : 1 |
| `--accent-text` | `--accent` | 4.5 : 1 |

Translucent backgrounds SHALL be composited over `--bg-elevated` before the
ratio is computed to avoid false positives.
13 changes: 10 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading