feat: introduced a token-based theming system across the UI

* feat: added themes system

* feat: smart sidebar auto-hide for files/diff tabs + lower files sidebar threshold

* feat: added Checkbox component and update theming

- Add reusable Checkbox component for toggles across UI
- Replace several inputs with Checkbox in settings and commands panels
- Add DiffIcon and apply surface/border theming to key UI areas

* feat: Add convert-vscode-theme.cjs to convert VS Code themes to OpenChamber format

* refactor: remove unused permission logic from ChatInput

- Remove unused permission rules parsing logic from ChatInput
- Memoize renderTheme in DiffWorkerProvider to avoid unnecessary recalculations
- Remove forceOpaque helper in vscode theme adapter

* fix: guard VSCode theme loading in MarkdownRenderer

* feat: add custom user themes loading and reload

- Load user themes from ~/.config/openchamber/themes at runtime
- Expose /api/config/themes to fetch custom themes
- Allow theme reloading from Settings → Theme → Reload themes in the UI
This commit is contained in:
Bohdan Triapitsyn
2026-02-01 18:29:34 +02:00
committed by GitHub
parent 5bb2c56bc0
commit ddedc02687
147 changed files with 9853 additions and 2435 deletions
+57 -12
View File
@@ -5,8 +5,8 @@ import type { SupportedLanguages } from '@pierre/diffs';
import { useOptionalThemeSystem } from './useThemeSystem';
import { workerFactory } from '@/lib/diff/workerFactory';
import { ensureFlexokiThemesRegistered } from '@/lib/shiki/registerFlexokiThemes';
import { flexokiThemeNames } from '@/lib/shiki/flexokiThemes';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import { useGitStore } from '@/stores/useGitStore';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
@@ -31,14 +31,14 @@ const PRELOAD_LANGS: SupportedLanguages[] = [
const WARMUP_MAX_FILES = 10;
// Matches cache key logic in `packages/ui/src/components/views/PierreDiffViewer.tsx`
function getPierreCacheKey(fileName: string, original: string, modified: string): string {
function getPierreCacheKey(fileName: string, original: string, modified: string, themeKey: string): string {
const sampleOriginal = original.length > 100
? `${original.slice(0, 50)}${original.slice(-50)}`
: original;
const sampleModified = modified.length > 100
? `${modified.slice(0, 50)}${modified.slice(-50)}`
: modified;
return `${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`;
return `${themeKey}::${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`;
}
interface DiffWorkerProviderProps {
@@ -57,7 +57,11 @@ function scheduleWarmupWork(cb: (deadline?: IdleDeadlineLike) => void): () => vo
}
// Component that warms up the worker pool and precomputes diff ASTs
const WorkerPoolWarmup: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const WorkerPoolWarmup: React.FC<{
children: React.ReactNode;
themeKey: string;
renderTheme: { light: string; dark: string };
}> = ({ children, themeKey, renderTheme }) => {
const workerPool = useWorkerPool();
const activeDirectory = useGitStore((state) => state.activeDirectory);
const lastStatusChange = useGitStore((state) => {
@@ -72,6 +76,20 @@ const WorkerPoolWarmup: React.FC<{ children: React.ReactNode }> = ({ children })
const didDummyWarmupRef = useRef(false);
const warmedStatusRef = useRef(new Map<string, number>());
useEffect(() => {
warmedStatusRef.current.clear();
}, [themeKey]);
useEffect(() => {
if (!workerPool) {
return;
}
// Important: WorkerPoolContextProvider uses a singleton and does not react to
// prop changes. Update the worker pool render options explicitly.
void workerPool.setRenderOptions({ theme: renderTheme });
}, [renderTheme, workerPool]);
useEffect(() => {
if (!workerPool || didDummyWarmupRef.current) return;
@@ -128,7 +146,7 @@ const WorkerPoolWarmup: React.FC<{ children: React.ReactNode }> = ({ children })
index += 1;
const language = getLanguageFromExtension(filePath) || 'text';
const cacheKey = getPierreCacheKey(filePath, diff.original, diff.modified);
const cacheKey = getPierreCacheKey(filePath, diff.original, diff.modified, themeKey);
const oldFile: FileContents = {
name: filePath,
@@ -171,7 +189,7 @@ const WorkerPoolWarmup: React.FC<{ children: React.ReactNode }> = ({ children })
cancelled = true;
cancelScheduled?.();
};
}, [workerPool, activeDirectory, lastStatusChange, diffCacheSize]);
}, [workerPool, activeDirectory, lastStatusChange, diffCacheSize, themeKey]);
return <>{children}</>;
};
@@ -180,16 +198,40 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
const themeSystem = useOptionalThemeSystem();
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
ensureFlexokiThemesRegistered();
const fallbackLight = getDefaultTheme(false);
const fallbackDark = getDefaultTheme(true);
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLight.metadata.id;
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDark.metadata.id;
const lightTheme =
themeSystem?.availableThemes.find((theme) => theme.metadata.id === lightThemeId) ??
fallbackLight;
const darkTheme =
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
fallbackDark;
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
const highlighterOptions = useMemo(() => ({
theme: {
dark: flexokiThemeNames.dark,
light: flexokiThemeNames.light,
dark: darkTheme.metadata.id,
light: lightTheme.metadata.id,
},
themeType: isDark ? ('dark' as const) : ('light' as const),
langs: PRELOAD_LANGS,
}), [isDark]);
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id]);
const workerThemeKey = `${lightTheme.metadata.id}:${darkTheme.metadata.id}:${isDark ? 'dark' : 'light'}`;
const renderTheme = useMemo(
() => ({
light: lightTheme.metadata.id,
dark: darkTheme.metadata.id,
}),
[darkTheme.metadata.id, lightTheme.metadata.id],
);
return (
<WorkerPoolContextProvider
@@ -200,7 +242,10 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
}}
highlighterOptions={highlighterOptions}
>
<WorkerPoolWarmup>
<WorkerPoolWarmup
themeKey={workerThemeKey}
renderTheme={renderTheme}
>
{children}
</WorkerPoolWarmup>
</WorkerPoolContextProvider>
+179 -45
View File
@@ -6,18 +6,18 @@ import React, {
} from 'react';
import type { Theme, ThemeMode } from '@/types/theme';
import type { DesktopSettings } from '@/lib/desktop';
import { isVSCodeRuntime } from '@/lib/desktop';
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
import { updateDesktopSettings } from '@/lib/persistence';
import {
themes,
getThemeById,
flexokiLightTheme,
flexokiDarkTheme,
getDefaultTheme,
DEFAULT_LIGHT_THEME_ID,
DEFAULT_DARK_THEME_ID,
} from '@/lib/theme/themes';
import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context';
import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter';
import { useUIStore } from '@/stores/useUIStore';
type ThemePreferences = {
themeMode: ThemeMode;
@@ -25,8 +25,8 @@ type ThemePreferences = {
darkThemeId: string;
};
const DEFAULT_LIGHT_ID = flexokiLightTheme.metadata.id;
const DEFAULT_DARK_ID = flexokiDarkTheme.metadata.id;
const DEFAULT_LIGHT_ID = DEFAULT_LIGHT_THEME_ID;
const DEFAULT_DARK_ID = DEFAULT_DARK_THEME_ID;
const getSystemPreference = (): boolean => {
if (typeof window === 'undefined') {
@@ -36,38 +36,83 @@ const getSystemPreference = (): boolean => {
};
const fallbackThemeForVariant = (variant: 'light' | 'dark'): Theme =>
variant === 'dark' ? flexokiDarkTheme : flexokiLightTheme;
const findFallbackThemeId = (variant: 'light' | 'dark'): string => {
const fallback = themes.find((candidate) => candidate.metadata.variant === variant);
return (fallback ?? fallbackThemeForVariant(variant)).metadata.id;
};
const ensureThemeById = (themeId: string, variant: 'light' | 'dark'): Theme => {
const theme = getThemeById(themeId);
if (theme && theme.metadata.variant === variant) {
return theme;
}
const fallback = themes.find((candidate) => candidate.metadata.variant === variant);
return fallback ?? fallbackThemeForVariant(variant);
};
getDefaultTheme(variant === 'dark');
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
const validateThemeId = (themeId: string | null, variant: 'light' | 'dark'): string => {
if (!themeId) {
return variant === 'light' ? DEFAULT_LIGHT_ID : DEFAULT_DARK_ID;
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const getNested = (value: unknown, path: string[]): unknown =>
path.reduce<unknown>((acc, key) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined), value);
const isValidCustomTheme = (value: unknown): value is Theme => {
if (!value || typeof value !== 'object') {
return false;
}
const theme = getThemeById(themeId);
if (theme && theme.metadata.variant === variant) {
return theme.metadata.id;
const requiredPaths = [
['metadata', 'id'],
['metadata', 'name'],
['metadata', 'variant'],
['colors', 'primary', 'base'],
['colors', 'primary', 'foreground'],
['colors', 'surface', 'background'],
['colors', 'surface', 'foreground'],
['colors', 'surface', 'muted'],
['colors', 'surface', 'mutedForeground'],
['colors', 'surface', 'elevated'],
['colors', 'surface', 'elevatedForeground'],
['colors', 'surface', 'subtle'],
['colors', 'interactive', 'border'],
['colors', 'interactive', 'selection'],
['colors', 'interactive', 'selectionForeground'],
['colors', 'interactive', 'focusRing'],
['colors', 'interactive', 'hover'],
['colors', 'status', 'error'],
['colors', 'status', 'errorForeground'],
['colors', 'status', 'errorBackground'],
['colors', 'status', 'errorBorder'],
['colors', 'status', 'warning'],
['colors', 'status', 'warningForeground'],
['colors', 'status', 'warningBackground'],
['colors', 'status', 'warningBorder'],
['colors', 'status', 'success'],
['colors', 'status', 'successForeground'],
['colors', 'status', 'successBackground'],
['colors', 'status', 'successBorder'],
['colors', 'status', 'info'],
['colors', 'status', 'infoForeground'],
['colors', 'status', 'infoBackground'],
['colors', 'status', 'infoBorder'],
['colors', 'syntax', 'base', 'background'],
['colors', 'syntax', 'base', 'foreground'],
['colors', 'syntax', 'base', 'keyword'],
['colors', 'syntax', 'base', 'string'],
['colors', 'syntax', 'base', 'number'],
['colors', 'syntax', 'base', 'function'],
['colors', 'syntax', 'base', 'variable'],
['colors', 'syntax', 'base', 'type'],
['colors', 'syntax', 'base', 'comment'],
['colors', 'syntax', 'base', 'operator'],
['colors', 'syntax', 'highlights', 'diffAdded'],
['colors', 'syntax', 'highlights', 'diffRemoved'],
['colors', 'syntax', 'highlights', 'lineNumber'],
];
for (const path of requiredPaths) {
if (!isNonEmptyString(getNested(value, path))) {
return false;
}
}
return findFallbackThemeId(variant);
const variant = getNested(value, ['metadata', 'variant']);
return variant === 'light' || variant === 'dark';
};
const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
let lightThemeId = DEFAULT_LIGHT_ID;
let darkThemeId = DEFAULT_DARK_ID;
let lightThemeId: string = DEFAULT_LIGHT_ID;
let darkThemeId: string = DEFAULT_DARK_ID;
let themeMode: ThemeMode = 'system';
if (typeof window !== 'undefined') {
@@ -99,8 +144,13 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
themeMode = legacyVariant;
}
lightThemeId = validateThemeId(storedLightId, 'light');
darkThemeId = validateThemeId(storedDarkId, 'dark');
if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) {
lightThemeId = storedLightId.trim();
}
if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) {
darkThemeId = storedDarkId.trim();
}
}
if (defaultThemeId) {
@@ -130,6 +180,8 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
const cssGenerator = useMemo(() => new CSSVariableGenerator(), []);
const [preferences, setPreferences] = useState<ThemePreferences>(() => buildInitialPreferences(defaultThemeId));
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getSystemPreference());
const [customThemes, setCustomThemes] = useState<Theme[]>([]);
const [customThemesLoading, setCustomThemesLoading] = useState(false);
const [vscodeTheme, setVSCodeTheme] = useState<Theme | null>(() => {
if (typeof window === 'undefined' || !isVSCodeRuntime()) {
return null;
@@ -138,6 +190,47 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return existing || null;
});
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
const isDesktop = useMemo(() => isDesktopRuntime(), []);
const availableThemes = useMemo(() => {
const merged: Theme[] = [];
const seen = new Set<string>();
const add = (theme: Theme) => {
const id = theme.metadata.id;
if (seen.has(id)) return;
seen.add(id);
merged.push(theme);
};
if (isVSCode && vscodeTheme) {
add(vscodeTheme);
}
// Custom themes first so they can override built-ins with the same id.
customThemes.forEach(add);
themes.forEach(add);
return merged;
}, [customThemes, isVSCode, vscodeTheme]);
const getThemeByIdFromAvailable = useCallback(
(themeId: string): Theme | undefined => availableThemes.find((theme) => theme.metadata.id === themeId),
[availableThemes],
);
const ensureThemeById = useCallback(
(themeId: string, variant: 'light' | 'dark'): Theme => {
const theme = getThemeByIdFromAvailable(themeId);
if (theme && theme.metadata.variant === variant) {
return theme;
}
const fallback = availableThemes.find((candidate) => candidate.metadata.variant === variant);
return fallback ?? fallbackThemeForVariant(variant);
},
[availableThemes, getThemeByIdFromAvailable],
);
const currentTheme = useMemo(() => {
if (isVSCode && vscodeTheme) {
@@ -152,12 +245,46 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return systemPrefersDark
? ensureThemeById(preferences.darkThemeId, 'dark')
: ensureThemeById(preferences.lightThemeId, 'light');
}, [isVSCode, preferences, systemPrefersDark, vscodeTheme]);
}, [ensureThemeById, isVSCode, preferences, systemPrefersDark, vscodeTheme]);
const availableThemes = useMemo(
() => (isVSCode && vscodeTheme ? [vscodeTheme, ...themes] : themes),
[isVSCode, vscodeTheme],
);
const reloadCustomThemes = useCallback(async () => {
if (typeof window === 'undefined' || isVSCode) {
return;
}
setCustomThemesLoading(true);
try {
const res = await fetch('/api/config/themes', {
method: 'GET',
credentials: isDesktop ? 'omit' : 'include',
headers: {
Accept: 'application/json',
},
});
if (res.status === 401) {
// UI auth gate will handle prompting; avoid noisy retries here.
return;
}
if (!res.ok) {
return;
}
const payload = await res.json();
const incoming = Array.isArray(payload?.themes) ? payload.themes : [];
const normalized = incoming.filter(isValidCustomTheme);
setCustomThemes(normalized);
} catch {
// ignore
} finally {
setCustomThemesLoading(false);
}
}, [isDesktop, isVSCode]);
useEffect(() => {
void reloadCustomThemes();
}, [reloadCustomThemes]);
useEffect(() => {
if (!isVSCode) {
@@ -166,11 +293,6 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
const applyVSCodeTheme = (theme: Theme) => {
setVSCodeTheme(theme);
const variant: ThemeMode = theme.metadata.variant === 'dark' ? 'dark' : 'light';
const uiStore = useUIStore.getState();
if (uiStore.theme !== variant) {
uiStore.setTheme(variant);
}
};
const handleThemeEvent = (event: Event) => {
@@ -270,7 +392,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
'selectedThemeVariant',
currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
);
}, [preferences, currentTheme]);
// Splash screen (packages/web/index.html) runs before the theme CSS vars load.
// Persist just enough to theme it on next boot.
const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
localStorage.setItem('splashBgLight', lightTheme.colors.surface.background);
localStorage.setItem('splashFgLight', lightTheme.colors.surface.foreground);
localStorage.setItem('splashBgDark', darkTheme.colors.surface.background);
localStorage.setItem('splashFgDark', darkTheme.colors.surface.foreground);
}, [preferences, currentTheme, ensureThemeById]);
useEffect(() => {
void updateDesktopSettings({
@@ -304,12 +436,12 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
let nextLight = prev.lightThemeId;
if (typeof detail.lightThemeId === 'string' && detail.lightThemeId.length > 0) {
nextLight = validateThemeId(detail.lightThemeId, 'light');
nextLight = detail.lightThemeId.trim();
}
let nextDark = prev.darkThemeId;
if (typeof detail.darkThemeId === 'string' && detail.darkThemeId.length > 0) {
nextDark = validateThemeId(detail.darkThemeId, 'dark');
nextDark = detail.darkThemeId.trim();
}
const same =
@@ -458,6 +590,8 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
currentTheme,
availableThemes,
setTheme,
customThemesLoading,
reloadCustomThemes,
isSystemPreference: preferences.themeMode === 'system',
setSystemPreference: setSystemPreferenceHandler,
themeMode: preferences.themeMode,
@@ -6,6 +6,8 @@ export interface ThemeContextValue {
currentTheme: Theme;
availableThemes: Theme[];
setTheme: (themeId: string) => void;
customThemesLoading: boolean;
reloadCustomThemes: () => Promise<void>;
isSystemPreference: boolean;
setSystemPreference: (use: boolean) => void;
themeMode: ThemeMode;