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:
committed by
GitHub
parent
5bb2c56bc0
commit
ddedc02687
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user