Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
51 changes: 49 additions & 2 deletions src/main/config/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,44 @@ 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';
export type AppTheme =
| 'dark'
| 'light'
| 'system'
| 'nordic'
| 'tokyo-night'
| 'gruvbox'
| 'catppuccin'
| 'rose-pine'
| 'solarized-light';

/**
* Palettes shipped with the app. Each entry declares whether it is a dark or
* light scheme so the Electron main process can pick the right native window
* background and `nativeTheme.themeSource`.
*/
export const THEME_PALETTES = [
'nordic',
'tokyo-night',
'gruvbox',
'catppuccin',
'rose-pine',
'solarized-light',
] as const;

export const LIGHT_PALETTES = new Set<string>(['solarized-light']);

/** 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]);
}

/** Resolve any AppTheme to its effective light/dark identity. */
export function isLightTheme(theme: AppTheme): boolean {
if (theme === 'light') return true;
if (theme === 'dark' || theme === 'system') return false;
return LIGHT_PALETTES.has(theme);
}
export type ProviderProfileKey =
| 'openrouter'
| 'anthropic'
Expand Down Expand Up @@ -340,7 +377,17 @@ const PROFILE_KEYS: ProviderProfileKey[] = [
'custom:openai',
'custom:gemini',
];
const VALID_THEMES: AppTheme[] = ['dark', 'light', 'system'];
export const VALID_THEMES: AppTheme[] = [
'dark',
'light',
'system',
'nordic',
'tokyo-night',
'gruvbox',
'catppuccin',
'rose-pine',
'solarized-light',
];

function isProviderType(value: unknown): value is ProviderType {
return (
Expand Down
26 changes: 22 additions & 4 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
type AppConfig,
type AppTheme,
type CreateConfigSetPayload,
isPaletteTheme,
isLightTheme,
} from './config/config-store';
import { runConfigApiTest } from './config/config-test-routing';
import { listOllamaModels } from './config/ollama-api';
Expand Down Expand Up @@ -365,18 +367,33 @@ function setupTray() {

function getSavedThemePreference(): AppTheme {
const theme = configStore.get('theme');
return theme === 'dark' || theme === 'system' ? theme : 'light';
// Accept the three classic modes plus the six named palettes.
if (
theme === 'dark' ||
theme === 'light' ||
theme === 'system' ||
isPaletteTheme(theme)
) {
return theme;
}
return 'light';
}

function resolveEffectiveTheme(theme: AppTheme): 'dark' | 'light' {
if (theme === 'system') {
return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
}
return theme;
return isLightTheme(theme) ? 'light' : 'dark';
}

function applyNativeThemePreference(theme: AppTheme): void {
nativeTheme.themeSource = theme;
// nativeTheme only understands dark/light/system; map palettes to their
// underlying mode so native widgets (context menus, scrollbars) match.
if (theme === 'system') {
nativeTheme.themeSource = 'system';
} else {
nativeTheme.themeSource = isLightTheme(theme) ? 'light' : 'dark';
}
}

function createWindow() {
Expand Down Expand Up @@ -2775,7 +2792,8 @@ async function handleClientEvent(event: ClientEvent): Promise<unknown> {
if (
event.payload.theme === 'dark' ||
event.payload.theme === 'light' ||
event.payload.theme === 'system'
event.payload.theme === 'system' ||
isPaletteTheme(event.payload.theme as string)
) {
const nextTheme = event.payload.theme as AppTheme;
configStore.update({ theme: nextTheme });
Expand Down
26 changes: 19 additions & 7 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,15 +96,26 @@ function App() {
}
}, []); // Empty deps - run once

// Apply theme to document root
// Apply theme to document root.
// Built-in modes: 'dark' (no class — :root default), 'light' (.light),
// 'system' (resolves to dark/light via OS preference).
// Named palettes: '.theme-<palette>' overrides the variables entirely.
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', ...THEME_PALETTES.map((p) => `theme-${p}`)];
root.classList.remove(...themeClasses);

if (effectiveTheme === 'light') {
document.documentElement.classList.add('light');
} else {
document.documentElement.classList.remove('light');
const theme = settings.theme;
if (theme === 'system') {
if (!systemDarkMode) {
root.classList.add('light');
}
} else if (theme === 'light') {
root.classList.add('light');
} else if (theme !== 'dark') {
// Named palette (anything other than dark/light/system)
root.classList.add(`theme-${theme}`);
}
}, [settings.theme, systemDarkMode]);

Expand Down
93 changes: 93 additions & 0 deletions src/renderer/components/settings/SettingsGeneral.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store';
import { LIGHT_PALETTES, type AppTheme } from '../../types';

export function SettingsGeneral() {
const { i18n, t } = useTranslation();
Expand Down Expand Up @@ -29,6 +30,47 @@ export function SettingsGeneral() {
{ value: 'system' as const, label: t('general.themeSystem', 'System') },
];

// Named palette themes. Each swatch shows the palette's background + accent
// so users can preview the scheme before selecting it.
const paletteOptions: { value: AppTheme; label: string; bg: string; accent: string }[] = [
{
value: 'nordic',
label: t('general.themeNordic', 'Nordic'),
bg: '#2e3440',
accent: '#88c0d0',
},
{
value: 'tokyo-night',
label: t('general.themeTokyoNight', 'Tokyo Night'),
bg: '#1a1b26',
accent: '#7aa2f7',
},
{
value: 'gruvbox',
label: t('general.themeGruvbox', 'Gruvbox'),
bg: '#282828',
accent: '#d8a657',
},
{
value: 'catppuccin',
label: t('general.themeCatppuccin', 'Catppuccin'),
bg: '#1e1e2e',
accent: '#cba6f7',
},
{
value: 'rose-pine',
label: t('general.themeRosePine', 'Rosé Pine'),
bg: '#191724',
accent: '#c4a7e7',
},
{
value: 'solarized-light',
label: t('general.themeSolarizedLight', 'Solarized Light'),
bg: '#fdf6e3',
accent: '#268bd2',
},
];

return (
<div className="space-y-6">
{/* Theme */}
Expand All @@ -51,6 +93,57 @@ export function SettingsGeneral() {
</div>
</div>

{/* Color palettes */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-text-primary">
{t('general.colorPalette', 'Color palette')}
</h4>
<div className="grid grid-cols-3 gap-2">
{paletteOptions.map((opt) => {
const selected = settings.theme === opt.value;
const isLightSwatch = LIGHT_PALETTES.has(opt.value);
return (
<button
key={opt.value}
onClick={() => updateSettings({ theme: opt.value })}
className={`group relative flex flex-col items-start gap-2 p-2.5 rounded-lg border-2 transition-all ${
selected ? 'border-accent' : 'border-border hover:border-accent/50'
}`}
title={opt.label}
>
{/* Swatch preview */}
<div
className="h-10 w-full rounded-md border border-border-subtle flex items-center justify-center"
style={{ backgroundColor: opt.bg }}
>
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: opt.accent }}
aria-hidden
/>
</div>
<span
className={`text-xs font-medium ${
selected ? 'text-text-primary' : 'text-text-secondary'
}`}
>
{opt.label}
</span>
{selected && (
<span
className={`absolute right-2 top-2 text-[10px] font-semibold px-1.5 py-0.5 rounded ${
isLightSwatch ? 'bg-accent/15 text-accent' : 'bg-accent text-white'
}`}
>
</span>
)}
</button>
);
})}
</div>
</div>

{/* Language */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-text-primary">{t('general.language')}</h4>
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"colorPalette": "Color palette",
"themeNordic": "Nordic",
"themeTokyoNight": "Tokyo Night",
"themeGruvbox": "Gruvbox",
"themeCatppuccin": "Catppuccin",
"themeRosePine": "Rosé Pine",
"themeSolarizedLight": "Solarized Light",
"language": "Language"
},
"memory": {
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@
"themeLight": "浅色",
"themeDark": "深色",
"themeSystem": "跟随系统",
"colorPalette": "配色方案",
"themeNordic": "Nordic 极地",
"themeTokyoNight": "Tokyo Night 东京夜",
"themeGruvbox": "Gruvbox",
"themeCatppuccin": "Catppuccin 摩卡",
"themeRosePine": "Rosé Pine 玫瑰松",
"themeSolarizedLight": "Solarized Light",
"language": "语言"
},
"memory": {
Expand Down
Loading