Skip to content

feat(themes): custom themes with CSS editor, AI prompt, WCAG validation, and light/dark slots#117

Open
brooksc wants to merge 3 commits into
johannesjo:mainfrom
brooksc:task/theme3-549ec8
Open

feat(themes): custom themes with CSS editor, AI prompt, WCAG validation, and light/dark slots#117
brooksc wants to merge 3 commits into
johannesjo:mainfrom
brooksc:task/theme3-549ec8

Conversation

@brooksc
Copy link
Copy Markdown
Contributor

@brooksc brooksc commented May 16, 2026

Core feature: Custom Themes

  • New Themes tab in Settings with Light / Dark / System appearance modes
  • Each mode has an independent slot: a built-in preset plus an optional custom theme overlay
  • Custom theme editor dialog: paste CSS, get live parse/validation, WCAG contrast warnings, and a "Save & Apply" button
  • AI prompt generator — copy to Claude Code (or any LLM), it asks about your preferences and outputs ready-to-paste CSS
  • Built-in preset cards show a Clone button that pre-fills the editor; custom theme cards show Edit
  • New themes auto-assigned to the light or dark slot based on luminance detection; re-slotted on edit if tone changes

Theming mechanics

  • Themes stored as ~/.../themes/<id>.css; atomic writes; ID allowlist enforced in all IPC handlers
  • CSS parser: header comment for metadata, comment-stripping before :root scan, brace-counting block extractor, declaration-level regex, nested-rule detection
  • WCAG AA contrast checker (4 pairs checked; warnings shown but don't block save)
  • Light theme xterm palette override so terminal text stays readable on light backgrounds
  • Startup: loadState → loadCustomThemes → applyAppearanceMode; both slots sanitized on every apply; stale IDs cleared
  • Legacy customThemes JSON migration via Promise.allSettled; malformed files logged and skipped

Other fixes bundled in

  • Keybinding fix: task-reorder no longer shadows word-select (Alt+Shift+←/→ on macOS, Ctrl+Shift elsewhere)
  • Pagination dots clickable on Linux
  • Close dialog: added Cancel option for running sessions
  • Mobile QR intermittent rendering fix
  • Prompt: bracketed-paste delay scaled by line count to prevent stuck initial prompts
  • Diff viewer related changes

OpenSpec

  • Proposal + spec at openspec/specs/custom-themes/spec.md

🤖 Generated with Claude Code

@brooksc
Copy link
Copy Markdown
Contributor Author

brooksc commented May 16, 2026

image image image

prompt:

You are a UI theme designer for Parallel Code, a dark-mode terminal multiplexer and AI coding assistant.

Help me create a custom color theme by asking about my aesthetic preferences, then filling in the CSS template.

VARIABLES AND THEIR ROLES:
/* --bg: App-wide page background. Can be a hex color or CSS gradient (e.g. radial-gradient(130% 120% at 18% 0%, #202044 0%, #171c30 58%, #12151f 100%)) /
/
--bg-elevated: Raised surfaces: panels, dropdowns, tooltips /
/
--bg-input: Input fields and code editor backgrounds /
/
--bg-hover: Hover state background for buttons and list items /
/
--bg-selected: Selected item background (active task, highlighted row) /
/
--bg-selected-subtle: Subtle selected state — same hue as --bg-selected with ~25% alpha (e.g. #2d2b5840) /
/
--border: Primary border for panels and inputs /
/
--border-subtle: Softer secondary borders /
/
--border-focus: Focus ring color when a field is focused (usually matches accent) /
/
--fg: Primary text color — must be readable on --bg-elevated /
/
--fg-muted: Secondary text, less important labels /
/
--fg-subtle: Tertiary text, placeholders, disabled states /
/
--accent: Primary interactive color — buttons, checkboxes, active indicators /
/
--accent-hover: Lighter/brighter version of accent for hover states /
/
--accent-text: Text color ON accent-colored backgrounds (usually white or near-black) /
/
--link: Hyperlink color (often a lighter, more saturated accent) /
/
--success: Success states, positive indicators (usually green-ish) /
/
--error: Error states, destructive actions (usually red-ish) /
/
--warning: Warning states, caution indicators (usually amber/orange) /
/
--island-bg: Background of task column "islands" — typically 1-2 shades darker than bg-elevated /
/
--island-border: Border around task column islands /
/
--island-radius: Corner radius for islands (e.g. 12px, 8px, or 0px for sharp) /
/
--task-container-bg: Background of the task list container within an island /
/
--task-panel-bg: Content panel backgrounds inside tasks (conceptually matches terminalBackground) */

/* terminalBackground: Opaque hex color for the terminal emulator (hex only, no gradients — should match --task-panel-bg conceptually) */

Please:

  1. Ask me about my aesthetic preferences (mood, accent color, reference themes I like, light vs dark)
  2. Generate a complete theme in this exact CSS format when ready (keep the comments — they help the user understand each value):

/*
name: My Theme Name
description: One-line description of the theme's mood or style
terminalBackground: #hex
*/

:root {
--bg: ; /* App-wide page background. Can be a hex color or CSS gradient (e.g. radial-gradient(130% 120% at 18% 0%, #202044 0%, #171c30 58%, #12151f 100%)) /
--bg-elevated: ; /
Raised surfaces: panels, dropdowns, tooltips /
--bg-input: ; /
Input fields and code editor backgrounds /
--bg-hover: ; /
Hover state background for buttons and list items /
--bg-selected: ; /
Selected item background (active task, highlighted row) /
--bg-selected-subtle: ; /
Subtle selected state — same hue as --bg-selected with ~25% alpha (e.g. #2d2b5840) /
--border: ; /
Primary border for panels and inputs /
--border-subtle: ; /
Softer secondary borders /
--border-focus: ; /
Focus ring color when a field is focused (usually matches accent) /
--fg: ; /
Primary text color — must be readable on --bg-elevated /
--fg-muted: ; /
Secondary text, less important labels /
--fg-subtle: ; /
Tertiary text, placeholders, disabled states /
--accent: ; /
Primary interactive color — buttons, checkboxes, active indicators /
--accent-hover: ; /
Lighter/brighter version of accent for hover states /
--accent-text: ; /
Text color ON accent-colored backgrounds (usually white or near-black) /
--link: ; /
Hyperlink color (often a lighter, more saturated accent) /
--success: ; /
Success states, positive indicators (usually green-ish) /
--error: ; /
Error states, destructive actions (usually red-ish) /
--warning: ; /
Warning states, caution indicators (usually amber/orange) /
--island-bg: ; /
Background of task column "islands" — typically 1-2 shades darker than bg-elevated /
--island-border: ; /
Border around task column islands /
--island-radius: ; /
Corner radius for islands (e.g. 12px, 8px, or 0px for sharp) /
--task-container-bg: ; /
Background of the task list container within an island /
--task-panel-bg: ; /
Content panel backgrounds inside tasks (conceptually matches terminalBackground) */
}

RULES:

  • All --bg-* and --fg-* values must be hex colors (no gradients)
  • --bg may be a CSS gradient if the aesthetic calls for it
  • --bg-selected-subtle should be the same hue as --bg-selected with ~25% opacity appended (e.g. #2d2b5840)
  • --island-radius should be 12px, 8px, or 0px
  • Ensure sufficient contrast: --fg on --bg-elevated should meet WCAG AA (4.5:1 ratio)
  • terminalBackground must be an opaque hex value

@johannesjo
Copy link
Copy Markdown
Owner

Review pass against the net diff from origin/main found two issues to address:

  1. Persisted custom theme selections can be cleared during startup before theme files load. App registers the reactive applyAppearanceMode() effect before loadState() / loadCustomThemes() complete (src/App.tsx:248 and src/App.tsx:360). When loadState() restores darkThemeCustomId / lightThemeCustomId (src/store/persistence.ts:523), store.customThemes is still {}, so applyAppearanceMode() treats the restored ID as stale and nulls it (src/store/ui.ts:90). loadCustomThemes() then has no slot ID left to reactivate, and autosave can persist the loss. This also affects migrated legacy themes.

  2. Cloning a built-in preset while a custom theme is active copies the custom overlay too. SettingsDialog calls readCssVarsForPreset() for Clone (src/components/SettingsDialog.tsx:60), but that helper only temporarily swaps data-look (src/lib/theme.ts:107). The active data-custom-theme attribute and injected stylesheet still apply, so computed CSS variables come from the custom theme overlay rather than the requested built-in preset. Temporarily clearing/restoring dataset.customTheme while reading the preset vars should fix it.

Verification: git diff --check origin/main..HEAD passed. Local test/typecheck runs were blocked because this checkout does not have the newly added colord package installed in node_modules yet.

…on, and light/dark slots

Add a fully-featured custom theme system:

- Themes settings tab with Light/Dark/System appearance modes; each slot
  holds an independent built-in preset + optional custom overlay
- Custom theme editor (CSS paste + live parse/validate) with AI prompt
  generator that can be copied to Claude Code or any LLM
- WCAG AA contrast checker with per-pair warnings (theme still saves)
- CSS parser: header-comment metadata (name, description, terminalBackground),
  comment-stripping before :root scan, brace-counting block extractor,
  declaration-by-declaration regex, nested-rule detection
- Built-in preset cards show "Clone" → opens editor pre-filled; custom
  cards show "Edit" overlay; tone-based slot assignment on save
- Themes persisted as ~/.../themes/<id>.css; atomic writes; VALID_THEME_ID
  allowlist on all IPC handlers; ENOENT-only swallow on delete
- Startup sequence: loadState → loadCustomThemes → applyAppearanceMode;
  both dark and light slots sanitized (stale IDs cleared) on every apply
- Save/delete IPC awaited before store mutation (pessimistic); save error
  kept in a separate signal so live-validation edits don't clear it
- Legacy JSON customThemes migration via Promise.allSettled; malformed
  theme files logged with console.warn and skipped gracefully
- Light theme xterm palette override so terminal text is readable on
  light backgrounds; luminance-based tone detection via colord
- OpenSpec change proposal (openspec/changes/custom-themes/)

Also includes: prompt cancel-option dialog, pagination-dot Linux fix,
keybinding word-select shadow fix, mobile QR intermittent rendering fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@brooksc brooksc force-pushed the task/theme3-549ec8 branch from 757e815 to 0f22f29 Compare May 16, 2026 17:49
Startup race: applyAppearanceMode() was sanitizing persisted custom-theme
slot IDs against an empty store before loadCustomThemes() had run, permanently
losing the user's selections. loadCustomThemes() now returns false on IPC
failure so markCustomThemesReady() (which unlocks sanitization) is only called
after a successful load.

Clone overlay: readCssVarsForPreset() now clears data-custom-theme and the
injected custom-theme <style> tag while sampling built-in preset vars, so
computed values come from the preset alone rather than the active overlay.

Tests: added startup-race guard test; sanitization tests now call
markCustomThemesReady() explicitly to simulate post-load phase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@brooksc
Copy link
Copy Markdown
Contributor Author

brooksc commented May 16, 2026

Addressed both review findings:

Startup race (persisted selections lost): loadCustomThemes() now returns false on IPC failure instead of silently converting it to []. markCustomThemesReady() — which unlocks slot-ID sanitization in applyAppearanceMode() — is only called after a successful load. A transient filesystem error no longer causes customThemes to be empty when sanitization runs, so the user's saved selections are preserved.

Clone copies custom overlay: readCssVarsForPreset() now clears data-custom-theme and empties the injected <style id="custom-theme-style"> tag before calling getComputedStyle(), then restores both. Sampled vars now come from the built-in preset alone.

Tests: Added a passing test for the startup-race guard ("pre-load preserves IDs"). The two existing sanitization tests now call markCustomThemesReady() explicitly to simulate the post-load phase. 525/525 passing.

@johannesjo
Copy link
Copy Markdown
Owner

johannesjo commented May 16, 2026

Thanks for the large themes pass. I found a few issues that should be fixed before merge:

  1. Persisted custom themes are cleared on restart
    src/App.tsx:248, src/App.tsx:360, src/store/ui.ts:89

    loadState() restores darkThemeCustomId / lightThemeCustomId, but the reactive applyAppearanceMode() can run before loadCustomThemes() has populated store.customThemes. The sanitizer then treats the persisted id as missing and clears the slot. On the next autosave, the selected custom theme can be permanently removed from state.

    Suggested fix: defer missing-theme sanitization until custom theme files have loaded, or add a loaded flag / startup path that applies appearance only after loadCustomThemes().

  2. Close watchdog is cancelled too early
    src/lib/window.ts:101, src/lib/window.ts:107, electron/ipc/register.ts:1006

    The renderer now sends WindowCloseHandling as soon as it receives WindowCloseRequested, which clears the 5s backend fallback before captureWindowState(), saveState(), and CountRunningAgents complete. If any of that async pre-work hangs, the app can hang on close instead of force-closing.

    Suggested fix: only ack WindowCloseHandling when preventDefault() is actually called for the interactive dialog path, or use an explicit response protocol that keeps the watchdog armed until the renderer chooses abort/hide/force-close.

  3. Cloning built-in presets can clone the active custom overlay
    src/lib/theme.ts:104, src/components/SettingsDialog.tsx:60

    readCssVarsForPreset() temporarily changes html.dataset.look, but leaves data-custom-theme and the injected custom theme style active. If the user clicks Clone while a custom theme is active, getComputedStyle(document.documentElement) includes the custom overlay values, so the built-in clone is actually contaminated by the current custom theme.

    Suggested fix: temporarily remove/restore data-custom-theme while reading built-in preset variables.

  4. Custom theme values are injected without value validation
    src/lib/custom-theme.ts:184, src/lib/custom-theme.ts:239

    Parsed variable values are accepted raw and later emitted into the injected style rule. A pasted/AI-generated theme can use values such as url("https://...") for background variables, causing unintended resource loads or unsupported CSS behavior in the renderer.

    Suggested fix: validate values per variable before saving/injecting. At minimum reject url(, image-set(, braces, at-rules, control chars, and allow only hex colors plus a narrowly parsed gradient format where intentionally supported.

  5. Save & Apply may not apply
    src/components/CustomThemeDialog.tsx:101, src/components/CustomThemeDialog.tsx:104

    Creating a light theme while currently in Dark mode assigns it to the light slot and closes the dialog, but the current appearance remains dark, so the new theme is not applied or visible in the current grid. That makes the action label misleading.

    Suggested fix: either switch to the detected tone before assigning, apply to the current slot, or relabel the action when it only saves to the other slot.

Close watchdog: removed premature WindowCloseHandling ack that fired before
the async pre-work in the close handler (captureWindowState, saveState,
CountRunningAgents). The ack now only fires inside preventDefault(), keeping
the 5s force-close fallback armed through the pre-work path.

CSS value validation: added isAllowedCssValue() that rejects url(), image-set(),
@-containing, and control-character values before they enter the store or get
injected into the custom-theme <style> tag. --island-radius additionally
restricted to px/0 values only.

Save & Apply label: button now shows "Save to {tone} slot" when a new theme's
detected tone doesn't match the currently-active slot, so the label accurately
reflects whether the theme will be visible immediately.

Tests: 14 new tests covering CSS value validation (url, @, control chars,
island-radius, hex, gradient) and detectThemeTone (dark, light, fallbacks,
gradient, unparseable).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@brooksc
Copy link
Copy Markdown
Contributor Author

brooksc commented May 17, 2026

Addressed the remaining three findings:

Close watchdog (issue 2): Removed the premature WindowCloseHandling ack that was firing at the top of onCloseRequested before the async pre-work. The ack now only fires inside preventDefault(), so the 5s force-close fallback stays armed through captureWindowState(), saveState(), and CountRunningAgents. A hang in any of those still gets cleaned up.

CSS value validation (issue 4): Added isAllowedCssValue() in custom-theme.ts that rejects values containing url(, image-set(, @, or control characters before they enter the store or are injected into the <style> tag. --island-radius is additionally restricted to Npx/0 only. Valid hex colors and CSS gradients pass through unchanged.

Save & Apply label (issue 5): The button now reactively shows Save to {tone} slot when the detected tone of the new theme doesn't match the currently-active slot, so users see an accurate label rather than a misleading "Save & Apply" when the theme will land in the inactive slot.

Tests: 14 new tests — CSS value validation (url, @, control chars, island-radius, valid hex/gradient) and detectThemeTone (dark/light backgrounds, --bg-elevated preference, fallbacks, gradient, unparseable). 539/539 passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants