Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fbcc620
feat(themes): add 6 selectable color palettes (Nord, Tokyo Night, Gru…
Jun 14, 2026
a042f1d
refactor(themes): address review nits, add unit tests
Jun 14, 2026
0bc16a9
test(themes): extract theme resolution, cover integration points
Jun 14, 2026
a25c300
feat(themes): split palette/appearance into orthogonal axes with per-…
Jun 15, 2026
faff068
fix(themes): migrate legacy solarized-light palette to new solarized id
Jun 15, 2026
e8e8a3b
fix(themes): eliminate theme-switch FOUC and dedupe palette list in g…
Jun 15, 2026
a098fbd
feat(themes): add font family + size pickers, General-on-top, dedupe …
Jun 15, 2026
6dce6dc
feat(themes): import Obsidian community themes (.css) with var mapping
Jun 15, 2026
68d1b50
fix(themes): apply font family via inline style to win CSS specificity
Jun 15, 2026
4fb71e0
fix(themes): whitelist obsidianTheme.select in preload bridge
Jun 15, 2026
42de1ce
feat(themes): add 'Browse Obsidian themes online' link
Jun 15, 2026
2757cc8
fix(themes): point Obsidian browse link at live gallery URL
Jun 15, 2026
09f0e41
feat(themes): multi-import Obsidian themes, font bundling, dedup stacks
Jun 15, 2026
036ec74
feat: remove Obsidian theme integration
Jun 15, 2026
7e5c90f
feat(themes): custom logo, font, and per-element color picker
Jun 15, 2026
3116403
feat(themes): clear customColors on palette switch + add 4 new palettes
Jun 16, 2026
39c6ded
fix(themes): validate customColors keys, drop unused i18n 'clear' key
Jun 16, 2026
0e9cf14
feat(welcome): localized time-based greeting with name + daily rotation
Jun 16, 2026
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
180 changes: 175 additions & 5 deletions src/main/config/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,95 @@ import { API_PROVIDER_PRESETS, PI_AI_CURATED_PRESETS } from '../../shared/api-mo
*/
export type ProviderType = 'openrouter' | 'anthropic' | 'custom' | 'openai' | 'gemini' | 'ollama';
export type CustomProtocolType = 'anthropic' | 'openai' | 'gemini';
export type AppTheme = 'dark' | 'light' | 'system';

/**
* Named color palettes. Each palette ships BOTH a light and a dark variant;
* which one is rendered is decided by the orthogonal `AppAppearance` setting
* (resolved against the OS preference when `appearance === 'system'`).
*
* 'claude' is the default palette and matches the historical app look.
*/
export type AppTheme =
| 'claude'
| 'nordic'
| 'tokyo-night'
| 'gruvbox'
| 'catppuccin'
| 'rose-pine'
| 'solarized';

/**
* Orthogonal to `AppTheme`: which light/dark variant of the selected palette
* should be rendered. `system` defers to the OS dark-color preference.
*/
export type AppAppearance = 'dark' | 'light' | 'system';

/**
* Palettes shipped with the app. Order here is the order shown in the
* Settings swatch grid.
*/
export const THEME_PALETTES = [
'claude',
'nordic',
'tokyo-night',
'gruvbox',
'catppuccin',
'rose-pine',
'solarized',
] as const;

/** Appearance modes the user can pick alongside a palette. */
export const VALID_APPEARANCES: AppAppearance[] = ['dark', 'light', 'system'];

/** Returns true when a theme id is one of the built-in named palettes. */
export function isPaletteTheme(theme: string): theme is (typeof THEME_PALETTES)[number] {
return THEME_PALETTES.includes(theme as (typeof THEME_PALETTES)[number]);
}

/** Type guard for an AppAppearance value. */
export function isAppearance(value: unknown): value is AppAppearance {
return value === 'dark' || value === 'light' || value === 'system';
}

// Note: resolveEffectiveAppearance lives in theme-resolution.ts (single source
// of truth) so it can be unit-tested without booting Electron.

/**
* Font family presets the user can pick in Settings, independent of the
* palette. Each id maps to a stack defined in globals.css via the
* `--font-sans-<id>` / `--font-serif-<id>` / `--font-mono-<id>` variables.
* 'auto' means "inherit from the active palette" (the default).
*/
export type FontFamily = 'auto' | 'sans' | 'serif' | 'mono' | 'rounded' | 'condensed' | 'system';

export const VALID_FONT_FAMILIES: FontFamily[] = [
'auto',
'sans',
'serif',
'mono',
'rounded',
'condensed',
'system',
];

/** Type guard for a FontFamily value. */
export function isFontFamily(value: unknown): value is FontFamily {
return typeof value === 'string' && (VALID_FONT_FAMILIES as string[]).includes(value);
}

/**
* Base font size presets. The renderer maps these onto a CSS custom property
* (`--font-scale`) that scales the root font size.
*/
export type FontSize = 'sm' | 'md' | 'lg' | 'xl';

export const VALID_FONT_SIZES: FontSize[] = ['sm', 'md', 'lg', 'xl'];

/** Type guard for a FontSize value. */
export function isFontSize(value: unknown): value is FontSize {
return typeof value === 'string' && (VALID_FONT_SIZES as string[]).includes(value);
}

export type ProviderProfileKey =
| 'openrouter'
| 'anthropic'
Expand Down Expand Up @@ -109,9 +197,25 @@ export interface AppConfig {
// Developer logs
enableDevLogs: boolean;

// UI theme preference
// UI palette preference (which color palette)
theme: AppTheme;

// Orthogonal light/dark/system mode applied on top of the palette
appearance: AppAppearance;

// Font family preset ('auto' inherits from the active palette)
fontFamily: FontFamily;

// Base font size preset (scales --font-scale)
fontSize: FontSize;

// Optional Obsidian community-theme CSS to inject into the document. When
// non-empty, App.tsx writes it into a <style id="obsidian-theme"> tag; the
// Obsidian variable aliases in globals.css map our --color-* vars onto the
// variable names Obsidian themes target (--background-primary, etc.) so the
// imported theme's overrides resolve. Empty string = no theme.
obsidianThemeCss: string;

// Sandbox mode (WSL/Lima isolation)
sandboxEnabled: boolean;

Expand Down Expand Up @@ -167,6 +271,10 @@ const DIRECT_READ_KEYS = new Set<keyof AppConfig>([
'globalSkillsPath',
'enableDevLogs',
'theme',
'appearance',
'fontFamily',
'fontSize',
'obsidianThemeCss',
'sandboxEnabled',
'memoryEnabled',
'enableThinking',
Expand Down Expand Up @@ -242,7 +350,11 @@ const defaultConfig: AppConfig = {
defaultWorkdir: '',
globalSkillsPath: '',
enableDevLogs: false,
theme: 'light',
theme: 'claude',
appearance: 'system',
fontFamily: 'auto',
fontSize: 'md',
obsidianThemeCss: '',
sandboxEnabled: false,
memoryEnabled: true,
memoryRuntime: {
Expand Down Expand Up @@ -340,7 +452,31 @@ const PROFILE_KEYS: ProviderProfileKey[] = [
'custom:openai',
'custom:gemini',
];
const VALID_THEMES: AppTheme[] = ['dark', 'light', 'system'];
export const VALID_THEMES: AppTheme[] = [
'claude',
'nordic',
'tokyo-night',
'gruvbox',
'catppuccin',
'rose-pine',
'solarized',
];

/** Legacy theme ids persisted before the palette/appearance split. Mapped
* to the new two-axis model by the normalizer so existing users keep a
* working theme without touching their config file. */
const LEGACY_THEME_TO_APPEARANCE: Record<string, AppAppearance> = {
dark: 'dark',
light: 'light',
system: 'system',
};

/** Legacy palette ids renamed during the palette/appearance split. Maps
* the old id to the new id so existing users keep their chosen palette
* instead of falling through to the default. */
export const LEGACY_PALETTE_MAP: Record<string, AppTheme> = {
'solarized-light': 'solarized',
};

function isProviderType(value: unknown): value is ProviderType {
return (
Expand Down Expand Up @@ -973,7 +1109,25 @@ export class ConfigStore {
? raw.globalSkillsPath
: defaultConfig.globalSkillsPath,
enableDevLogs: toBoolean(raw.enableDevLogs, defaultConfig.enableDevLogs),
theme: isAppTheme(raw.theme) ? raw.theme : defaultConfig.theme,
// Backward compat: a pre-split config persisted theme as 'dark'/'light'/'system'
// (a mode, not a palette) or under a renamed id ('solarized-light').
// Map both onto the new two-axis model:
// - legacy mode -> palette 'claude', appearance = old mode
// - renamed id -> new palette id, appearance left at its default
theme: isAppTheme(raw.theme)
? raw.theme
: (LEGACY_PALETTE_MAP[typeof raw.theme === 'string' ? raw.theme : ''] ??
defaultConfig.theme),
appearance: isAppearance(raw.appearance)
? raw.appearance
: (LEGACY_THEME_TO_APPEARANCE[typeof raw.theme === 'string' ? raw.theme : ''] ??
defaultConfig.appearance),
fontFamily: isFontFamily(raw.fontFamily) ? raw.fontFamily : defaultConfig.fontFamily,
fontSize: isFontSize(raw.fontSize) ? raw.fontSize : defaultConfig.fontSize,
obsidianThemeCss:
typeof raw.obsidianThemeCss === 'string'
? raw.obsidianThemeCss
: defaultConfig.obsidianThemeCss,
sandboxEnabled: toBoolean(raw.sandboxEnabled, defaultConfig.sandboxEnabled),
memoryEnabled: toBoolean(raw.memoryEnabled, defaultConfig.memoryEnabled),
memoryRuntime: normalizeMemoryRuntimeConfig(raw.memoryRuntime),
Expand Down Expand Up @@ -1113,6 +1267,15 @@ export class ConfigStore {
if (key === 'theme' && !isAppTheme(rawValue)) {
return defaultConfig[key];
}
if (key === 'appearance' && !isAppearance(rawValue)) {
return defaultConfig[key];
}
if (key === 'fontFamily' && !isFontFamily(rawValue)) {
return defaultConfig[key];
}
if (key === 'fontSize' && !isFontSize(rawValue)) {
return defaultConfig[key];
}
if (
(key === 'enableDevLogs' ||
key === 'sandboxEnabled' ||
Expand Down Expand Up @@ -1388,6 +1551,13 @@ export class ConfigStore {
enableDevLogs:
updates.enableDevLogs !== undefined ? updates.enableDevLogs : current.enableDevLogs,
theme: updates.theme !== undefined ? updates.theme : current.theme,
appearance: updates.appearance !== undefined ? updates.appearance : current.appearance,
fontFamily: updates.fontFamily !== undefined ? updates.fontFamily : current.fontFamily,
fontSize: updates.fontSize !== undefined ? updates.fontSize : current.fontSize,
obsidianThemeCss:
updates.obsidianThemeCss !== undefined
? updates.obsidianThemeCss
: current.obsidianThemeCss,
sandboxEnabled:
updates.sandboxEnabled !== undefined ? updates.sandboxEnabled : current.sandboxEnabled,
memoryEnabled:
Expand Down
70 changes: 70 additions & 0 deletions src/main/config/theme-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @module main/config/theme-resolution
*
* Pure helpers that map persisted theme settings onto the values the
* Electron main process needs:
*
* - the effective light/dark identity (for window background color)
* - the nativeTheme.themeSource value (dark | light | system)
*
* The theme model has two orthogonal axes:
* - `theme` (AppTheme): which color palette ('claude', 'nordic', ...)
* - `appearance` (AppAppearance): dark / light / system
*
* The palette choice has no effect on light/dark anymore — that's purely the
* appearance axis. The palette only affects which CSS variables the renderer
* loads (handled in the renderer via `.theme-<palette>` + `.light`/`.dark`
* classes on <html>).
*
* Extracted from main/index.ts so these can be unit-tested without booting
* the full Electron main process. The only impure input — the OS dark-color
* preference, used to resolve 'system' — is passed in as an argument.
*/
import {
isAppearance,
LEGACY_PALETTE_MAP,
THEME_PALETTES,
type AppAppearance,
type AppTheme,
} from './config-store';

/**
* Normalize a persisted appearance value. Accepts known modes; otherwise
* falls back to 'system' (matching the default).
*/
export function getSavedAppearance(raw: unknown): AppAppearance {
return isAppearance(raw) ? raw : 'system';
}

/**
* Normalize a persisted palette (theme) value. Accepts known palettes;
* otherwise maps any renamed legacy id ('solarized-light' -> 'solarized')
* and finally falls back to 'claude' (the default).
*/
export function getSavedPalette(raw: AppTheme | string | undefined): AppTheme {
if ((THEME_PALETTES as readonly string[]).includes(raw as string)) return raw as AppTheme;
if (typeof raw === 'string' && LEGACY_PALETTE_MAP[raw]) return LEGACY_PALETTE_MAP[raw];
return 'claude';
}

/**
* Resolve an appearance setting to its effective light/dark identity.
* `systemPrefersDark` is the OS-level preference (nativeTheme.shouldUseDarkColors)
* passed in by the caller to keep this function pure.
*/
export function resolveEffectiveAppearance(
appearance: AppAppearance,
systemPrefersDark: boolean
): 'dark' | 'light' {
if (appearance === 'system') return systemPrefersDark ? 'dark' : 'light';
return appearance;
}

/**
* Map an appearance setting to the value nativeTheme.themeSource understands.
* The palette axis is irrelevant here — native widgets only care about
* light/dark/system.
*/
export function resolveNativeThemeSource(appearance: AppAppearance): 'system' | 'dark' | 'light' {
return appearance;
}
Loading