fix: sync context panel iframe themes
Keeps embedded sessions aligned with parent theme Prevents iframe reloads on theme changes Supports theme hotkeys from focused iframes
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { getInitialSystemPreference } from './theme-embedded-bootstrap';
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
const installWindow = (search: string, matchMediaDark: boolean) => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
location: {
|
||||
search,
|
||||
},
|
||||
matchMedia: () => ({ matches: matchMediaDark }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
installWindow('', false);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: originalWindow,
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThemeSystemProvider embedded bootstrap', () => {
|
||||
test('uses parent effective variant for embedded system theme before iframe matchMedia', () => {
|
||||
installWindow('?ocPanel=session-chat&themeMode=system&themeVariant=dark', false);
|
||||
|
||||
expect(getInitialSystemPreference()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context';
|
||||
import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getInitialSystemPreference, readEmbeddedThemeSearchParams } from './theme-embedded-bootstrap';
|
||||
import { isValidTheme } from './theme-validation';
|
||||
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
|
||||
|
||||
type ThemePreferences = {
|
||||
themeMode: ThemeMode;
|
||||
@@ -38,11 +41,18 @@ type ThemeSyncPayload = {
|
||||
const DEFAULT_LIGHT_ID = DEFAULT_LIGHT_THEME_ID;
|
||||
const DEFAULT_DARK_ID = DEFAULT_DARK_THEME_ID;
|
||||
|
||||
const getSystemPreference = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
const readEmbeddedCurrentTheme = (): Theme | null => {
|
||||
const raw = readEmbeddedThemeSearchParams()?.get('currentTheme');
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return isValidTheme(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const fallbackThemeForVariant = (variant: 'light' | 'dark'): Theme =>
|
||||
@@ -70,87 +80,16 @@ const suppressTransitionsForThemeSwitch = () => {
|
||||
};
|
||||
};
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
const variant = getNested(value, ['metadata', 'variant']);
|
||||
return variant === 'light' || variant === 'dark';
|
||||
};
|
||||
|
||||
const getSyncedThemeVariant = (payload: ThemeSyncPayload): 'light' | 'dark' | null => {
|
||||
const variant = getNested(payload.currentTheme, ['metadata', 'variant']);
|
||||
return variant === 'light' || variant === 'dark' ? variant : null;
|
||||
};
|
||||
|
||||
const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
|
||||
let lightThemeId: string = DEFAULT_LIGHT_ID;
|
||||
let darkThemeId: string = DEFAULT_DARK_ID;
|
||||
let themeMode: ThemeMode = 'system';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const embeddedParams = readEmbeddedThemeSearchParams();
|
||||
const embeddedMode = embeddedParams?.get('themeMode');
|
||||
const embeddedLightId = embeddedParams?.get('lightThemeId');
|
||||
const embeddedDarkId = embeddedParams?.get('darkThemeId');
|
||||
const storedMode = localStorage.getItem('themeMode');
|
||||
const storedLightId = localStorage.getItem('lightThemeId');
|
||||
const storedDarkId = localStorage.getItem('darkThemeId');
|
||||
@@ -158,7 +97,9 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
|
||||
const legacyThemeId = localStorage.getItem('selectedThemeId');
|
||||
const legacyVariant = localStorage.getItem('selectedThemeVariant');
|
||||
|
||||
if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') {
|
||||
if (embeddedMode === 'light' || embeddedMode === 'dark' || embeddedMode === 'system') {
|
||||
themeMode = embeddedMode;
|
||||
} else if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') {
|
||||
themeMode = storedMode;
|
||||
} else if (legacyUseSystem !== null) {
|
||||
const useSystem = legacyUseSystem === 'true';
|
||||
@@ -179,11 +120,15 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
|
||||
themeMode = legacyVariant;
|
||||
}
|
||||
|
||||
if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) {
|
||||
if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) {
|
||||
lightThemeId = embeddedLightId.trim();
|
||||
} else if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) {
|
||||
lightThemeId = storedLightId.trim();
|
||||
}
|
||||
|
||||
if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) {
|
||||
if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) {
|
||||
darkThemeId = embeddedDarkId.trim();
|
||||
} else if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) {
|
||||
darkThemeId = storedDarkId.trim();
|
||||
}
|
||||
}
|
||||
@@ -214,8 +159,10 @@ interface ThemeSystemProviderProps {
|
||||
export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemProviderProps) {
|
||||
const cssGenerator = useMemo(() => new CSSVariableGenerator(), []);
|
||||
const [preferences, setPreferences] = useState<ThemePreferences>(() => buildInitialPreferences(defaultThemeId));
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getSystemPreference());
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getInitialSystemPreference());
|
||||
const [customThemes, setCustomThemes] = useState<Theme[]>([]);
|
||||
const [embeddedBootstrapTheme] = useState<Theme | null>(() => readEmbeddedCurrentTheme());
|
||||
const [embeddedSyncedTheme, setEmbeddedSyncedTheme] = useState<Theme | null>(null);
|
||||
const [customThemesLoading, setCustomThemesLoading] = useState(false);
|
||||
const [vscodeTheme, setVSCodeTheme] = useState<Theme | null>(() => {
|
||||
if (typeof window === 'undefined' || !isVSCodeRuntime()) {
|
||||
@@ -231,7 +178,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return new URLSearchParams(window.location.search).get('ocPanel') === 'session-chat';
|
||||
return readEmbeddedThemeSearchParams() !== null;
|
||||
}, []);
|
||||
|
||||
const availableThemes = useMemo(() => {
|
||||
@@ -249,12 +196,20 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
add(vscodeTheme);
|
||||
}
|
||||
|
||||
if (embeddedSyncedTheme) {
|
||||
add(embeddedSyncedTheme);
|
||||
}
|
||||
|
||||
if (embeddedBootstrapTheme) {
|
||||
add(embeddedBootstrapTheme);
|
||||
}
|
||||
|
||||
// Custom themes first so they can override built-ins with the same id.
|
||||
customThemes.forEach(add);
|
||||
themes.forEach(add);
|
||||
|
||||
return merged;
|
||||
}, [customThemes, isVSCode, vscodeTheme]);
|
||||
}, [customThemes, embeddedBootstrapTheme, embeddedSyncedTheme, isVSCode, vscodeTheme]);
|
||||
|
||||
const getThemeByIdFromAvailable = useCallback(
|
||||
(themeId: string): Theme | undefined => availableThemes.find((theme) => theme.metadata.id === themeId),
|
||||
@@ -315,7 +270,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
|
||||
const payload = await res.json();
|
||||
const incoming = Array.isArray(payload?.themes) ? payload.themes : [];
|
||||
const normalized = incoming.filter(isValidCustomTheme);
|
||||
const normalized = incoming.filter(isValidTheme);
|
||||
setCustomThemes(normalized);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -430,7 +385,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
}, [preferences.themeMode, receivesParentThemeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
if (receivesParentThemeSync || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -453,13 +408,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
localStorage.setItem('splashFgLight', lightTheme.colors.surface.foreground);
|
||||
localStorage.setItem('splashBgDark', darkTheme.colors.surface.background);
|
||||
localStorage.setItem('splashFgDark', darkTheme.colors.surface.foreground);
|
||||
}, [preferences, currentTheme, ensureThemeById]);
|
||||
}, [preferences, currentTheme, ensureThemeById, receivesParentThemeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (receivesParentThemeSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.storageArea !== window.localStorage) {
|
||||
return;
|
||||
@@ -500,13 +459,14 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
}, [receivesParentThemeSync]);
|
||||
|
||||
const applyIncomingThemeSync = useCallback((payload: ThemeSyncPayload) => {
|
||||
const mode = payload.themeMode;
|
||||
const light = payload.lightThemeId;
|
||||
const dark = payload.darkThemeId;
|
||||
const syncedVariant = getSyncedThemeVariant(payload);
|
||||
const syncedTheme = getSyncedThemeFromPayload(payload);
|
||||
|
||||
if ((mode !== 'light' && mode !== 'dark' && mode !== 'system') || typeof light !== 'string' || typeof dark !== 'string') {
|
||||
return;
|
||||
@@ -520,6 +480,10 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
|
||||
suppressTransitionsForThemeSwitch();
|
||||
flushSync(() => {
|
||||
if (receivesParentThemeSync && syncedTheme) {
|
||||
setEmbeddedSyncedTheme(syncedTheme);
|
||||
}
|
||||
|
||||
if (mode === 'system' && syncedVariant) {
|
||||
setSystemPrefersDark(syncedVariant === 'dark');
|
||||
}
|
||||
@@ -536,7 +500,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
};
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
}, [receivesParentThemeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -583,6 +547,10 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
}, [applyIncomingThemeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (receivesParentThemeSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
|
||||
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
|
||||
|
||||
@@ -597,17 +565,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
splashBgDark: darkTheme.colors.surface.background,
|
||||
splashFgDark: darkTheme.colors.surface.foreground,
|
||||
});
|
||||
}, [currentTheme.metadata.id, currentTheme.metadata.variant, ensureThemeById, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId]);
|
||||
}, [currentTheme.metadata.id, currentTheme.metadata.variant, ensureThemeById, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId, receivesParentThemeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopShell) {
|
||||
if (receivesParentThemeSync || !isDesktopShell) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
await setDesktopWindowTheme(preferences.themeMode, currentTheme.metadata.variant);
|
||||
})();
|
||||
}, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode]);
|
||||
}, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode, receivesParentThemeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const readEmbeddedThemeSearchParams = (): URLSearchParams | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('ocPanel') === 'session-chat' ? params : null;
|
||||
};
|
||||
|
||||
const getSystemPreference = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
export const getInitialSystemPreference = (): boolean => {
|
||||
const embeddedParams = readEmbeddedThemeSearchParams();
|
||||
const embeddedVariant = embeddedParams?.get('themeVariant');
|
||||
if (embeddedVariant === 'dark' || embeddedVariant === 'light') {
|
||||
return embeddedVariant === 'dark';
|
||||
}
|
||||
return getSystemPreference();
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
|
||||
|
||||
describe('theme sync payload', () => {
|
||||
test('accepts full custom theme payloads for embedded live sync', () => {
|
||||
const customTheme = {
|
||||
...getDefaultTheme(true),
|
||||
metadata: {
|
||||
...getDefaultTheme(true).metadata,
|
||||
id: 'live-custom-dark',
|
||||
name: 'Live custom dark',
|
||||
variant: 'dark' as const,
|
||||
},
|
||||
};
|
||||
|
||||
expect(getSyncedThemeFromPayload({ currentTheme: customTheme })?.metadata.id).toBe('live-custom-dark');
|
||||
expect(getSyncedThemeVariant({ currentTheme: customTheme })).toBe('dark');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { isValidTheme } from './theme-validation';
|
||||
|
||||
export type ThemeSyncPayloadShape = {
|
||||
currentTheme?: unknown;
|
||||
};
|
||||
|
||||
export const getSyncedThemeFromPayload = (payload: ThemeSyncPayloadShape): Theme | null => (
|
||||
isValidTheme(payload.currentTheme) ? payload.currentTheme : null
|
||||
);
|
||||
|
||||
export const getSyncedThemeVariant = (payload: ThemeSyncPayloadShape): 'light' | 'dark' | null => (
|
||||
getSyncedThemeFromPayload(payload)?.metadata.variant ?? null
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
|
||||
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 isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
export const isValidTheme = (value: unknown): value is Theme => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const variant = getNested(value, ['metadata', 'variant']);
|
||||
return variant === 'light' || variant === 'dark';
|
||||
};
|
||||
Reference in New Issue
Block a user