Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
116 changes: 111 additions & 5 deletions src/main/config/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,68 @@ 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';
}

/**
* Resolve an appearance setting to its effective light/dark identity.
* `systemPrefersDark` is the OS-level preference (nativeTheme.shouldUseDarkColors)
* passed in by the caller so this stays pure.
*/
export function resolveEffectiveAppearance(
appearance: AppAppearance,
systemPrefersDark: boolean
): 'dark' | 'light' {
if (appearance === 'system') return systemPrefersDark ? 'dark' : 'light';
return appearance;
}
export type ProviderProfileKey =
| 'openrouter'
| 'anthropic'
Expand Down Expand Up @@ -109,9 +170,12 @@ 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;

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

Expand Down Expand Up @@ -167,6 +231,7 @@ const DIRECT_READ_KEYS = new Set<keyof AppConfig>([
'globalSkillsPath',
'enableDevLogs',
'theme',
'appearance',
'sandboxEnabled',
'memoryEnabled',
'enableThinking',
Expand Down Expand Up @@ -242,7 +307,8 @@ const defaultConfig: AppConfig = {
defaultWorkdir: '',
globalSkillsPath: '',
enableDevLogs: false,
theme: 'light',
theme: 'claude',
appearance: 'system',
sandboxEnabled: false,
memoryEnabled: true,
memoryRuntime: {
Expand Down Expand Up @@ -340,7 +406,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. */
const LEGACY_PALETTE_MAP: Record<string, AppTheme> = {
'solarized-light': 'solarized',
};

function isProviderType(value: unknown): value is ProviderType {
return (
Expand Down Expand Up @@ -973,7 +1063,19 @@ 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),
sandboxEnabled: toBoolean(raw.sandboxEnabled, defaultConfig.sandboxEnabled),
memoryEnabled: toBoolean(raw.memoryEnabled, defaultConfig.memoryEnabled),
memoryRuntime: normalizeMemoryRuntimeConfig(raw.memoryRuntime),
Expand Down Expand Up @@ -1113,6 +1215,9 @@ export class ConfigStore {
if (key === 'theme' && !isAppTheme(rawValue)) {
return defaultConfig[key];
}
if (key === 'appearance' && !isAppearance(rawValue)) {
return defaultConfig[key];
}
if (
(key === 'enableDevLogs' ||
key === 'sandboxEnabled' ||
Expand Down Expand Up @@ -1388,6 +1493,7 @@ 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,
sandboxEnabled:
updates.sandboxEnabled !== undefined ? updates.sandboxEnabled : current.sandboxEnabled,
memoryEnabled:
Expand Down
74 changes: 74 additions & 0 deletions src/main/config/theme-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @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, 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 {
const KNOWN: AppTheme[] = [
'claude',
'nordic',
'tokyo-night',
'gruvbox',
'catppuccin',
'rose-pine',
'solarized',
];
const LEGACY: Record<string, AppTheme> = { 'solarized-light': 'solarized' };
if ((KNOWN as string[]).includes(raw as string)) return raw as AppTheme;
if (typeof raw === 'string' && LEGACY[raw]) return LEGACY[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;
}
56 changes: 35 additions & 21 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,16 @@ import {
configStore,
getPiAiModelPresets,
type AppConfig,
type AppAppearance,
type AppTheme,
type CreateConfigSetPayload,
isPaletteTheme,
} from './config/config-store';
import {
getSavedAppearance as getSavedAppearanceFromConfig,
resolveEffectiveAppearance as resolveEffectiveAppearancePure,
resolveNativeThemeSource,
} from './config/theme-resolution';
import { runConfigApiTest } from './config/config-test-routing';
import { listOllamaModels } from './config/ollama-api';
import { setPermissionRules } from './config/permission-rules-store';
Expand Down Expand Up @@ -363,26 +370,22 @@ function setupTray() {
});
}

function getSavedThemePreference(): AppTheme {
const theme = configStore.get('theme');
return theme === 'dark' || theme === 'system' ? theme : 'light';
function getSavedAppearance(): AppAppearance {
return getSavedAppearanceFromConfig(configStore.get('appearance'));
}

function resolveEffectiveTheme(theme: AppTheme): 'dark' | 'light' {
if (theme === 'system') {
return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
}
return theme;
function resolveEffectiveAppearance(appearance: AppAppearance): 'dark' | 'light' {
return resolveEffectiveAppearancePure(appearance, nativeTheme.shouldUseDarkColors);
}

function applyNativeThemePreference(theme: AppTheme): void {
nativeTheme.themeSource = theme;
function applyNativeThemePreference(appearance: AppAppearance): void {
nativeTheme.themeSource = resolveNativeThemeSource(appearance);
}

function createWindow() {
const savedTheme = getSavedThemePreference();
applyNativeThemePreference(savedTheme);
const effectiveTheme = resolveEffectiveTheme(savedTheme);
const savedAppearance = getSavedAppearance();
applyNativeThemePreference(savedAppearance);
const effectiveTheme = resolveEffectiveAppearance(savedAppearance);
const THEME =
effectiveTheme === 'dark'
? {
Expand Down Expand Up @@ -895,7 +898,7 @@ app
type: 'native-theme.changed',
payload: { shouldUseDarkColors: nativeTheme.shouldUseDarkColors },
});
if (getSavedThemePreference() === 'system' && mainWindow && !mainWindow.isDestroyed()) {
if (getSavedAppearance() === 'system' && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setBackgroundColor(nativeTheme.shouldUseDarkColors ? DARK_BG : LIGHT_BG);
}
});
Expand Down Expand Up @@ -2772,18 +2775,29 @@ async function handleClientEvent(event: ClientEvent): Promise<unknown> {
}

case 'settings.update':
if (
event.payload.theme === 'dark' ||
event.payload.theme === 'light' ||
event.payload.theme === 'system'
) {
if (typeof event.payload.theme === 'string' && isPaletteTheme(event.payload.theme)) {
const nextTheme = event.payload.theme as AppTheme;
configStore.update({ theme: nextTheme });
applyNativeThemePreference(nextTheme);
}
if (
event.payload.appearance === 'dark' ||
event.payload.appearance === 'light' ||
event.payload.appearance === 'system'
) {
const nextAppearance = event.payload.appearance as AppAppearance;
configStore.update({ appearance: nextAppearance });
applyNativeThemePreference(nextAppearance);
if (mainWindow && !mainWindow.isDestroyed()) {
const effectiveTheme = resolveEffectiveTheme(nextTheme);
const effectiveTheme = resolveEffectiveAppearance(nextAppearance);
mainWindow.setBackgroundColor(effectiveTheme === 'dark' ? DARK_BG : LIGHT_BG);
}
}
if (
(typeof event.payload.theme === 'string' && isPaletteTheme(event.payload.theme)) ||
event.payload.appearance === 'dark' ||
event.payload.appearance === 'light' ||
event.payload.appearance === 'system'
) {
sendToRenderer({
type: 'config.status',
payload: {
Expand Down
26 changes: 17 additions & 9 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { SandboxSyncToast } from './components/SandboxSyncToast';
import { GlobalNoticeToast } from './components/GlobalNoticeToast';
import { PanelErrorBoundary } from './components/PanelErrorBoundary';
import type { AppConfig } from './types';
import { THEME_PALETTES } from './types';
import type { GlobalNoticeAction } from './store';

const ChatView = lazy(() =>
Expand Down Expand Up @@ -95,17 +96,24 @@ function App() {
}
}, []); // Empty deps - run once

// Apply theme to document root
// Apply theme to document root.
// Two orthogonal axes:
// - palette -> `.theme-<palette>` class (e.g. `.theme-gruvbox`)
// - appearance -> `.dark`/`.light` class, with `system` resolved from
// the OS preference (systemDarkMode, supplied by the main process).
// Both classes are applied simultaneously so CSS rules like
// `.theme-gruvbox.light` and `.theme-gruvbox.dark` can target each variant.
useEffect(() => {
const effectiveTheme =
settings.theme === 'system' ? (systemDarkMode ? 'dark' : 'light') : settings.theme;
const root = document.documentElement;
// Clear any previously-applied theme classes first.
const themeClasses = ['light', 'dark', ...THEME_PALETTES.map((p) => `theme-${p}`)];
root.classList.remove(...themeClasses);

if (effectiveTheme === 'light') {
document.documentElement.classList.add('light');
} else {
document.documentElement.classList.remove('light');
}
}, [settings.theme, systemDarkMode]);
root.classList.add(`theme-${settings.theme}`);
const effective =
settings.appearance === 'system' ? (systemDarkMode ? 'dark' : 'light') : settings.appearance;
root.classList.add(effective);
}, [settings.theme, settings.appearance, systemDarkMode]);

// Auto-collapse panels based on window width
useEffect(() => {
Expand Down
Loading