feat(theme): support system appearance across desktop platforms

- Use Tauri native theme APIs with Linux desktop preference support
- Fall back to WebView theme detection for startup and browser preview
- Add a follow-system option to both standard and beginner modes
- Persist preferences and handle resume events and async race conditions
- Add translations and theme behavior and UI regression tests
This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent b483d77bf8
commit 739c6e1711
25 files changed
+1051 -53

No files matched your search

+8 -8
View File
@@ -5,17 +5,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
(() => {
let savedTheme;
try {
const savedTheme = localStorage.getItem('easy-cli-proxy-api.theme');
const theme = savedTheme === 'light' || savedTheme === 'dark'
? savedTheme
: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.style.backgroundColor = theme === 'dark' ? '#111412' : '#f6f7f5';
savedTheme = localStorage.getItem('easy-cli-proxy-api.theme');
} catch {
// The app applies the theme again during initialization.
}
const theme = savedTheme === 'light' || savedTheme === 'dark'
? savedTheme
: window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.style.backgroundColor = theme === 'dark' ? '#111412' : '#f6f7f5';
})();
</script>
<title>EasyCLIProxyAPI</title>
+1
View File
@@ -597,6 +597,7 @@ version = "0.2.81"
dependencies = [
"base64 0.22.1",
"chrono",
"dbus",
"flate2",
"futures-util",
"getrandom 0.3.4",
+3
View File
@@ -41,6 +41,9 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
dbus = "0.9"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent", "NSRunningApplication"] }
+1
View File
@@ -7,6 +7,7 @@
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:window:allow-set-theme",
"core:window:allow-theme",
"core:window:allow-set-background-color",
"core:webview:allow-set-webview-background-color",
"dialog:allow-open"
+34 -1
View File
@@ -94,6 +94,36 @@ fn import_changes(
let previous = generated.and_then(|fields| fields.get(key));
if current != previous {
if let Some(value) = current {
if key == "compat" {
if let Some(fields) = value.as_object() {
let previous = previous.and_then(Value::as_object);
let mut imported = overrides
.get(key)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let keys = fields
.keys()
.chain(previous.into_iter().flat_map(|fields| fields.keys()))
.collect::<std::collections::BTreeSet<_>>();
for field in keys {
let current = fields.get(field);
if current != previous.and_then(|fields| fields.get(field)) {
if let Some(value) = current {
imported.insert(field.clone(), value.clone());
} else {
imported.remove(field);
}
}
}
if imported.is_empty() {
overrides.remove(key);
} else {
overrides.insert(key.into(), Value::Object(imported));
}
continue;
}
}
overrides.insert(key.into(), value.clone());
} else {
overrides.remove(key);
@@ -327,7 +357,7 @@ fn validate_harness_field(
"reasoning" => {
value == &json!(false)
|| value.as_object().is_some_and(|values| {
!values.is_empty()
values.keys().any(|key| key != "off")
&& values.iter().all(|(key, value)| {
["off", "minimal", "low", "medium", "high", "xhigh", "max"]
.contains(&key.as_str())
@@ -376,6 +406,9 @@ fn render_harness_profiles(
models: &[AgentModelOption],
state: &mut DeepSeekHarnessCatalogState,
) -> Result<String, String> {
if models.is_empty() {
return Err("DSH 模型列表为空,已保留现有配置,请刷新后重试".into());
}
let root = harness_root(existing)?;
let old = harness_provider(&root);
let api = state
+57
View File
@@ -0,0 +1,57 @@
#[tauri::command]
pub(crate) async fn get_linux_system_theme() -> Option<tauri::Theme> {
#[cfg(target_os = "linux")]
{
tauri::async_runtime::spawn_blocking(read_portal_theme)
.await
.ok()
.flatten()
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(target_os = "linux")]
fn read_portal_theme() -> Option<tauri::Theme> {
use dbus::{arg::Variant, blocking::Connection};
use std::time::Duration;
let connection = Connection::new_session().ok()?;
let proxy = connection.with_proxy(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
Duration::from_secs(1),
);
let (value,): (Variant<Variant<u32>>,) = proxy
.method_call(
"org.freedesktop.portal.Settings",
"Read",
("org.freedesktop.appearance", "color-scheme"),
)
.ok()?;
portal_color_scheme(value.0 .0)
}
#[cfg(any(target_os = "linux", test))]
fn portal_color_scheme(value: u32) -> Option<tauri::Theme> {
match value {
1 => Some(tauri::Theme::Dark),
2 => Some(tauri::Theme::Light),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn follows_the_freedesktop_color_scheme_values() {
assert_eq!(portal_color_scheme(1), Some(tauri::Theme::Dark));
assert_eq!(portal_color_scheme(2), Some(tauri::Theme::Light));
assert_eq!(portal_color_scheme(0), None);
assert_eq!(portal_color_scheme(3), None);
}
}
+2
View File
@@ -9,6 +9,7 @@ mod codex_sessions;
mod configuration_watcher;
mod core_config;
mod core_runtime;
mod desktop_theme;
mod instance_lock;
mod management_api;
mod oauth_browser;
@@ -2499,6 +2500,7 @@ fn main() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
desktop_theme::get_linux_system_theme,
health_check,
detect_core_platform,
get_core_status,
+129 -1
View File
@@ -248,6 +248,54 @@ fn harness_saves_before_connection_and_applies_documented_protocol() {
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_rejects_empty_catalog_without_modifying_configured_files() {
let home = agent_test_home("harness-empty-catalog");
apply(&home, &models());
let mut paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
paths.push(deepseek_harness_catalog_state_path(&paths).unwrap());
for selection in [
root(&home).get("agent-default-model").cloned(),
Some(json!({"provider":"deepseek","model":"deepseek-chat"})),
None,
] {
let mut document = root(&home);
if let Some(selection) = selection {
document["agent-default-model"] = selection;
} else {
document
.as_object_mut()
.unwrap()
.remove("agent-default-model");
}
fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap();
let before = config_images(&paths).unwrap();
let mut edit = request(harness_editor_snapshot(&home, &[], 8317).unwrap());
edit.provider.insert("timeoutMs".into(), json!(60000));
assert!(save_harness_editor(&home, &[], 8317, edit).is_err());
assert_eq!(config_images(&paths).unwrap(), before);
assert_eq!(provider(&home)["models"].as_array().unwrap().len(), 3);
}
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_allows_empty_catalog_for_unconnected_provider_drafts() {
let home = agent_test_home("harness-empty-catalog-draft");
let mut edit = request(harness_editor_snapshot(&home, &[], 8317).unwrap());
edit.provider.insert("timeoutMs".into(), json!(60000));
save_harness_editor(&home, &[], 8317, edit).unwrap();
let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
assert!(paths.iter().all(|path| !path.exists()));
assert_eq!(
harness_editor_snapshot(&home, &[], 8317).unwrap().provider["timeoutMs"],
60000
);
apply(&home, &models());
assert_eq!(provider(&home)["timeoutMs"], 60000);
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_rejects_stale_catalog_and_invalid_fields_without_writing() {
let home = agent_test_home("harness-catalog-validation");
@@ -260,13 +308,17 @@ fn harness_rejects_stale_catalog_and_invalid_fields_without_writing() {
.err()
.unwrap()
.contains("DSH_MODEL_CATALOG_CHANGED"));
let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
let mut paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
paths.push(deepseek_harness_catalog_state_path(&paths).unwrap());
let before = config_images(&paths).unwrap();
for configuration in [
json!({"contextWindow":0}),
json!({"maxTokens":1.5}),
json!({"input":["audio"]}),
json!({"reasoningEfforts":{"high":null}}),
json!({"reasoningEfforts":{"off":null}}),
json!({"reasoningEfforts":{"off":"none"}}),
json!({"reasoningEfforts":{}}),
json!({"compat":{"supportsTemperature":true}}),
json!({"compat":{"supportsStore":null}}),
json!({"compat":{"vllmPriority":0.5}}),
@@ -328,6 +380,82 @@ fn harness_imports_external_edits_and_keeps_default_selection_and_other_settings
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_imports_compat_changes_without_freezing_other_api_defaults() {
let home = agent_test_home("harness-compat-import");
let mut models = models();
models[0].harness_metadata = harness_api_metadata(&json!({"compat":{
"supportsStore":true,"supportsDeveloperRole":true,"supportsStrictMode":true
}}));
apply(&home, &models);
let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
edit.models[0]
.configuration
.insert("compat".into(), json!({"supportsStrictMode":false}));
save_harness_editor(&home, &models, 8317, edit).unwrap();
let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
let mut document = root(&home);
document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0]["compat"]
["supportsStore"] = json!(false);
fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap();
let edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
assert_eq!(
edit.models[0].configuration["compat"],
json!({
"supportsStore":false,"supportsStrictMode":false
})
);
save_harness_editor(&home, &models, 8317, edit).unwrap();
models[0].harness_metadata = harness_api_metadata(&json!({"compat":{
"supportsStore":true,"supportsDeveloperRole":false,"supportsStrictMode":true
}}));
apply(&home, &models);
assert_eq!(
provider(&home)["models"][0]["compat"],
json!({
"supportsStore":false,"supportsDeveloperRole":false,"supportsStrictMode":false
})
);
let mut document = root(&home);
let compat = document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0]
["compat"]
.as_object_mut()
.unwrap();
compat.remove("supportsStore");
compat.insert("maxTokensField".into(), json!("max_tokens"));
fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap();
let edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
assert_eq!(
edit.models[0].configuration["compat"],
json!({
"supportsStrictMode":false,"maxTokensField":"max_tokens"
})
);
save_harness_editor(&home, &models, 8317, edit).unwrap();
assert_eq!(
provider(&home)["models"][0]["compat"]["supportsStore"],
true
);
let mut document = root(&home);
document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0]
.as_object_mut()
.unwrap()
.remove("compat");
fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap();
let edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
assert!(!edit.models[0].configuration.contains_key("compat"));
save_harness_editor(&home, &models, 8317, edit).unwrap();
assert_eq!(
provider(&home)["models"][0]["compat"],
json!({
"supportsStore":true,"supportsDeveloperRole":false,"supportsStrictMode":true
})
);
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_state_failure_rolls_back_settings_and_credentials() {
let home = agent_test_home("harness-catalog-rollback");
+12 -7
View File
@@ -33,7 +33,7 @@ import { languageOptions, useI18n } from './i18n';
import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate';
import { appUpdateIndicatorState } from './appUpdateModel';
import { canOpenAppPage, isAlwaysAvailablePage } from './navigation';
import { detectInitialTheme, saveTheme, type AppTheme } from './theme';
import { useThemePreference } from './theme';
const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG';
@@ -128,7 +128,7 @@ function AppContent() {
const { latest: coreLatest, hasUpdate: coreHasUpdate } = useCoreUpdate();
const [active, setActive] = useState<PageId>('home');
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [theme, setTheme] = useState<AppTheme>(detectInitialTheme);
const [theme, setTheme] = useThemePreference();
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
const closeDialogRef = useRef<HTMLElement>(null);
const languageMenuRef = useRef<HTMLDivElement>(null);
@@ -147,10 +147,6 @@ function AppContent() {
? `${t('kernel.versions.coreCardTitle')}: ${t('kernel.update.available')} ${coreLatest?.version ?? ''}`.trim()
: '',
].filter(Boolean).join(' · ');
useEffect(() => {
saveTheme(theme);
}, [theme]);
useEffect(() => {
if (!canOpenAppPage(active, coreRunning)) {
setActive('home');
@@ -344,7 +340,7 @@ function AppContent() {
<div
className="sidebar-theme-selector"
role="group"
aria-label={`${t('app.theme.light')} / ${t('app.theme.dark')}`}
aria-label={t('app.theme.label')}
>
<button
type="button"
@@ -364,6 +360,15 @@ function AppContent() {
>
{t('app.theme.dark')}
</button>
<button
type="button"
className={theme === 'system' ? 'active' : ''}
aria-pressed={theme === 'system'}
title={t('app.theme.switchToSystem')}
onClick={() => setTheme('system')}
>
{t('app.theme.system')}
</button>
</div>
<div ref={languageMenuRef} className="sidebar-language">
<button
+3
View File
@@ -222,6 +222,9 @@ export const jaOverrides = {
'app.language': '言語',
'app.theme.light': 'ライトモード',
'app.theme.dark': 'ダークモード',
'app.theme.label': '外観モード',
'app.theme.system': 'システムに従う',
'app.theme.switchToSystem': 'システムの外観に合わせて自動的に切り替え',
'app.theme.switchToLight': 'ライトモードに切り替え',
'app.theme.switchToDark': 'ダークモードに切り替え',
'app.desktopConsole': 'デスクトップコンソール',
+3
View File
@@ -172,6 +172,9 @@ export const en: Record<MessageKey, string> = {
'app.language': 'Language',
'app.theme.light': 'Light mode',
'app.theme.dark': 'Dark mode',
'app.theme.label': 'Appearance',
'app.theme.system': 'Follow system',
'app.theme.switchToSystem': 'Automatically match the system appearance',
'app.theme.switchToLight': 'Switch to light mode',
'app.theme.switchToDark': 'Switch to dark mode',
'app.desktopConsole': 'Desktop Console',
+3
View File
@@ -171,6 +171,9 @@ export const zhCN = {
'app.language': '语言',
'app.theme.light': '日间模式',
'app.theme.dark': '夜间模式',
'app.theme.label': '外观模式',
'app.theme.system': '跟随系统',
'app.theme.switchToSystem': '随系统自动切换日间和夜间模式',
'app.theme.switchToLight': '切换到日间模式',
'app.theme.switchToDark': '切换到夜间模式',
'app.desktopConsole': '桌面控制台',
+3
View File
@@ -1,4 +1,7 @@
const phraseReplacements: ReadonlyArray<readonly [string, string]> = [
['随系统自动切换日间和夜间模式', '隨系統自動切換日間和夜間模式'],
['跟随系统', '跟隨系統'],
['外观模式', '外觀模式'],
['登录系统后自动启动', '登入系統後自動啟動'],
['系统托盘', '系統匣'],
['开机自启', '開機自啟'],
+2 -2
View File
@@ -3,10 +3,10 @@ import { createRoot } from 'react-dom/client';
import { AppErrorBoundary } from './AppErrorBoundary';
import App from './App';
import { I18nProvider } from './i18n';
import { applyTheme, detectInitialTheme } from './theme';
import { initializeTheme } from './theme';
import './styles.css';
applyTheme(detectInitialTheme());
initializeTheme();
createRoot(document.getElementById('root')!).render(
<StrictMode>
+19 -4
View File
@@ -15,6 +15,7 @@ import {
ChevronDown,
Languages,
LoaderCircle,
Monitor,
Moon,
Sun,
X,
@@ -34,7 +35,7 @@ import {
type ModelOption,
type ModelProvider,
} from "../services/modelService";
import { type AppTheme } from "../theme";
import { type ThemePreference } from "../theme";
import { AgentsPage } from "./AgentsPage";
import codexIcon from "../assets/icons/codex.svg";
@@ -100,8 +101,8 @@ export function EasyModePage({
setLocale,
}: {
onExit?: () => void;
theme?: AppTheme;
setTheme?: (theme: AppTheme) => void;
theme?: ThemePreference;
setTheme?: (theme: ThemePreference) => void;
locale?: AppLocale;
setLocale?: (locale: AppLocale) => void;
}) {
@@ -632,11 +633,13 @@ export function EasyModePage({
<div className="simple-mode-topbar-right">
{/* 主题切换 */}
{setTheme ? (
<div className="simple-mode-theme-group">
<div className="simple-mode-theme-group" role="group" aria-label={t("app.theme.label")}>
<button
type="button"
className={theme === "light" ? "active" : ""}
title={t("easyMode.theme.light")}
aria-label={t("easyMode.theme.light")}
aria-pressed={theme === "light"}
onClick={() => setTheme("light")}
>
<Sun size={15} />
@@ -645,10 +648,22 @@ export function EasyModePage({
type="button"
className={theme === "dark" ? "active" : ""}
title={t("easyMode.theme.dark")}
aria-label={t("easyMode.theme.dark")}
aria-pressed={theme === "dark"}
onClick={() => setTheme("dark")}
>
<Moon size={15} />
</button>
<button
type="button"
className={theme === "system" ? "active" : ""}
title={t("app.theme.switchToSystem")}
aria-label={t("app.theme.system")}
aria-pressed={theme === "system"}
onClick={() => setTheme("system")}
>
<Monitor size={15} />
</button>
</div>
) : null}
+1 -1
View File
@@ -82,7 +82,7 @@ export function parseHarnessDraft(draft: HarnessDraft, group: string, api: strin
if (['modalities', 'strings'].includes(field.kind)) valid = Array.isArray(value) && (value.length > 0 || field.name === 'input')
&& value.every(v => typeof v === 'string' && v.trim() && (field.kind !== 'modalities' || ['text', 'image'].includes(v)))
&& new Set(value).size === value.length;
if (field.kind === 'reasoning') valid = value === false || (isHarnessRecord(value) && Object.keys(value).length > 0
if (field.kind === 'reasoning') valid = value === false || (isHarnessRecord(value) && Object.keys(value).some(level => level !== 'off')
&& Object.entries(value).every(([level, wire]) => harnessReasoningLevels.includes(level)
&& ((level === 'off' && wire === null) || (typeof wire === 'string' && wire.trim()))));
if (field.kind === 'headers') valid = isHarnessRecord(value)
+4
View File
@@ -660,6 +660,10 @@ textarea:focus-visible {
white-space: nowrap;
}
.sidebar-theme-selector button:last-child {
grid-column: 1 / -1;
}
.sidebar-theme-selector button:hover {
background: var(--theme-e8e5dc);
color: var(--theme-2d2a26);
+74 -29
View File
@@ -1,7 +1,10 @@
import { useSyncExternalStore } from 'react';
import { invoke, isTauri } from '@tauri-apps/api/core';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { createThemeController, type AppTheme, type ThemePreference } from './themeController';
export type AppTheme = 'light' | 'dark';
export type { AppTheme, ThemePreference } from './themeController';
const STORAGE_KEY = 'easy-cli-proxy-api.theme';
const WINDOW_BACKGROUND: Record<AppTheme, string> = {
@@ -9,46 +12,88 @@ const WINDOW_BACKGROUND: Record<AppTheme, string> = {
dark: '#111412',
};
async function applyNativeTheme(theme: AppTheme): Promise<void> {
const background = WINDOW_BACKGROUND[theme];
const currentWindow = getCurrentWindow();
const currentWebview = getCurrentWebview();
await Promise.allSettled([
currentWindow.setTheme(theme),
currentWindow.setBackgroundColor(background),
currentWebview.setBackgroundColor(background),
]);
}
export function detectInitialTheme(): AppTheme {
if (typeof window === 'undefined') return 'light';
export function detectThemePreference(): ThemePreference {
try {
const saved = window.localStorage.getItem(STORAGE_KEY);
if (saved === 'light' || saved === 'dark') return saved;
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved;
} catch {
// Storage may be unavailable in a restricted WebView; use the OS preference.
}
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
return 'system';
}
export function applyTheme(theme: AppTheme): void {
function applyTheme(theme: AppTheme): void {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.style.backgroundColor = WINDOW_BACKGROUND[theme];
if (document.body) {
document.body.style.backgroundColor = WINDOW_BACKGROUND[theme];
}
void applyNativeTheme(theme);
}
export function saveTheme(theme: AppTheme): void {
applyTheme(theme);
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
// The in-memory theme still works when persistent storage is unavailable.
}
let controller: ReturnType<typeof createThemeController> | undefined;
export function initializeTheme() {
if (controller) return controller;
const media = window.matchMedia?.('(prefers-color-scheme: dark)');
controller = createThemeController({
readPreference: detectThemePreference,
savePreference(preference) {
try {
window.localStorage.setItem(STORAGE_KEY, preference);
} catch {
}
},
readMediaTheme: () => media?.matches ? 'dark' : 'light',
listenMedia(listener) {
if (!media) return () => {};
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}
media.addListener(listener);
return () => media.removeListener(listener);
},
listenResume(listener) {
const visible = () => { if (document.visibilityState === 'visible') listener(); };
window.addEventListener('focus', listener);
document.addEventListener('visibilitychange', visible);
return () => {
window.removeEventListener('focus', listener);
document.removeEventListener('visibilitychange', visible);
};
},
applyTheme,
async connectNative() {
if (!isTauri()) return null;
const currentWindow = getCurrentWindow();
const currentWebview = getCurrentWebview();
const { os } = await invoke<{ os: string }>('detect_core_platform');
return {
explicitSystemAppearance: os === 'linux',
setTheme: (theme) => currentWindow.setTheme(theme),
readTheme: () => os === 'linux'
? invoke<AppTheme | null>('get_linux_system_theme')
: currentWindow.theme(),
listen: (listener) => currentWindow.onThemeChanged(({ payload }) => listener(payload)),
async setBackground(theme) {
const background = WINDOW_BACKGROUND[theme];
await Promise.allSettled([
currentWindow.setBackgroundColor(background),
currentWebview.setBackgroundColor(background),
]);
},
};
},
});
return controller;
}
export function useThemePreference() {
const themeController = initializeTheme();
const preference = useSyncExternalStore(themeController.subscribe, themeController.getPreference);
return [preference, themeController.setPreference] as const;
}
if (import.meta.hot) {
import.meta.hot.dispose(() => controller?.dispose());
}
+157
View File
@@ -0,0 +1,157 @@
export type AppTheme = 'light' | 'dark';
export type ThemePreference = AppTheme | 'system';
type Unlisten = () => void;
export interface NativeThemeSource {
setTheme(theme: AppTheme | null): Promise<void>;
readTheme(): Promise<AppTheme | null>;
listen(listener: (theme: AppTheme) => void): Promise<Unlisten>;
setBackground(theme: AppTheme): Promise<void>;
explicitSystemAppearance: boolean;
}
export interface ThemeEnvironment {
readPreference(): ThemePreference;
savePreference(preference: ThemePreference): void;
readMediaTheme(): AppTheme;
listenMedia(listener: () => void): Unlisten;
listenResume(listener: () => void): Unlisten;
applyTheme(theme: AppTheme): void;
connectNative(): Promise<NativeThemeSource | null>;
}
export function createThemeController(environment: ThemeEnvironment) {
let preference = environment.readPreference();
let native: NativeThemeSource | null = null;
let disposed = false;
let generation = 0;
let observation = 0;
let configuring = false;
let nativeAppearance: AppTheme | null | undefined;
let queue = Promise.resolve();
const subscribers = new Set<Unlisten>();
const cleanup: Unlisten[] = [];
const current = (version: number) => !disposed && version === generation;
const apply = (theme: AppTheme) => environment.applyTheme(theme);
function enqueue(work: () => Promise<void>) {
queue = queue.then(work).catch(() => {
});
}
async function background(theme: AppTheme, version: number) {
if (current(version) && native) await native.setBackground(theme);
}
async function readSystem(version: number) {
if (!current(version) || preference !== 'system' || !native) return;
const reading = ++observation;
const theme = await native.readTheme().catch(() => null);
if (!current(version) || reading !== observation) return;
const resolved = theme ?? environment.readMediaTheme();
apply(resolved);
if (theme && native.explicitSystemAppearance && nativeAppearance !== theme) {
configuring = true;
try {
nativeAppearance = theme;
await native.setTheme(theme);
} catch {
nativeAppearance = undefined;
} finally {
configuring = false;
}
if (nativeAppearance && nativeAppearance !== theme) refreshSystem();
}
await background(resolved, version);
}
function refreshSystem() {
if (disposed || preference !== 'system') return;
if (!native) {
apply(environment.readMediaTheme());
return;
}
const version = generation;
enqueue(() => readSystem(version));
}
function configureNative() {
const version = generation;
enqueue(async () => {
if (!current(version) || !native) return;
configuring = true;
try {
nativeAppearance = preference === 'system' ? null : preference;
await native.setTheme(nativeAppearance);
} catch {
nativeAppearance = undefined;
} finally {
configuring = false;
}
if (!current(version)) return;
if (preference === 'system') await readSystem(version);
else {
if (nativeAppearance && nativeAppearance !== preference) configureNative();
await background(preference, version);
}
});
}
function nativeChanged(theme: AppTheme) {
if (disposed || !native) return;
nativeAppearance = theme;
if (configuring) return;
if (preference !== 'system') {
if (theme !== preference) configureNative();
return;
}
if (native.explicitSystemAppearance) {
refreshSystem();
return;
}
++observation;
apply(theme);
const version = generation;
enqueue(() => background(theme, version));
}
apply(preference === 'system' ? environment.readMediaTheme() : preference);
cleanup.push(environment.listenMedia(refreshSystem), environment.listenResume(refreshSystem));
void environment.connectNative().then(async (source) => {
if (disposed || !source) return;
native = source;
try {
const unlisten = await source.listen(nativeChanged);
if (disposed) unlisten();
else cleanup.push(unlisten);
} catch {
}
if (!disposed) configureNative();
}).catch(() => {
});
return {
getPreference: () => preference,
subscribe(listener: Unlisten) {
subscribers.add(listener);
return () => { subscribers.delete(listener); };
},
setPreference(next: ThemePreference) {
if (disposed || preference === next) return;
preference = next;
++generation;
++observation;
environment.savePreference(next);
if (next !== 'system') apply(next);
else if (!native) apply(environment.readMediaTheme());
configureNative();
subscribers.forEach((listener) => listener());
},
dispose() {
disposed = true;
++generation;
cleanup.forEach((unlisten) => unlisten());
subscribers.clear();
},
};
}
+6
View File
@@ -21,6 +21,12 @@ describe('DSH catalog overrides', () => {
expect(() => parseHarnessDraft({ reasoningEfforts: '{"high":null}' }, 'model', 'openai-completions')).toThrow();
expect(() => parseHarnessDraft({ reasoningEfforts: '{}' }, 'model', 'openai-completions')).toThrow();
});
test('reasoning maps require a thinking level beyond off', () => {
for (const reasoningEfforts of ['{"off":null}', '{"off":"none"}']) {
expect(() => parseHarnessDraft({ reasoningEfforts }, 'model', 'openai-completions')).toThrow('reasoningEfforts');
}
expect(parseHarnessDraft({ reasoningEfforts: '{"high":"high"}' }, 'model', 'openai-completions')).toEqual({ reasoningEfforts: { high: 'high' } });
});
test('invalid numbers, JSON, headers and incompatible protocol switches are rejected', () => {
for (const draft of [{ maxTokens: '-1' }, { input: '["audio"]' }, { contextWindow: '2.5' }, { 'compat.supportsStore': 'null' }, { 'compat.supportsTemperature': 'true' }]) {
expect(() => parseHarnessDraft(draft, 'model', 'openai-completions')).toThrow();
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head>
<body><div id="root"></div><script type="module" src="./theme.tsx"></script></body>
</html>
+63
View File
@@ -0,0 +1,63 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC, mockWindows } from '@tauri-apps/api/mocks';
import { emit } from '@tauri-apps/api/event';
import App from '../../src/App';
import { I18nProvider } from '../../src/i18n';
import { initializeTheme } from '../../src/theme';
import type { AppTheme } from '../../src/themeController';
import '../../src/styles.css';
const params = new URLSearchParams(location.search);
const platform = params.get('platform') || 'windows';
const storageKey = 'easy-cli-proxy-api.theme';
if (params.has('reset')) localStorage.removeItem(storageKey);
if (params.has('saved')) localStorage.setItem(storageKey, params.get('saved')!);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN');
let system: AppTheme = params.get('system') === 'light' ? 'light' : 'dark';
let override: AppTheme | null = null;
let appearance: AppTheme = system;
const calls: { cmd: string; args: unknown }[] = [];
const notify = (theme: AppTheme) => emit('tauri://theme-changed', theme);
mockWindows('main');
mockIPC(async (cmd, args: any) => {
calls.push({ cmd, args });
if (cmd === 'detect_core_platform') return { os: platform, arch: 'x86_64' };
if (cmd === 'get_linux_system_theme') return params.has('no-portal') ? null : system;
if (cmd === 'plugin:window|theme') return override ?? system;
if (cmd === 'plugin:window|set_theme') {
override = args.value ?? null;
appearance = override ?? (platform === 'linux' ? 'light' : system);
const changed = appearance;
setTimeout(() => void notify(changed), 0);
return;
}
if (cmd.includes('background_color') || cmd === 'set_app_locale') return;
if (cmd === 'plugin:app|version') return '0.0.0';
if (cmd === 'get_core_status') return { installed: false, running: false, starting: false, managed: false };
if (cmd === 'get_gui_settings') return { host: '127.0.0.1', port: 8317, closeBehavior: 'ask' };
if (cmd === 'get_core_config_settings') return { apiKeys: [] };
if (cmd === 'get_core_tls_settings') return { enabled: false };
if (cmd === 'check_app_update') return { updateAvailable: false, currentVersion: '0.0.0' };
if (cmd === 'get_app_update_task') return { running: false, phase: 'idle' };
if (cmd === 'check_latest_core') return { version: '0.0.0' };
throw new Error(`Theme fixture does not implement ${cmd}`);
}, { shouldMockEvents: true });
Object.assign(window, {
isTauri: platform !== 'browser',
themeFixture: {
calls,
get appearance() { return appearance; },
async setSystem(theme: AppTheme) {
system = theme;
if (platform === 'linux' || override === null) {
appearance = theme;
await notify(theme);
}
},
},
});
initializeTheme();
createRoot(document.getElementById('root')!).render(
<StrictMode><I18nProvider><App /></I18nProvider></StrictMode>,
);
+9
View File
@@ -4,6 +4,15 @@ import { en, ja, zhCN, zhTW, type MessageKey } from '../src/i18n/resources';
import { jaOverrides } from '../src/i18n/ja';
describe('i18n', () => {
it('localizes system appearance controls in every language', () => {
expect(translate('zh-CN', 'app.theme.system')).toBe('跟随系统');
expect(translate('zh-TW', 'app.theme.system')).toBe('跟隨系統');
expect(translate('zh-TW', 'app.theme.label')).toBe('外觀模式');
expect(translate('zh-TW', 'app.theme.switchToSystem')).toBe('隨系統自動切換日間和夜間模式');
expect(translate('en', 'app.theme.system')).toBe('Follow system');
expect(translate('ja', 'app.theme.system')).toBe('システムに従う');
});
it('uses credential-file terminology throughout both Chinese locales', () => {
for (const key of ['app.nav.authFiles', 'authFiles.title'] as const) {
expect(translate('zh-CN', key)).toBe('凭证文件');
+85
View File
@@ -0,0 +1,85 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, colorScheme: 'light' });
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
page.setDefaultTimeout(10000);
const open = query => page.goto(`http://127.0.0.1:1422/tests/fixtures/theme.html?${query}`);
const theme = expected => page.waitForFunction(expected => document.documentElement.dataset.theme === expected, expected);
const sidebar = () => page.locator('.sidebar-theme-selector');
const select = index => sidebar().getByRole('button').nth(index).click();
const system = value => page.evaluate(value => window.themeFixture.setSystem(value), value);
const stored = () => page.evaluate(() => localStorage.getItem('easy-cli-proxy-api.theme'));
const nativeReady = () => page.waitForFunction(() => window.themeFixture?.calls.some(call => call.cmd.includes('background_color')));
for (const platform of ['windows', 'macos', 'linux']) {
console.log(`Checking ${platform}`);
await open(`reset&platform=${platform}`);
await nativeReady();
await theme('dark');
assert.equal(await sidebar().getByRole('button').nth(2).getAttribute('aria-pressed'), 'true');
assert.equal(await stored(), null);
await system('light');
await theme('light');
await select(1);
await theme('dark');
await system('light');
assert.equal(await stored(), 'dark');
await page.waitForFunction(() => window.themeFixture.appearance === 'dark');
await select(2);
await theme('light');
assert.equal(await stored(), 'system');
await open(`platform=${platform}&system=dark`);
await nativeReady();
await theme('dark');
assert.equal(await stored(), 'system');
await page.locator('.sidebar-easy-entry').click();
const easy = page.locator('.simple-mode-theme-group');
assert.equal(await easy.getByRole('button').count(), 3);
assert.equal(await easy.getByRole('button').nth(2).getAttribute('aria-pressed'), 'true');
await easy.getByRole('button').nth(0).click();
await theme('light');
await page.locator('.simple-mode-exit-btn').click();
assert.equal(await sidebar().getByRole('button').nth(0).getAttribute('aria-pressed'), 'true');
const writes = await page.evaluate(() => window.themeFixture.calls.filter(call => call.cmd === 'plugin:window|set_theme').length);
assert.ok(writes < 30, `${platform}: native theme echo loop (${writes} writes)`);
}
await open('reset&platform=browser');
await theme('light');
await page.emulateMedia({ colorScheme: 'dark' });
await theme('dark');
await select(0);
await theme('light');
await page.emulateMedia({ colorScheme: 'light' });
await page.emulateMedia({ colorScheme: 'dark' });
assert.equal(await page.locator('html').getAttribute('data-theme'), 'light');
await select(2);
await theme('dark');
await open('platform=browser');
await theme('dark');
assert.equal(await stored(), 'system');
for (const locale of ['zh-CN', 'zh-TW', 'en', 'ja']) for (const width of [1280, 640]) {
await page.setViewportSize({ width, height: 900 });
await open(`reset&platform=windows&locale=${locale}`);
await theme('dark');
const fits = await sidebar().getByRole('button').evaluateAll(buttons => buttons.every(button => button.scrollWidth <= button.clientWidth));
assert.ok(fits, `${locale} at ${width}px: theme label clipped`);
if (locale === 'zh-CN' && width === 1280 && process.env.THEME_SCREENSHOT_DIR) {
await page.screenshot({ path: `${process.env.THEME_SCREENSHOT_DIR}/theme-dark.png`, animations: 'disabled' });
await system('light');
await theme('light');
await page.screenshot({ path: `${process.env.THEME_SCREENSHOT_DIR}/theme-light.png`, animations: 'disabled' });
}
}
assert.deepEqual(errors, []);
console.log('Theme UI passed: Windows/macOS/Linux IPC, browser media, persistence, both selectors, 4 locales at 2 widths.');
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+367
View File
@@ -0,0 +1,367 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createThemeController, type AppTheme, type NativeThemeSource, type ThemePreference } from '../src/themeController';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
async function settle() {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
function setup(options: {
preference?: ThemePreference;
system?: AppTheme;
media?: AppTheme;
platform?: 'windows' | 'macos' | 'linux' | 'browser';
} = {}) {
let saved = options.preference ?? 'system';
let system: AppTheme | null = options.system ?? 'dark';
let media = options.media ?? 'light';
let displayed: AppTheme | undefined;
let shell: AppTheme | undefined;
let backdrop: AppTheme | undefined;
let mediaListener: (() => void) | undefined;
let resumeListener: (() => void) | undefined;
let nativeListener: ((theme: AppTheme) => void) | undefined;
let removals = 0;
const writes: ThemePreference[] = [];
const nativeWrites: (AppTheme | null)[] = [];
const native: NativeThemeSource = {
explicitSystemAppearance: options.platform === 'linux',
async setTheme(theme) {
nativeWrites.push(theme);
shell = theme ?? system ?? 'light';
},
async readTheme() { return system; },
async listen(listener) {
nativeListener = listener;
return () => { nativeListener = undefined; removals++; };
},
async setBackground(theme) { backdrop = theme; },
};
const connection = deferred<NativeThemeSource | null>();
const controller = createThemeController({
readPreference: () => saved,
savePreference(value) { saved = value; writes.push(value); },
readMediaTheme: () => media,
listenMedia(listener) {
mediaListener = listener;
return () => { mediaListener = undefined; removals++; };
},
listenResume(listener) {
resumeListener = listener;
return () => { resumeListener = undefined; removals++; };
},
applyTheme(theme) { displayed = theme; },
connectNative: () => connection.promise,
});
return {
controller, native, writes, nativeWrites,
get displayed() { return displayed; },
get shell() { return shell; },
get backdrop() { return backdrop; },
get saved() { return saved; },
get removals() { return removals; },
setSystem(theme: AppTheme | null) { system = theme; },
emitNative(theme: AppTheme) { shell = theme; nativeListener?.(theme); },
emitMedia(theme: AppTheme) { media = theme; mediaListener?.(); },
resume() { resumeListener?.(); },
async connect() {
connection.resolve(options.platform === 'browser' ? null : native);
await settle();
},
};
}
describe('theme preferences and native synchronization', () => {
for (const platform of ['windows', 'macos'] as const) {
test(`${platform}: native startup and events win without locking system mode`, async () => {
const app = setup({ platform, media: 'light', system: 'dark' });
expect(app.displayed).toBe('light');
await app.connect();
expect(app.displayed).toBe('dark');
expect(app.backdrop).toBe('dark');
expect(app.nativeWrites).toEqual([null]);
app.setSystem('light');
app.emitNative('light');
await settle();
expect(app.displayed).toBe('light');
expect(app.backdrop).toBe('light');
expect(app.controller.getPreference()).toBe('system');
expect(app.writes).toEqual([]);
expect(app.nativeWrites).toEqual([null]);
app.controller.dispose();
});
}
test('native reading wins over a conflicting WebView change', async () => {
const app = setup({ system: 'light' });
await app.connect();
app.emitMedia('dark');
await settle();
expect(app.displayed).toBe('light');
app.controller.dispose();
});
test('preserves an existing manual preference and ignores system/media changes', async () => {
const app = setup({ preference: 'light', system: 'dark' });
await app.connect();
app.emitMedia('dark');
app.emitNative('dark');
await settle();
expect(app.displayed).toBe('light');
expect(app.shell).toBe('light');
expect(app.writes).toEqual([]);
app.controller.dispose();
});
test('switches manual → system → manual and persists the preference, not its result', async () => {
const app = setup({ preference: 'light', system: 'dark' });
await app.connect();
app.controller.setPreference('system');
await settle();
expect(app.displayed).toBe('dark');
expect(app.saved).toBe('system');
expect(app.nativeWrites).toEqual(['light', null]);
app.controller.setPreference('light');
app.setSystem('dark');
app.emitNative('dark');
await settle();
expect(app.displayed).toBe('light');
expect(app.shell).toBe('light');
expect(app.saved).toBe('light');
app.controller.dispose();
});
test('browser preview follows media changes, including after a manual override', async () => {
const app = setup({ platform: 'browser' });
await app.connect();
app.emitMedia('dark');
expect(app.displayed).toBe('dark');
app.controller.setPreference('light');
app.emitMedia('dark');
expect(app.displayed).toBe('light');
app.controller.setPreference('system');
expect(app.displayed).toBe('dark');
app.emitMedia('light');
expect(app.displayed).toBe('light');
app.controller.dispose();
});
test('failed native reads and listeners retain the media fallback', async () => {
const app = setup();
app.native.readTheme = async () => { throw new Error('unavailable'); };
app.native.listen = async () => { throw new Error('unsupported'); };
app.native.setTheme = async () => { throw new Error('unsupported'); };
await app.connect();
app.emitMedia('dark');
await settle();
expect(app.displayed).toBe('dark');
expect(app.controller.getPreference()).toBe('system');
app.controller.dispose();
});
test('refreshes after resuming from tray/sleep when a notification was missed', async () => {
const app = setup();
await app.connect();
app.setSystem('light');
app.resume();
await settle();
expect(app.displayed).toBe('light');
expect(app.backdrop).toBe('light');
app.controller.dispose();
});
test('a delayed initial read cannot overwrite a newer native notification', async () => {
const app = setup();
const reading = deferred<AppTheme>();
app.native.readTheme = () => reading.promise;
await app.connect();
app.emitNative('dark');
reading.resolve('light');
await settle();
expect(app.displayed).toBe('dark');
expect(app.backdrop).toBe('dark');
app.controller.dispose();
});
test('a delayed system read cannot overwrite a newer manual selection', async () => {
const app = setup();
const reading = deferred<AppTheme>();
app.native.readTheme = () => reading.promise;
await app.connect();
app.controller.setPreference('light');
reading.resolve('dark');
await settle();
expect(app.displayed).toBe('light');
expect(app.shell).toBe('light');
expect(app.backdrop).toBe('light');
app.controller.dispose();
});
test('serializes slow native writes during rapid preference changes', async () => {
const app = setup();
const write = deferred<void>();
const original = app.native.setTheme;
app.native.setTheme = async (theme) => {
if (theme === null) await write.promise;
await original(theme);
};
await app.connect();
app.controller.setPreference('light');
app.controller.setPreference('dark');
app.controller.setPreference('system');
app.controller.setPreference('light');
write.resolve();
await settle();
expect(app.nativeWrites).toEqual([null, 'light']);
expect(app.displayed).toBe('light');
expect(app.shell).toBe('light');
app.controller.dispose();
});
test('Linux reads the portal after clearing a manual override and applies GTK appearance', async () => {
const app = setup({ platform: 'linux', preference: 'light', system: 'dark' });
await app.connect();
app.controller.setPreference('system');
await settle();
expect(app.displayed).toBe('dark');
expect(app.shell).toBe('dark');
expect(app.saved).toBe('system');
expect(app.nativeWrites).toEqual(['light', null, 'dark']);
app.emitNative('light');
await settle();
expect(app.displayed).toBe('dark');
expect(app.shell).toBe('dark');
const writeCount = app.nativeWrites.length;
app.emitNative('dark');
app.emitMedia('dark');
await settle();
expect(app.nativeWrites.length).toBe(writeCount);
app.setSystem('light');
app.emitNative('light');
await settle();
expect(app.displayed).toBe('light');
app.controller.dispose();
});
test('Linux without a portal preference falls back to WebKit', async () => {
const app = setup({ platform: 'linux' });
app.setSystem(null);
await app.connect();
app.emitMedia('dark');
await settle();
expect(app.displayed).toBe('dark');
expect(app.nativeWrites).toEqual([null]);
app.controller.dispose();
});
test('Linux retains a manual appearance when the portal sends a different theme', async () => {
const app = setup({ platform: 'linux', preference: 'dark' });
await app.connect();
app.setSystem('light');
app.emitNative('light');
await settle();
expect(app.displayed).toBe('dark');
expect(app.shell).toBe('dark');
app.controller.dispose();
});
test('Linux reconciles a system change delivered during an in-flight GTK appearance write', async () => {
const app = setup({ platform: 'linux', system: 'dark' });
const write = deferred<void>();
const original = app.native.setTheme;
app.native.setTheme = async (theme) => {
await original(theme);
if (theme === 'dark') await write.promise;
};
await app.connect();
app.setSystem('light');
app.emitNative('light');
write.resolve();
await settle();
expect(app.displayed).toBe('light');
expect(app.shell).toBe('light');
expect(app.backdrop).toBe('light');
app.controller.dispose();
});
test('Linux reasserts a manual mode when the portal changes GTK during the manual write', async () => {
const app = setup({ platform: 'linux', preference: 'dark' });
const write = deferred<void>();
const original = app.native.setTheme;
app.native.setTheme = async (theme) => {
await original(theme);
await write.promise;
};
await app.connect();
app.setSystem('light');
app.emitNative('light');
write.resolve();
await settle();
expect(app.displayed).toBe('dark');
expect(app.shell).toBe('dark');
expect(app.backdrop).toBe('dark');
app.controller.dispose();
});
test('disposes all observers and ignores late async reads', async () => {
const app = setup();
const reading = deferred<AppTheme>();
app.native.readTheme = () => reading.promise;
await app.connect();
app.controller.dispose();
reading.resolve('dark');
app.emitMedia('dark');
await settle();
expect(app.removals).toBe(3);
expect(app.displayed).toBe('light');
});
test('cleans up a native listener that attaches after disposal', async () => {
const app = setup();
const registration = deferred<() => void>();
let removed = false;
app.native.listen = () => registration.promise;
await app.connect();
app.controller.dispose();
registration.resolve(() => { removed = true; });
await settle();
expect(removed).toBe(true);
expect(app.nativeWrites).toEqual([]);
});
});
describe('first paint', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)![1];
for (const [saved, dark, expected] of [
[null, true, 'dark'], ['system', true, 'dark'], ['system', false, 'light'],
['light', true, 'light'], ['dark', false, 'dark'], ['invalid', true, 'dark'],
] as const) {
test(`saved ${saved}, dark system ${dark} → ${expected}`, () => {
const root = { dataset: {} as Record<string, string>, style: {} };
runInNewContext(script, {
localStorage: { getItem: () => saved },
window: { matchMedia: () => ({ matches: dark }) },
document: { documentElement: root },
});
expect(root.dataset.theme).toBe(expected);
});
}
test('blocked storage still uses the system theme before React loads', () => {
const root = { dataset: {} as Record<string, string>, style: {} };
runInNewContext(script, {
localStorage: { getItem() { throw new Error('blocked'); } },
window: { matchMedia: () => ({ matches: true }) },
document: { documentElement: root },
});
expect(root.dataset.theme).toBe('dark');
});
});