From 73fd2e9d9d3887344238d3a550d9e383baf6542c Mon Sep 17 00:00:00 2001 From: Pablo Gonzalez Date: Sun, 30 Aug 2026 12:26:57 +0200 Subject: [PATCH] fix(ui): scope theme settings per runtime instance (#2897) Closes #2958 --- packages/ui/src/apps/mobileNativeChrome.ts | 21 ++ .../ui/src/contexts/ThemeSystemContext.tsx | 93 ++---- .../ui/src/contexts/theme-storage.test.ts | 290 ++++++++++++++++++ packages/ui/src/contexts/theme-storage.ts | 184 +++++++++++ packages/ui/src/lib/persistence.ts | 5 - 5 files changed, 526 insertions(+), 67 deletions(-) create mode 100644 packages/ui/src/contexts/theme-storage.test.ts create mode 100644 packages/ui/src/contexts/theme-storage.ts diff --git a/packages/ui/src/apps/mobileNativeChrome.ts b/packages/ui/src/apps/mobileNativeChrome.ts index a26c6eb4..07d1c7e6 100644 --- a/packages/ui/src/apps/mobileNativeChrome.ts +++ b/packages/ui/src/apps/mobileNativeChrome.ts @@ -70,6 +70,27 @@ export const useNativeMobileChrome = (): void => { const retry = window.setTimeout(() => void applyStatusBar(), 400); cleanup.push(() => window.clearTimeout(retry)); + // Theme toggles must reach the status bar without an app restart: re-run + // whenever the root dark/light class flips — the one signal every theme + // path converges on (settings toggle, synced settings, storage events, + // system-preference changes while in system mode). splashBg* colors are + // per-variant values, so they are stable across mode toggles. + if (platform === 'android') { + let wasDark = root.classList.contains('dark'); + const themeClassObserver = new MutationObserver(() => { + const isDark = root.classList.contains('dark'); + if (isDark === wasDark) return; + wasDark = isDark; + void applyStatusBar(); + }); + themeClassObserver.observe(root, { attributes: true, attributeFilter: ['class'] }); + if (disposed) { + themeClassObserver.disconnect(); + return; + } + cleanup.push(() => themeClassObserver.disconnect()); + } + const { App } = await import('@capacitor/app'); const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { if (isActive) void applyStatusBar(); diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index 681e2caf..edc7929b 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -31,6 +31,12 @@ import { import { isValidTheme } from './theme-validation'; import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { + adoptThemePreferencesForRuntime, + resolveThemePreferencesForRuntime, + resolveThemePreferencesFromStorageEvent, + writeThemePreferencesForRuntime, +} from './theme-storage'; type ThemePreferences = { themeMode: ThemeMode; @@ -87,46 +93,27 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => { 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'); - const legacyUseSystem = localStorage.getItem('useSystemTheme'); - const legacyThemeId = localStorage.getItem('selectedThemeId'); - const legacyVariant = localStorage.getItem('selectedThemeVariant'); + // Scoped entry when present; otherwise a one-time seed from the superseded + // global keys (see resolveThemePreferencesForRuntime), so the first scoped + // write carries the last-known theme instead of defaults. + const resolvedPreferences = resolveThemePreferencesForRuntime(getRuntimeKey()); 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'; - 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; + } else { + themeMode = resolvedPreferences.themeMode; } if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) { lightThemeId = embeddedLightId.trim(); - } else if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) { - lightThemeId = storedLightId.trim(); + } else { + lightThemeId = resolvedPreferences.lightThemeId; } if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) { darkThemeId = embeddedDarkId.trim(); - } else if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) { - darkThemeId = storedDarkId.trim(); + } else { + darkThemeId = resolvedPreferences.darkThemeId; } } @@ -314,6 +301,9 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro customThemesRequestRef.current += 1; setCustomThemes([]); setCustomThemesLoading(false); + // Adopt the new instance's last-known theme immediately; the incoming + // settings sync refines it with the server's authoritative value. + setPreferences((prev) => adoptThemePreferencesForRuntime(detail.runtimeKey, prev)); void reloadCustomThemes(); }), [isVSCode, reloadCustomThemes]); @@ -424,6 +414,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro return; } + writeThemePreferencesForRuntime(getRuntimeKey(), { + themeMode: preferences.themeMode, + lightThemeId: preferences.lightThemeId, + darkThemeId: preferences.darkThemeId, + }); + + // Cosmetic last-writer-wins hints for the pre-React splash shells + // (packages/web/index.html, mobile.html, mini-chat.html) and the Android + // status bar, which run before the scoped key can be read. Not part of the + // app's theme authority — the scoped entry and the per-instance server + // settings own that. localStorage.setItem('themeMode', preferences.themeMode); localStorage.setItem('lightThemeId', preferences.lightThemeId); localStorage.setItem('darkThemeId', preferences.darkThemeId); @@ -434,8 +435,6 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro currentTheme.metadata.variant === 'light' ? 'light' : 'dark', ); - // 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'); @@ -459,37 +458,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro return; } - if (event.key !== 'themeMode' && event.key !== 'lightThemeId' && event.key !== 'darkThemeId') { - return; - } - - setPreferences((prev) => { - const nextModeRaw = localStorage.getItem('themeMode'); - const nextMode: ThemeMode = - nextModeRaw === 'light' || nextModeRaw === 'dark' || nextModeRaw === 'system' - ? nextModeRaw - : prev.themeMode; - - const nextLightRaw = localStorage.getItem('lightThemeId'); - const nextLight = typeof nextLightRaw === 'string' && nextLightRaw.trim().length > 0 - ? nextLightRaw.trim() - : prev.lightThemeId; - - const nextDarkRaw = localStorage.getItem('darkThemeId'); - const nextDark = typeof nextDarkRaw === 'string' && nextDarkRaw.trim().length > 0 - ? nextDarkRaw.trim() - : prev.darkThemeId; - - if (nextMode === prev.themeMode && nextLight === prev.lightThemeId && nextDark === prev.darkThemeId) { - return prev; - } - - return { - themeMode: nextMode, - lightThemeId: nextLight, - darkThemeId: nextDark, - }; - }); + setPreferences((prev) => resolveThemePreferencesFromStorageEvent(event.key, getRuntimeKey(), prev) ?? prev); }; window.addEventListener('storage', handleStorage); diff --git a/packages/ui/src/contexts/theme-storage.test.ts b/packages/ui/src/contexts/theme-storage.test.ts new file mode 100644 index 00000000..92acc2ba --- /dev/null +++ b/packages/ui/src/contexts/theme-storage.test.ts @@ -0,0 +1,290 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; + +import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes'; + +import { + adoptThemePreferencesForRuntime, + getThemePreferencesStorageKey, + isTransientRuntimeKey, + readThemePreferencesForRuntime, + resolveThemePreferencesForRuntime, + resolveThemePreferencesFromStorageEvent, + writeThemePreferencesForRuntime, +} from './theme-storage'; + +let createdWindow = false; +let createdLocalStorage = false; + +const ensureLocalStorage = (): void => { + if (typeof localStorage !== 'undefined') { + return; + } + const values = new Map(); + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + clear: () => { + values.clear(); + }, + }, + configurable: true, + writable: true, + }); + createdLocalStorage = true; +}; + +beforeEach(() => { + if (typeof window === 'undefined') { + Object.defineProperty(globalThis, 'window', { + value: {}, + configurable: true, + writable: true, + }); + createdWindow = true; + } + ensureLocalStorage(); + localStorage.clear(); +}); + +afterAll(() => { + if (createdWindow) { + delete (globalThis as { window?: unknown }).window; + } + if (createdLocalStorage) { + delete (globalThis as { localStorage?: unknown }).localStorage; + } +}); + +const preferences = { + themeMode: 'dark' as const, + lightThemeId: 'light-theme', + darkThemeId: 'dark-theme', +}; + +describe('theme preference runtime scoping', () => { + test('keys differ per runtime', () => { + expect(getThemePreferencesStorageKey('runtime-a')).not.toBe(getThemePreferencesStorageKey('runtime-b')); + }); + + test('round-trips preferences for the same runtime', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences); + }); + + test('a window on one instance never reads another instance theme', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(readThemePreferencesForRuntime('runtime-b')).toBeNull(); + }); + + test('latest write wins per runtime without cross-instance effects', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + writeThemePreferencesForRuntime('runtime-b', { themeMode: 'light', lightThemeId: 'other-light', darkThemeId: 'other-dark' }); + + expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences); + expect(readThemePreferencesForRuntime('runtime-b')).toEqual({ + themeMode: 'light', + lightThemeId: 'other-light', + darkThemeId: 'other-dark', + }); + }); + + test('malformed or invalid payloads are failure, not empty authority', () => { + localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json'); + expect(readThemePreferencesForRuntime('runtime-a')).toBeNull(); + + localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), JSON.stringify({ themeMode: 'neon' })); + expect(readThemePreferencesForRuntime('runtime-a')).toBeNull(); + + localStorage.setItem( + getThemePreferencesStorageKey('runtime-a'), + JSON.stringify({ themeMode: 'dark', lightThemeId: '', darkThemeId: 'dark-theme' }), + ); + expect(readThemePreferencesForRuntime('runtime-a')).toBeNull(); + }); + + test('leaves the splash-hint and migration-seed globals untouched', () => { + localStorage.setItem('themeMode', 'dark'); + localStorage.setItem('lightThemeId', 'light-theme'); + localStorage.setItem('darkThemeId', 'dark-theme'); + localStorage.setItem('useSystemTheme', 'false'); + localStorage.setItem('selectedThemeId', 'dark-theme'); + localStorage.setItem('selectedThemeVariant', 'dark'); + localStorage.setItem('splashBgDark', '#0c0a09'); + localStorage.setItem('splashFgDark', '#fafaf9'); + + writeThemePreferencesForRuntime('runtime-a', preferences); + + // The scoped key owns the app theme; the global keys stay as cosmetic + // last-writer-wins hints for the pre-React splash shells and the Android + // status bar, and as the one-time migration seed for new runtimes. + expect(localStorage.getItem('themeMode')).toBe('dark'); + expect(localStorage.getItem('lightThemeId')).toBe('light-theme'); + expect(localStorage.getItem('darkThemeId')).toBe('dark-theme'); + expect(localStorage.getItem('useSystemTheme')).toBe('false'); + expect(localStorage.getItem('selectedThemeId')).toBe('dark-theme'); + expect(localStorage.getItem('selectedThemeVariant')).toBe('dark'); + expect(localStorage.getItem('splashBgDark')).toBe('#0c0a09'); + expect(localStorage.getItem('splashFgDark')).toBe('#fafaf9'); + expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences); + }); +}); + +describe('theme preference resolution chain', () => { + test('uses the scoped entry when present', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual(preferences); + }); + + test('seeds from the legacy mode and theme ids when no scoped entry exists', () => { + localStorage.setItem('themeMode', 'dark'); + localStorage.setItem('lightThemeId', 'legacy-light'); + localStorage.setItem('darkThemeId', 'legacy-dark'); + + expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({ + themeMode: 'dark', + lightThemeId: 'legacy-light', + darkThemeId: 'legacy-dark', + }); + }); + + test('seeds from the useSystemTheme/selectedThemeId legacy chain', () => { + localStorage.setItem('useSystemTheme', 'false'); + localStorage.setItem('selectedThemeId', DEFAULT_DARK_THEME_ID); + + expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({ + themeMode: 'dark', + lightThemeId: DEFAULT_LIGHT_THEME_ID, + darkThemeId: DEFAULT_DARK_THEME_ID, + }); + }); + + test('falls back to defaults when nothing is stored', () => { + expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({ + themeMode: 'system', + lightThemeId: DEFAULT_LIGHT_THEME_ID, + darkThemeId: DEFAULT_DARK_THEME_ID, + }); + }); + + test('the migrated seed survives into the scoped key while the seed globals stay', () => { + localStorage.setItem('themeMode', 'dark'); + localStorage.setItem('lightThemeId', 'legacy-light'); + localStorage.setItem('darkThemeId', 'legacy-dark'); + + writeThemePreferencesForRuntime('runtime-a', resolveThemePreferencesForRuntime('runtime-a')); + + expect(readThemePreferencesForRuntime('runtime-a')).toEqual({ + themeMode: 'dark', + lightThemeId: 'legacy-light', + darkThemeId: 'legacy-dark', + }); + expect(localStorage.getItem('themeMode')).toBe('dark'); + expect(localStorage.getItem('lightThemeId')).toBe('legacy-light'); + expect(localStorage.getItem('darkThemeId')).toBe('legacy-dark'); + }); +}); + +describe('runtime-switch adoption', () => { + const current = { themeMode: 'dark' as const, lightThemeId: 'current-light', darkThemeId: 'current-dark' }; + + test('adopts the target runtime stored theme when one exists', () => { + writeThemePreferencesForRuntime('runtime-b', preferences); + + expect(adoptThemePreferencesForRuntime('runtime-b', current)).toEqual(preferences); + }); + + test('keeps the current preferences — same reference — when the target runtime has no entry', () => { + expect(adoptThemePreferencesForRuntime('runtime-empty', current)).toBe(current); + }); +}); + +describe('transient runtime keys', () => { + test('uninitialized and disconnected runtime keys are transient', () => { + expect(isTransientRuntimeKey('url:default')).toBe(true); + expect(isTransientRuntimeKey('mobile-disconnected')).toBe(true); + expect(isTransientRuntimeKey('')).toBe(true); + expect(isTransientRuntimeKey('local')).toBe(false); + expect(isTransientRuntimeKey('url:https://host.example')).toBe(false); + }); + + test('writes are skipped for transient runtimes — no stale cold-boot theme gets pinned', () => { + writeThemePreferencesForRuntime('url:default', preferences); + writeThemePreferencesForRuntime('mobile-disconnected', preferences); + + expect(readThemePreferencesForRuntime('url:default')).toBeNull(); + expect(readThemePreferencesForRuntime('mobile-disconnected')).toBeNull(); + expect(localStorage.getItem(getThemePreferencesStorageKey('url:default'))).toBeNull(); + }); + + test('reads never surface an entry under a transient key', () => { + localStorage.setItem(getThemePreferencesStorageKey('url:default'), JSON.stringify(preferences)); + + expect(readThemePreferencesForRuntime('url:default')).toBeNull(); + }); + + test('boot resolution falls back to the global splash hints for transient runtimes', () => { + localStorage.setItem('themeMode', 'light'); + localStorage.setItem('lightThemeId', 'legacy-light'); + localStorage.setItem('darkThemeId', 'legacy-dark'); + + expect(resolveThemePreferencesForRuntime('url:default')).toEqual({ + themeMode: 'light', + lightThemeId: 'legacy-light', + darkThemeId: 'legacy-dark', + }); + }); + + test('endpoint-switch adoption keeps current preferences for transient runtimes', () => { + const current = { themeMode: 'light' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' }; + + expect(adoptThemePreferencesForRuntime('mobile-disconnected', current)).toBe(current); + }); +}); + +describe('theme storage event resolution', () => { + const current = { themeMode: 'system' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' }; + + test('adopts a storage event for the current runtime', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toEqual(preferences); + }); + + test('ignores a storage event from another runtime', () => { + writeThemePreferencesForRuntime('runtime-b', preferences); + + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-b'), 'runtime-a', current)).toBeNull(); + }); + + test('ignores legacy global theme keys (revert-to-globals regression guard)', () => { + localStorage.setItem('themeMode', 'dark'); + localStorage.setItem('lightThemeId', 'light-theme'); + localStorage.setItem('darkThemeId', 'dark-theme'); + + expect(resolveThemePreferencesFromStorageEvent('themeMode', 'runtime-a', current)).toBeNull(); + expect(resolveThemePreferencesFromStorageEvent('lightThemeId', 'runtime-a', current)).toBeNull(); + expect(resolveThemePreferencesFromStorageEvent('darkThemeId', 'runtime-a', current)).toBeNull(); + }); + + test('resolves to no change when stored preferences already match', () => { + writeThemePreferencesForRuntime('runtime-a', preferences); + + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', preferences)).toBeNull(); + }); + + test('resolves to no change when nothing valid is stored', () => { + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull(); + + localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json'); + expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull(); + }); +}); diff --git a/packages/ui/src/contexts/theme-storage.ts b/packages/ui/src/contexts/theme-storage.ts new file mode 100644 index 00000000..8fc94ef6 --- /dev/null +++ b/packages/ui/src/contexts/theme-storage.ts @@ -0,0 +1,184 @@ +import type { ThemeMode } from '@/types/theme'; +import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes'; + +type StoredThemePreferences = { + themeMode: ThemeMode; + lightThemeId: string; + darkThemeId: string; +}; + +// Theme preferences are scoped per runtime endpoint, like the settings mirror +// (lib/persistence.ts), so windows pointing at different instances never +// overwrite or adopt each other's theme through shared localStorage. +// +// Retention is intentionally unbounded, unlike the mirror's capped 5-runtime +// index: each entry is ~150 bytes, the count is bounded by the distinct +// instances ever visited from this origin, and evicting old entries would only +// discard the last-known theme for rarely visited instances while saving +// trivial space. +const THEME_PREFERENCES_KEY_PREFIX = 'openchamber.theme.v2:'; + +export const getThemePreferencesStorageKey = (runtimeKey: string): string => + `${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`; + +// Runtime keys that mean "no instance connected" — the uninitialized default +// and the mobile disconnect state. They carry no instance theme, so scoped +// storage must not read or write them: a write would pin whatever theme was +// current at that moment (e.g. cold-boot defaults) to a key every future +// launch resolves before connecting, and a read would surface that stale +// entry on the mobile connect splash. The global splash hints are the right +// fallback for those phases. +const TRANSIENT_RUNTIME_KEYS = new Set(['', 'url:default', 'mobile-disconnected']); + +export const isTransientRuntimeKey = (runtimeKey: string): boolean => + TRANSIENT_RUNTIME_KEYS.has(runtimeKey); + +export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => { + if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { + return null; + } + let raw: string | null = null; + try { + raw = localStorage.getItem(getThemePreferencesStorageKey(runtimeKey)); + } catch { + return null; + } + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') { + return null; + } + const candidate = parsed as Record; + if (candidate.themeMode !== 'light' && candidate.themeMode !== 'dark' && candidate.themeMode !== 'system') { + return null; + } + if (typeof candidate.lightThemeId !== 'string' || typeof candidate.darkThemeId !== 'string') { + return null; + } + const lightThemeId = candidate.lightThemeId.trim(); + const darkThemeId = candidate.darkThemeId.trim(); + if (!lightThemeId || !darkThemeId) { + return null; + } + return { themeMode: candidate.themeMode, lightThemeId, darkThemeId }; + } catch { + return null; + } +}; + +export const writeThemePreferencesForRuntime = (runtimeKey: string, preferences: StoredThemePreferences): void => { + if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { + return; + } + try { + localStorage.setItem(getThemePreferencesStorageKey(runtimeKey), JSON.stringify(preferences)); + } catch { + // localStorage unavailable (e.g. read-only contextBridge) — the server + // settings sync remains authoritative and the app still works. + } +}; + +/** + * Resolve the preferences a cross-window storage event should apply for the + * current runtime. Returns null — meaning "keep current preferences" — when + * the event targets another runtime's key, when no valid stored preferences + * exist, or when the stored preferences already match the current ones (the + * identity check breaks cross-window adoption loops). + */ +export const resolveThemePreferencesFromStorageEvent = ( + eventKey: string | null, + runtimeKey: string, + current: StoredThemePreferences, +): StoredThemePreferences | null => { + if (eventKey !== getThemePreferencesStorageKey(runtimeKey)) { + return null; + } + const stored = readThemePreferencesForRuntime(runtimeKey); + if (!stored) { + return null; + } + if (stored.themeMode === current.themeMode && stored.lightThemeId === current.lightThemeId && stored.darkThemeId === current.darkThemeId) { + return null; + } + return stored; +}; + +// One-time migration seed: pre-scoped builds persisted theme state in these +// global keys. They are resolved only while no scoped entry exists — the +// persist effect then seeds the scoped key from the returned preferences — so +// no client-only theme state is discarded before the authoritative server sync +// lands. The keys themselves stay (see ThemeSystemContext's persist effect): +// the pre-React splash shells and the Android status bar read them as +// cosmetic last-writer-wins hints. +const readLegacyThemePreferences = (): StoredThemePreferences => { + let themeMode: ThemeMode = 'system'; + let lightThemeId: string = DEFAULT_LIGHT_THEME_ID; + let darkThemeId: string = DEFAULT_DARK_THEME_ID; + + if (typeof window === 'undefined') { + return { themeMode, lightThemeId, darkThemeId }; + } + + const legacyMode = localStorage.getItem('themeMode'); + const legacyUseSystem = localStorage.getItem('useSystemTheme'); + const legacyThemeId = localStorage.getItem('selectedThemeId'); + const legacyVariant = localStorage.getItem('selectedThemeVariant'); + + if (legacyMode === 'light' || legacyMode === 'dark' || legacyMode === 'system') { + themeMode = legacyMode; + } 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; + } + + const legacyLightId = localStorage.getItem('lightThemeId'); + const legacyDarkId = localStorage.getItem('darkThemeId'); + if (typeof legacyLightId === 'string' && legacyLightId.trim().length > 0) { + lightThemeId = legacyLightId.trim(); + } + if (typeof legacyDarkId === 'string' && legacyDarkId.trim().length > 0) { + darkThemeId = legacyDarkId.trim(); + } + + return { themeMode, lightThemeId, darkThemeId }; +}; + +/** + * Resolve the preferences for a runtime at boot: the scoped entry when one + * exists, otherwise a one-time seed from the superseded global keys, otherwise + * defaults. The seed guarantees the first scoped write carries the last-known + * theme instead of defaults. + */ +export const resolveThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences => { + const stored = readThemePreferencesForRuntime(runtimeKey); + return stored ?? readLegacyThemePreferences(); +}; + +/** + * Adopt another runtime's stored preferences when the endpoint switches: the + * new runtime's scoped entry when one exists, otherwise the current + * preferences unchanged (the same reference — no re-render, no write-through) + * until the incoming settings sync refines with the server's authoritative + * value. + */ +export const adoptThemePreferencesForRuntime = ( + runtimeKey: string, + current: StoredThemePreferences, +): StoredThemePreferences => readThemePreferencesForRuntime(runtimeKey) ?? current; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 934162d1..91870b90 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -106,11 +106,6 @@ const persistToLocalStorage = (settings: DesktopSettings) => { } persistRuntimeSettingsMirror(settings, getRuntimeKey()); - setOrRemoveLocalStorage('selectedThemeId', settings.themeId || null); - setOrRemoveLocalStorage('selectedThemeVariant', settings.themeVariant || null); - setOrRemoveLocalStorage('lightThemeId', settings.lightThemeId || null); - setOrRemoveLocalStorage('darkThemeId', settings.darkThemeId || null); - setOrRemoveLocalStorage('useSystemTheme', typeof settings.useSystemTheme === 'boolean' ? String(settings.useSystemTheme) : null); setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null); if (settings.homeDirectory) { localStorage.setItem('homeDirectory', settings.homeDirectory);