Skip to content
Merged
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
1,026 changes: 1,026 additions & 0 deletions docs/superpowers/plans/2026-07-18-named-custom-themes.md

Large diffs are not rendered by default.

267 changes: 267 additions & 0 deletions docs/superpowers/specs/2026-07-18-named-custom-themes-design.md

Large diffs are not rendered by default.

43 changes: 39 additions & 4 deletions src/renderer/src/app-state/settings/customAppearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,28 @@ export function stringifyCustomAppearance(colors: CustomAppearanceColors): strin
return JSON.stringify(colors, null, 2)
}

export function parseCustomAppearanceJson(raw: string): CustomAppearanceColors {
// WHY there are two strictnesses rather than one parser:
//
// The editor and the loader want opposite things from a bad key, and serving
// both from one function was a latent theme-destroying bug. In the EDITOR, a
// typo must be fatal — silently ignoring `"surfacce"` would render as "saved
// successfully" while that color never applies, which is exactly the
// unfalsifiable failure the strict check was added to prevent. At LOAD time
// the same fatality is catastrophic: coerceSavedThemes drops any theme whose
// JSON throws, so the day a product token is renamed, every *saved, named*
// theme still carrying the old key would be silently deleted at boot and the
// user dropped back to Dark.
//
// That asymmetry was tolerable when custom appearance was one throwaway slot.
// Named themes are durable user-authored artifacts — and they are seeded dense
// with all 81 keys by readAppliedAppearance(), which maximizes the exposure. So
// the loader now keeps the theme and drops what it cannot understand.
export type CustomAppearanceParseMode = 'strict' | 'lenient'

export function parseCustomAppearanceJson(
raw: string,
mode: CustomAppearanceParseMode = 'strict',
): CustomAppearanceColors {
let parsed: unknown
try {
parsed = JSON.parse(raw)
Expand All @@ -381,10 +402,13 @@ export function parseCustomAppearanceJson(raw: string): CustomAppearanceColors {
throw new Error('Custom appearance must be a JSON object.')
}

const lenient = mode === 'lenient'
const record = parsed as Record<string, unknown>
const allowed = new Set<string>(CUSTOM_APPEARANCE_COLOR_KEYS)
const extra = Object.keys(record).filter(key => !allowed.has(key))
if (extra.length > 0) {
// Structurally broken input (not JSON, not an object) still throws in both
// modes — there is no theme to salvage. Only *recognition* failures degrade.
if (extra.length > 0 && !lenient) {
throw new Error(`Unknown custom appearance key: ${extra.join(', ')}`)
}

Expand All @@ -395,17 +419,28 @@ export function parseCustomAppearanceJson(raw: string): CustomAppearanceColors {
// palette invalid: Custom Appearance is persisted user data, and new
// product tokens are added over time. Treating a missing *new* key as a
// fatal schema error would silently throw a user's old custom theme away
// during coerceSettings(). Unknown keys are still rejected above because a
// typo should not look like a successfully-applied color.
// during coerceSettings().
if (value === undefined) {
colors[key] = DEFAULT_CUSTOM_APPEARANCE[key]
continue
}
if (typeof value !== 'string') {
if (lenient) {
colors[key] = DEFAULT_CUSTOM_APPEARANCE[key]
continue
}
throw new Error(`Custom appearance key "${key}" must be a string.`)
}
const trimmed = value.trim()
// Note the value allowlist is enforced in BOTH modes. Leniency is about
// recovering a user's theme, never about relaxing what may reach a CSS
// custom property — isSafeCssColorValue is a security boundary (several
// tokens land in `background` shorthand, where url() can fetch).
if (!isSafeCssColorValue(trimmed)) {
if (lenient) {
colors[key] = DEFAULT_CUSTOM_APPEARANCE[key]
continue
}
throw new Error(
`Custom appearance key "${key}" must be a supported CSS color string.`,
)
Expand Down
91 changes: 87 additions & 4 deletions src/renderer/src/app-state/settings/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@ import {
AGENT_VIEW_MODES,
DEFAULT_SETTINGS,
FONT_FAMILIES,
THEME_MODES,
isBuiltInThemeMode,
USAGE_HEADER_LEVELS,
WORKSPACE_MODES,
} from '@renderer/app-state/settings/types'
import {
V4_CUSTOM_MIGRATION_MARKER,
coerceSavedThemes,
createSavedTheme,
findSavedTheme,
isSavedThemeId,
isV4MigratedTheme,
} from '@renderer/app-state/settings/savedThemes'
import type { SavedTheme } from '@renderer/app-state/settings/savedThemes'
import { parseCustomAppearanceJson } from '@renderer/app-state/settings/customAppearance'
import type {
AccentId,
FontFamilyId,
Expand All @@ -20,12 +30,21 @@ export function coerceSettings(value: unknown): Settings {
const parsed = value && typeof value === 'object'
? value as Partial<Settings>
: {}

// WHY savedThemes is computed here rather than inline in the returned
// object: `mode` validity DEPENDS on which themes survived coercion — a mode
// pointing at a theme just dropped for unparseable JSON has to fall back to
// the default. Resolving both inline would read the raw, un-coerced array and
// could leave mode pointing at a theme that no longer exists, which renders
// as Dark with no inline properties and looks to the user like the setting
// silently reset itself.
const savedThemes = migrateLegacyCustomAppearance(parsed, coerceSavedThemes(parsed.savedThemes))

return {
...DEFAULT_SETTINGS,
...parsed,
mode: THEME_MODES.some(option => option.id === parsed.mode)
? (parsed.mode as Settings['mode'])
: DEFAULT_SETTINGS.mode,
savedThemes,
mode: resolvePersistedMode(parsed, savedThemes),
contrast: parsed.contrast === true,
accent: ACCENTS.some(a => a.id === parsed.accent)
? (parsed.accent as AccentId)
Expand Down Expand Up @@ -98,6 +117,70 @@ export function coerceSettings(value: unknown): Settings {
}
}

// v4 and earlier had exactly one unnamed custom palette, selected by
// `mode === 'custom'`. Converting it into a real saved theme is what keeps an
// upgrading user looking at the same colors they had before the update.
//
// WHY this lives in coerceSettings instead of only in zustand's `migrate`:
// `migrate` fires only when the stored version is older, but same-version blobs
// can still be stale (interrupted writes, hand-edited localStorage, a dev build
// that ran this branch before the version bump landed). Coercion runs on every
// launch through `merge`, so putting the conversion here makes it unconditional.
// The migration marker is what makes re-running it idempotent.
function migrateLegacyCustomAppearance(
parsed: Partial<Settings>,
savedThemes: SavedTheme[],
): SavedTheme[] {
if (parsed.mode !== 'custom') return savedThemes
// WHY the guard keys on the marker and NOT on the theme's name: names are
// deliberately non-unique AND user-editable. A name match lets this sequence
// orphan real data — name a theme "Custom" on v5, run a v4 build (which
// rewrites version: 4 but leaves savedThemes in the blob) and set
// mode: 'custom', then upgrade: the migration sees the name, concludes it has
// already run, and the customAppearanceJson the user was just looking at is
// silently abandoned. A marker cannot be produced by renaming.
if (savedThemes.some(isV4MigratedTheme)) return savedThemes
const raw = typeof parsed.customAppearanceJson === 'string'
? parsed.customAppearanceJson
: ''
try {
parseCustomAppearanceJson(raw, 'lenient')
} catch {
// Matches the pre-existing coerceCustomAppearanceJson policy: an
// unparseable payload degrades silently rather than blocking boot. The
// user lands on Dark, which is what they would have seen anyway.
return savedThemes
}
return [
createSavedTheme(LEGACY_CUSTOM_THEME_NAME, raw, V4_CUSTOM_MIGRATION_MARKER),
...savedThemes,
]
}

const LEGACY_CUSTOM_THEME_NAME = 'Custom'

// Accepts a built-in id, or a saved theme id that actually resolves against the
// already-coerced list. Anything else — a typo, a theme deleted on another
// machine, the legacy 'custom' sentinel whose migration just produced a real
// theme — falls back rather than leaving mode pointing at nothing.
function resolvePersistedMode(
parsed: Partial<Settings>,
savedThemes: SavedTheme[],
): Settings['mode'] {
if (parsed.mode === 'custom') {
// Same marker, same reason as the guard above — resolving the legacy mode
// by name would hand the user whichever theme they happened to call
// "Custom" rather than the one their old payload became.
const migrated = savedThemes.find(isV4MigratedTheme)
return migrated ? migrated.id : DEFAULT_SETTINGS.mode
}
if (isBuiltInThemeMode(parsed.mode)) return parsed.mode
if (isSavedThemeId(parsed.mode) && findSavedTheme(savedThemes, parsed.mode)) {
return parsed.mode
}
return DEFAULT_SETTINGS.mode
}

function coerceCommandVisibilityOverrides(value: unknown): Record<string, boolean> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const result: Record<string, boolean> = {}
Expand Down
163 changes: 163 additions & 0 deletions src/renderer/src/app-state/settings/savedThemes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import {
CUSTOM_APPEARANCE_COLOR_KEYS,
CUSTOM_APPEARANCE_CSS_VARS,
DEFAULT_CUSTOM_APPEARANCE,
parseCustomAppearanceJson,
} from '@renderer/app-state/settings/customAppearance'
import type { CustomAppearanceColors } from '@renderer/app-state/settings/customAppearance'

// A saved theme's id doubles as the value of `Settings.mode`. The prefix is
// what makes that safe: built-in mode ids are a closed set of bare strings
// ('dark', 'light-soft', ...), so a prefixed id can never collide with one,
// and `isSavedThemeId` can classify a mode value without consulting the
// saved-theme list.
//
// WHY one field instead of `mode: 'custom'` plus a separate
// `activeSavedThemeId`: two fields admit an invalid state (mode is 'dark' but
// a theme id is also set) that every consumer would then have to defend
// against. applyTheme, useThemeSync, and the paired phone client all take the
// whole Settings object and ask it one question.
export const SAVED_THEME_ID_PREFIX = 'theme:'

// Names are labels, not keys. Uniqueness is deliberately NOT enforced: ids are
// already unique, and refusing a duplicate name is a papercut with no
// correctness payoff. The cap exists only so a pathological name cannot blow
// out the picker grid cell.
export const SAVED_THEME_NAME_MAX = 48

// Marks a theme created by the v4→v5 migration rather than by the user.
//
// WHY a dedicated marker instead of matching on the name "Custom": names are
// deliberately non-unique AND user-editable, so a name match makes migration
// idempotency depend on data the user controls. Concretely — create a theme you
// happen to name "Custom", downgrade to a v4 build (which rewrites version: 4
// but leaves savedThemes in the blob), then upgrade again: the migration sees
// the name, decides it already ran, and orphans the customAppearanceJson the
// user was actually looking at. The marker cannot be produced by renaming.
export const V4_CUSTOM_MIGRATION_MARKER = 'v4-custom'

export type SavedTheme = {
id: string
name: string
/** Set only by the v4→v5 legacy migration; never written by the editor. */
migratedFrom?: typeof V4_CUSTOM_MIGRATION_MARKER
// WHY raw JSON text rather than a parsed object: the editor is a JSON
// textarea, and users expect their formatting, key ordering, and blank lines
// to survive a round trip. Storing parsed objects would silently reformat
// every theme on every save. Validity is enforced at the two boundaries that
// matter — save time (blocks the save) and load time (coerceSavedThemes
// drops entries that no longer parse).
json: string
createdAt: number
updatedAt: number
}

export function isSavedThemeId(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(SAVED_THEME_ID_PREFIX)
}

export function mintSavedThemeId(): string {
return `${SAVED_THEME_ID_PREFIX}${crypto.randomUUID()}`
}

export function createSavedTheme(
name: string,
json: string,
migratedFrom?: typeof V4_CUSTOM_MIGRATION_MARKER,
): SavedTheme {
const now = Date.now()
return {
id: mintSavedThemeId(),
name: name.trim().slice(0, SAVED_THEME_NAME_MAX),
json,
createdAt: now,
updatedAt: now,
...(migratedFrom ? { migratedFrom } : {}),
}
}

export function isV4MigratedTheme(theme: SavedTheme): boolean {
return theme.migratedFrom === V4_CUSTOM_MIGRATION_MARKER
}

// Mirrors normalizeCustomTemplates in the prompt-templates feature: drop
// anything malformed rather than letting one bad entry poison the list.
//
// WHY the load path parses LENIENTLY: only structurally hopeless JSON (not an
// object, unparseable text) costs a theme its place in the list. A theme
// carrying a key we no longer recognize — which is what a future token rename
// produces for every theme authored before it — keeps working with that key
// ignored. Strict parsing here would mean a one-line rename in
// CUSTOM_APPEARANCE_COLOR_KEYS silently deletes every saved theme at boot.
export function coerceSavedThemes(value: unknown): SavedTheme[] {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
return value.flatMap(item => {
if (!item || typeof item !== 'object') return []
const record = item as Record<string, unknown>
if (!isSavedThemeId(record.id)) return []
if (typeof record.name !== 'string' || !record.name.trim()) return []
if (typeof record.json !== 'string') return []
if (seen.has(record.id)) return []
try {
parseCustomAppearanceJson(record.json, 'lenient')
} catch {
return []
}
seen.add(record.id)
return [{
id: record.id,
name: record.name.trim().slice(0, SAVED_THEME_NAME_MAX),
json: record.json,
createdAt: typeof record.createdAt === 'number' ? record.createdAt : 0,
updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0,
...(record.migratedFrom === V4_CUSTOM_MIGRATION_MARKER
? { migratedFrom: V4_CUSTOM_MIGRATION_MARKER }
: {}),
}]
})
}

export function findSavedTheme(themes: SavedTheme[], id: string): SavedTheme | null {
return themes.find(theme => theme.id === id) ?? null
}

export function resolveSavedThemeColors(theme: SavedTheme): CustomAppearanceColors | null {
try {
// Lenient for the same reason coerceSavedThemes is: an unrecognized key
// must not stop a theme the user can see in the picker from rendering.
return parseCustomAppearanceJson(theme.json, 'lenient')
} catch {
// Unreachable for themes that came through coerceSavedThemes, which
// already dropped unparseable entries. Kept because this function is also
// reachable from paths where the payload has not been vetted yet.
return null
}
}

// Read the 81 currently-applied token values straight off <html>.
//
// WHY the DOM is the source rather than a TypeScript palette table: built-in
// themes exist ONLY as CSS in `[data-mode]` blocks in styles.css. THEME_MODES
// carries {id, label, family} and nothing else, so there is no way to ask
// TypeScript "what hex is Tokyonight's canvas?". This is the same mechanism
// readXtermTheme() and the Monaco theme bridges already use, so it is an
// established pattern here rather than a new one.
//
// This is what lets "+ New theme…" seed from whatever the user is currently
// looking at instead of from a blank object. A blank or sparse seed would
// render as an unreadable mess the instant it applied, because the user would
// then be editing a theme sharing almost nothing with the one they just had.
//
// Falls back per-token to DEFAULT_CUSTOM_APPEARANCE when a variable reads
// empty, which can only happen if this is called before the first applyTheme.
export function readAppliedAppearance(): CustomAppearanceColors {
if (typeof document === 'undefined') return { ...DEFAULT_CUSTOM_APPEARANCE }
const styles = getComputedStyle(document.documentElement)
const colors = {} as CustomAppearanceColors
for (const key of CUSTOM_APPEARANCE_COLOR_KEYS) {
const value = styles.getPropertyValue(CUSTOM_APPEARANCE_CSS_VARS[key]).trim()
colors[key] = value || DEFAULT_CUSTOM_APPEARANCE[key]
}
return colors
}
Loading
Loading