Repository navigation
feat(themes): named custom color schemes - #569
Conversation
Save, name, pick, edit, and delete custom color schemes instead of the single unnamed customAppearanceJson slot. Key decisions: saved theme id doubles as Settings.mode so there stays one source of truth for "what am I looking at"; themes store raw JSON strings to preserve user formatting; new themes seed from getComputedStyle on the live DOM, which is the only way to reach built-in palettes that exist only as CSS in [data-mode] blocks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eight tasks from the SavedTheme model through the picker grid, editor, v5 migration, and end-to-end verification. Gates on tsc plus driving the app rather than committed test files, per repo convention. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readAppliedAppearance reads the 81 live token values off <html>, which is the only way to reach built-in palettes that exist solely as CSS in [data-mode] blocks. relativeLuminance moves to lib/color so xterm's ANSI ramp and a saved theme's light/dark family answer from the same math. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tinel Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e to v5 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Five findings from an adversarial review of this branch. 1. isDarkThemeValue's luminance branch was unreachable at both call sites: reaching it required a saved theme that both resolves and parses, which is exactly the condition that yields a payload and takes the other branch. The spec's Risk #1 was wrong to begin with — accent and accentFg are themselves custom-appearance tokens, so a saved theme always writes its own concrete accent and the accent-pair branch is only reachable on a built-in mode. Deleted the helper, reverted the lib/color/luminance.ts extraction and the xtermTheme.ts churn it justified, and corrected the spec rather than leaving a wrong risk analysis in the repo. 2. applyTheme wrote a raw `theme:<uuid>` into data-mode when a theme id no longer resolved. Not a black screen (:root doubles as dark), but every high-contrast rule is a compound selector, so contrast silently switched off while its toggle still read on. Now falls back to a built-in id. Unreachable in-app today, but applyTheme is called by the phone client on an uncoerced blob. 3. Migration idempotency keyed on the theme NAME "Custom", which is non-unique and user-editable — so naming a theme "Custom" and round-tripping through a v4 build could orphan the payload the user was actually looking at. Now keyed on a migratedFrom marker that renaming cannot forge. 4. parseCustomAppearanceJson threw on unknown keys and coerceSavedThemes dropped anything that threw, so a future token rename would silently delete every saved theme at boot. That was survivable for one throwaway slot; named themes are durable artifacts seeded dense with all 81 keys. Split strict (editor — a typo must not look applied) from lenient (load — keep the theme, ignore what we cannot read). The isSafeCssColorValue allowlist is enforced in both modes; leniency recovers themes, it does not relax what reaches a CSS property. 5. Dropped THEME_MODES / ThemeMode imports left dead in settingsRegistry by the THEME_MODE_OPTIONS deletion. tsc misses these — noUnusedLocals is not set in any tsconfig. Verified: tsc clean on both projects; 12-case throwaway harness covering the marker-keyed idempotency, the reviewer's exact downgrade sequence, lenient survival of a renamed token, and that lenient still rejects url(). Harness deleted, not committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review findings addressedAn adversarial review of this branch surfaced five findings. All five are fixed in 762cdbd. 1. Reaching the luminance line required a saved theme that both resolves and parses, which is exactly the condition that makes The deeper problem was the spec, not the code: Risk #1 claimed a light saved theme would "pick the wrong half of the accent pair." It can't — Deleted the helper, reverted the 2. Invalid 3. Migration idempotency keyed on a user-editable name. Guarding on 4. Named themes made unknown-key rejection destructive. Split the parser: strict for the editor (a typo must not look successfully applied — the original reason the check exists) and lenient for load (keep the theme, ignore what we can't read). 5. Dead imports left in What the review clearedStale inline properties (tried to break it, couldn't — symmetric key iteration plus dense backfill), coercion ordering, and the Verification
|
Closes #566.
Replaces the single unnamed
customAppearanceJsonslot with named, selectable, editable saved themes.Saved themes sit in the same grid as built-ins, tagged
CUSTOM, with Edit/Delete revealed on hover — mirroring how custom prompt templates already render in the command palette. Hover-only actions also respond tofocus-withinso keyboard users aren't stranded.Design
Spec:
docs/superpowers/specs/2026-07-18-named-custom-themes-design.mdPlan:
docs/superpowers/plans/2026-07-18-named-custom-themes.mdThe theme id doubles as
Settings.mode.modeholds either a built-in id ortheme:<uuid>. The alternative —mode: 'custom'plus a separateactiveSavedThemeId— admits an invalid state (mode isdarkbut a theme id is also set) that every consumer would have to defend against. One field still answers "what am I looking at" forapplyTheme,useThemeSync, and the paired phone client alike.resolveThemePayload(settings)is the new seam between "which theme" and "what colors".applyThemecalls it instead of branching onmode === 'custom'; everything downstream (the 81 inline custom properties, thetheme-changedevent, the xterm/Monaco re-reads) is untouched.+ New theme…seeds fromgetComputedStyleon<html>. This is the only way to reach built-in palette values — they exist solely as CSS in[data-mode]blocks, andTHEME_MODEScarries just{id, label, family}. Same mechanismreadXtermTheme()already uses. It makes "duplicate Tokyonight and tweak it" work without copying palettes into TS, and means a new theme never starts as an unreadable blank.Themes store raw JSON strings, preserving the documented reason the single slot did: the editor is a text editor, and formatting/key order should survive a round trip.
Settings version 4 → 5 with a real migration. An existing
mode: 'custom'user gets their payload converted into a saved theme named "Custom" and stays on it. The conversion lives incoerceSettingsrather than only in zustand'smigrate, so it also repairs same-version-but-stale blobs; a name guard makes it idempotent.Opportunistic cleanup
relativeLuminancemoved from private-in-xtermTheme.tstolib/color/luminance.ts— the chrome's light/dark decision and xterm's ANSI ramp now answer from the same math instead of two copies.THEME_MODEScomment explaining that Custom sat at index 5 to make the Appearance grid an even 3×2. That arrangement no longer exists.AppearanceMenucomment excluding Custom. Its three reasons — "not a one-click preset", "needs the JSON editor", "default payload starts as the dark theme" — all stopped being true, so saved themes now appear there. Accent/contrast stay hidden while a saved theme is active, because inline properties outrank both.Verification
Type-checked clean, both projects:
tsc -p tsconfig.node.json --noEmitandtsc -p tsconfig.web.json --noEmiteach report 0 errors. (remote-clientis insidetsconfig.web.json, so the phone client compiled too.) Note this required building the node project once first — a fresh worktree otherwise reports onlyTS6305project-reference staleness and never checks real code.App boots clean:
npm run devbuilds main + preload + renderer and starts Electron with no errors.Migration and coercion logic exercised with a temporary vitest harness — 9 cases, all passing, deleted before commit per the no-new-test-files convention:
mode: 'custom'→ a saved theme named "Custom", still selected, colors preservedmodestill references → both fall back together (validatingmodeagainst the raw array instead of the coerced one would boot the user to an unstyled Dark that looks like a silent reset)Not verified by driving the UI: creating/editing/deleting through the actual Settings panel, switching between themes without stale inline properties leaking, and the phone client rendering a saved theme. These need a human at the window — an Electron GUI isn't something I can click. The clear-all-tokens teardown they depend on is unchanged code that now simply runs whenever the payload is null.
🤖 Generated with Claude Code