Files
openchamber/packages/ui/src/contexts/ThemeSystemContext.tsx
T

477 lines
15 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import React, {
useEffect,
useMemo,
useCallback,
useState,
} from 'react';
import type { Theme, ThemeMode } from '@/types/theme';
import type { DesktopSettings } from '@/lib/desktop';
import { isVSCodeRuntime } from '@/lib/desktop';
2025-12-07 19:32:53 +02:00
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
import { updateDesktopSettings } from '@/lib/persistence';
import {
themes,
getThemeById,
flexokiLightTheme,
flexokiDarkTheme,
} from '@/lib/theme/themes';
import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context';
2025-12-13 16:34:17 +02:00
import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter';
import { useUIStore } from '@/stores/useUIStore';
2025-12-07 19:32:53 +02:00
type ThemePreferences = {
themeMode: ThemeMode;
lightThemeId: string;
darkThemeId: string;
};
const DEFAULT_LIGHT_ID = flexokiLightTheme.metadata.id;
const DEFAULT_DARK_ID = flexokiDarkTheme.metadata.id;
const getSystemPreference = (): boolean => {
if (typeof window === 'undefined') {
return true;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
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);
};
2025-12-13 16:34:17 +02:00
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
2025-12-07 19:32:53 +02:00
const validateThemeId = (themeId: string | null, variant: 'light' | 'dark'): string => {
if (!themeId) {
return variant === 'light' ? DEFAULT_LIGHT_ID : DEFAULT_DARK_ID;
}
const theme = getThemeById(themeId);
if (theme && theme.metadata.variant === variant) {
return theme.metadata.id;
}
return findFallbackThemeId(variant);
};
const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
let lightThemeId = DEFAULT_LIGHT_ID;
let darkThemeId = DEFAULT_DARK_ID;
let themeMode: ThemeMode = 'system';
if (typeof window !== 'undefined') {
const storedMode = localStorage.getItem('themeMode');
const storedLightId = localStorage.getItem('lightThemeId');
const storedDarkId = localStorage.getItem('darkThemeId');
const legacyUseSystem = localStorage.getItem('useSystemTheme');
const legacyThemeId = localStorage.getItem('selectedThemeId');
const legacyVariant = localStorage.getItem('selectedThemeVariant');
if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') {
themeMode = storedMode;
} else if (legacyUseSystem !== null) {
const useSystem = legacyUseSystem === 'true';
if (useSystem) {
themeMode = 'system';
} else if (legacyThemeId) {
const legacyTheme = getThemeById(legacyThemeId);
if (legacyTheme) {
themeMode = legacyTheme.metadata.variant === 'dark' ? 'dark' : 'light';
if (legacyTheme.metadata.variant === 'dark') {
darkThemeId = legacyTheme.metadata.id;
} else {
lightThemeId = legacyTheme.metadata.id;
}
}
}
} else if (legacyVariant === 'light' || legacyVariant === 'dark') {
themeMode = legacyVariant;
}
lightThemeId = validateThemeId(storedLightId, 'light');
darkThemeId = validateThemeId(storedDarkId, 'dark');
}
if (defaultThemeId) {
const defaultTheme = getThemeById(defaultThemeId);
if (defaultTheme) {
if (defaultTheme.metadata.variant === 'light') {
lightThemeId = defaultTheme.metadata.id;
} else {
darkThemeId = defaultTheme.metadata.id;
}
}
}
return {
themeMode,
lightThemeId,
darkThemeId,
};
};
interface ThemeSystemProviderProps {
children: React.ReactNode;
defaultThemeId?: string;
}
export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemProviderProps) {
const cssGenerator = useMemo(() => new CSSVariableGenerator(), []);
const [preferences, setPreferences] = useState<ThemePreferences>(() => buildInitialPreferences(defaultThemeId));
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getSystemPreference());
2025-12-13 16:34:17 +02:00
const [vscodeTheme, setVSCodeTheme] = useState<Theme | null>(() => {
if (typeof window === 'undefined' || !isVSCodeRuntime()) {
return null;
}
const existing = (window as unknown as { __OPENCHAMBER_VSCODE_THEME__?: Theme }).__OPENCHAMBER_VSCODE_THEME__;
return existing || null;
});
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
2025-12-07 19:32:53 +02:00
const currentTheme = useMemo(() => {
2025-12-13 16:34:17 +02:00
if (isVSCode && vscodeTheme) {
return vscodeTheme;
}
2025-12-07 19:32:53 +02:00
if (preferences.themeMode === 'light') {
return ensureThemeById(preferences.lightThemeId, 'light');
}
if (preferences.themeMode === 'dark') {
return ensureThemeById(preferences.darkThemeId, 'dark');
}
return systemPrefersDark
? ensureThemeById(preferences.darkThemeId, 'dark')
: ensureThemeById(preferences.lightThemeId, 'light');
2025-12-13 16:34:17 +02:00
}, [isVSCode, preferences, systemPrefersDark, vscodeTheme]);
const availableThemes = useMemo(
() => (isVSCode && vscodeTheme ? [vscodeTheme, ...themes] : themes),
[isVSCode, vscodeTheme],
);
useEffect(() => {
if (!isVSCode) {
return;
}
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);
}
};
2025-12-07 19:32:53 +02:00
2025-12-13 16:34:17 +02:00
const handleThemeEvent = (event: Event) => {
const detail = (event as CustomEvent<VSCodeThemePayload>).detail;
if (detail?.theme) {
applyVSCodeTheme(detail.theme);
}
};
const existing = (window as unknown as { __OPENCHAMBER_VSCODE_THEME__?: Theme }).__OPENCHAMBER_VSCODE_THEME__;
if (existing) {
applyVSCodeTheme(existing);
}
window.addEventListener('openchamber:vscode-theme', handleThemeEvent as EventListener);
return () => window.removeEventListener('openchamber:vscode-theme', handleThemeEvent as EventListener);
}, [isVSCode]);
2025-12-07 19:32:53 +02:00
const updateBrowserChrome = useCallback((theme: Theme) => {
if (typeof document === 'undefined') {
return;
}
const chromeColor = theme.colors.surface.background;
document.body.style.backgroundColor = chromeColor;
2025-12-07 19:32:53 +02:00
let metaThemeColor = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement;
if (!metaThemeColor) {
metaThemeColor = document.createElement('meta');
metaThemeColor.setAttribute('name', 'theme-color');
document.head.appendChild(metaThemeColor);
}
metaThemeColor.setAttribute('content', chromeColor);
const mediaQuery =
theme.metadata.variant === 'dark'
? '(prefers-color-scheme: dark)'
: '(prefers-color-scheme: light)';
let metaThemeColorMedia = document.querySelector(
`meta[name="theme-color"][media="${mediaQuery}"]`,
) as HTMLMetaElement;
if (!metaThemeColorMedia) {
metaThemeColorMedia = document.createElement('meta');
metaThemeColorMedia.setAttribute('name', 'theme-color');
metaThemeColorMedia.setAttribute('media', mediaQuery);
document.head.appendChild(metaThemeColorMedia);
}
metaThemeColorMedia.setAttribute('content', chromeColor);
}, []);
2025-12-13 16:34:17 +02:00
const applyVSCodeRuntimeClass = useCallback((enabled: boolean) => {
if (typeof document === 'undefined') {
return;
}
document.documentElement.classList.toggle('vscode-runtime', enabled);
}, []);
useIsomorphicLayoutEffect(() => {
2025-12-07 19:32:53 +02:00
if (typeof window === 'undefined') {
return;
}
cssGenerator.apply(currentTheme);
2025-12-13 16:34:17 +02:00
applyVSCodeRuntimeClass(isVSCode);
2025-12-07 19:32:53 +02:00
updateBrowserChrome(currentTheme);
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(currentTheme.metadata.variant);
2025-12-13 16:34:17 +02:00
}, [applyVSCodeRuntimeClass, cssGenerator, currentTheme, isVSCode, updateBrowserChrome]);
2025-12-07 19:32:53 +02:00
useEffect(() => {
if (preferences.themeMode !== 'system' || typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => {
setSystemPrefersDark(event.matches);
};
setSystemPrefersDark(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [preferences.themeMode]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
localStorage.setItem('themeMode', preferences.themeMode);
localStorage.setItem('lightThemeId', preferences.lightThemeId);
localStorage.setItem('darkThemeId', preferences.darkThemeId);
localStorage.setItem('useSystemTheme', String(preferences.themeMode === 'system'));
localStorage.setItem('selectedThemeId', currentTheme.metadata.id);
localStorage.setItem(
'selectedThemeVariant',
currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
);
}, [preferences, currentTheme]);
useEffect(() => {
void updateDesktopSettings({
themeId: currentTheme.metadata.id,
themeVariant: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
useSystemTheme: preferences.themeMode === 'system',
lightThemeId: preferences.lightThemeId,
darkThemeId: preferences.darkThemeId,
});
}, [currentTheme.metadata.id, currentTheme.metadata.variant, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleSettingsSynced = (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).detail;
if (!detail) {
return;
}
setPreferences((prev) => {
let nextMode = prev.themeMode;
if (detail.useSystemTheme === true) {
nextMode = 'system';
} else if (detail.useSystemTheme === false) {
if (detail.themeVariant === 'dark' || detail.themeVariant === 'light') {
nextMode = detail.themeVariant;
}
}
let nextLight = prev.lightThemeId;
if (typeof detail.lightThemeId === 'string' && detail.lightThemeId.length > 0) {
nextLight = validateThemeId(detail.lightThemeId, 'light');
}
let nextDark = prev.darkThemeId;
if (typeof detail.darkThemeId === 'string' && detail.darkThemeId.length > 0) {
nextDark = validateThemeId(detail.darkThemeId, 'dark');
}
const same =
nextMode === prev.themeMode &&
nextLight === prev.lightThemeId &&
nextDark === prev.darkThemeId;
if (same) {
return prev;
}
return {
themeMode: nextMode,
lightThemeId: nextLight,
darkThemeId: nextDark,
};
});
};
window.addEventListener('openchamber:settings-synced', handleSettingsSynced);
return () => window.removeEventListener('openchamber:settings-synced', handleSettingsSynced);
}, []);
const setTheme = useCallback(
(themeId: string) => {
const theme = availableThemes.find((candidate) => candidate.metadata.id === themeId);
if (!theme) {
return;
}
setPreferences((prev) => {
if (theme.metadata.variant === 'dark') {
if (prev.darkThemeId === theme.metadata.id && prev.themeMode === 'dark') {
return prev;
}
return {
...prev,
darkThemeId: theme.metadata.id,
themeMode: 'dark',
};
}
if (prev.lightThemeId === theme.metadata.id && prev.themeMode === 'light') {
return prev;
}
return {
...prev,
lightThemeId: theme.metadata.id,
themeMode: 'light',
};
});
},
[availableThemes],
);
const setThemeModeHandler = useCallback((mode: ThemeMode) => {
setPreferences((prev) => {
if (prev.themeMode === mode) {
return prev;
}
return {
...prev,
themeMode: mode,
};
});
}, []);
const setSystemPreferenceHandler = useCallback(
(use: boolean) => {
if (use) {
setPreferences((prev) => {
if (prev.themeMode === 'system') {
return prev;
}
return {
...prev,
themeMode: 'system',
};
});
return;
}
const fallbackMode: ThemeMode =
currentTheme.metadata.variant === 'dark' ? 'dark' : 'light';
setPreferences((prev) => {
if (prev.themeMode === fallbackMode) {
return prev;
}
return {
...prev,
themeMode: fallbackMode,
};
});
},
[currentTheme.metadata.variant],
);
const setLightThemePreference = useCallback(
(themeId: string) => {
const theme = availableThemes.find(
(candidate) =>
candidate.metadata.id === themeId && candidate.metadata.variant === 'light',
);
if (!theme) {
return;
}
setPreferences((prev) => {
if (prev.lightThemeId === theme.metadata.id) {
return prev;
}
return {
...prev,
lightThemeId: theme.metadata.id,
};
});
},
[availableThemes],
);
const setDarkThemePreference = useCallback(
(themeId: string) => {
const theme = availableThemes.find(
(candidate) =>
candidate.metadata.id === themeId && candidate.metadata.variant === 'dark',
);
if (!theme) {
return;
}
setPreferences((prev) => {
if (prev.darkThemeId === theme.metadata.id) {
return prev;
}
return {
...prev,
darkThemeId: theme.metadata.id,
};
});
},
[availableThemes],
);
const value: ThemeContextValue = {
currentTheme,
availableThemes,
setTheme,
isSystemPreference: preferences.themeMode === 'system',
setSystemPreference: setSystemPreferenceHandler,
themeMode: preferences.themeMode,
setThemeMode: setThemeModeHandler,
lightThemeId: preferences.lightThemeId,
darkThemeId: preferences.darkThemeId,
setLightThemePreference,
setDarkThemePreference,
};
return (
<ThemeSystemContext.Provider value={value}>
{children}
</ThemeSystemContext.Provider>
);
}