+
+
) : null}
diff --git a/src/services/deepSeekHarnessCatalog.ts b/src/services/deepSeekHarnessCatalog.ts
index 12edbb8..cecf0cd 100644
--- a/src/services/deepSeekHarnessCatalog.ts
+++ b/src/services/deepSeekHarnessCatalog.ts
@@ -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)
diff --git a/src/styles.css b/src/styles.css
index d1e6955..c71a66d 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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);
diff --git a/src/theme.ts b/src/theme.ts
index f1be694..05437b7 100644
--- a/src/theme.ts
+++ b/src/theme.ts
@@ -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
= {
@@ -9,46 +12,88 @@ const WINDOW_BACKGROUND: Record = {
dark: '#111412',
};
-async function applyNativeTheme(theme: AppTheme): Promise {
- 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 | 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('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());
}
diff --git a/src/themeController.ts b/src/themeController.ts
new file mode 100644
index 0000000..52a1303
--- /dev/null
+++ b/src/themeController.ts
@@ -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;
+ readTheme(): Promise;
+ listen(listener: (theme: AppTheme) => void): Promise;
+ setBackground(theme: AppTheme): Promise;
+ 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;
+}
+
+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();
+ const cleanup: Unlisten[] = [];
+ const current = (version: number) => !disposed && version === generation;
+ const apply = (theme: AppTheme) => environment.applyTheme(theme);
+
+ function enqueue(work: () => Promise) {
+ 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();
+ },
+ };
+}
diff --git a/tests/deepSeekHarnessCatalog.test.ts b/tests/deepSeekHarnessCatalog.test.ts
index a7f2fd5..5d03558 100644
--- a/tests/deepSeekHarnessCatalog.test.ts
+++ b/tests/deepSeekHarnessCatalog.test.ts
@@ -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();
diff --git a/tests/fixtures/theme.html b/tests/fixtures/theme.html
new file mode 100644
index 0000000..8bf1f94
--- /dev/null
+++ b/tests/fixtures/theme.html
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/tests/fixtures/theme.tsx b/tests/fixtures/theme.tsx
new file mode 100644
index 0000000..c831246
--- /dev/null
+++ b/tests/fixtures/theme.tsx
@@ -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(
+ ,
+);
diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts
index 29c0f2f..ae24720 100644
--- a/tests/i18n.test.ts
+++ b/tests/i18n.test.ts
@@ -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('凭证文件');
diff --git a/tests/theme-ui.cjs b/tests/theme-ui.cjs
new file mode 100644
index 0000000..d7e4280
--- /dev/null
+++ b/tests/theme-ui.cjs
@@ -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; });
diff --git a/tests/theme.test.ts b/tests/theme.test.ts
new file mode 100644
index 0000000..2bdfb07
--- /dev/null
+++ b/tests/theme.test.ts
@@ -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() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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(/