fix(ui): scope theme settings per runtime instance (#2897)

Closes #2958
This commit is contained in:
Pablo Gonzalez
2026-08-30 13:26:57 +03:00
committed by GitHub
parent db0bf115ad
commit 73fd2e9d9d
5 changed files with 526 additions and 67 deletions
@@ -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();
+31 -62
View File
@@ -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);
@@ -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<string, string>();
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();
});
});
+184
View File
@@ -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<string, unknown>;
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;
-5
View File
@@ -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);