fix(theme): synchronize dark mode across app and tray

This commit is contained in:
jesieleo
2026-07-19 13:16:29 +08:00
parent 19973394d2
commit 107863919c
16 changed files with 233 additions and 29 deletions
+37
View File
@@ -323,19 +323,56 @@ export async function loadAppConfig(): Promise<AppConfig> {
}
}
let appConfigWriteQueue: Promise<void> = Promise.resolve();
let appThemePreferenceOverride: AppConfig["theme"] | undefined;
export async function saveAppConfig(config: AppConfig): Promise<AppConfig> {
return enqueueAppConfigWrite(() => saveAppConfigNow(config));
}
export async function saveAppThemePreference(theme: unknown): Promise<AppConfig["theme"]> {
const normalizedTheme = normalizeAppThemePreference(theme);
appThemePreferenceOverride = normalizedTheme;
return enqueueAppConfigWrite(async () => {
const currentConfig = await loadAppConfig();
await writeSanitizedConfig({
...currentConfig,
theme: normalizedTheme
});
return normalizedTheme;
});
}
async function saveAppConfigNow(config: AppConfig): Promise<AppConfig> {
const normalizedConfig = withSingleEnabledGlobalProfiles(config);
assertProviderApiKeysAreSafe(normalizedConfig);
const apiKeys = ensureGatewayApiKeys(normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey)));
await replacePersistedApiKeys(apiKeys);
await writeSanitizedConfig({
...normalizedConfig,
theme: appThemePreferenceOverride ?? normalizedConfig.theme,
APIKEY: apiKeys[0]?.key ?? "",
APIKEYS: apiKeys
});
return loadAppConfig();
}
function normalizeAppThemePreference(theme: unknown): AppConfig["theme"] {
if (theme === "system" || theme === "light" || theme === "dark") {
return theme;
}
throw new Error("Invalid theme preference.");
}
function enqueueAppConfigWrite<T>(operation: () => Promise<T>): Promise<T> {
const result = appConfigWriteQueue.then(operation, operation);
appConfigWriteQueue = result.then(
() => undefined,
() => undefined
);
return result;
}
function withSingleEnabledGlobalProfiles(config: AppConfig): AppConfig {
return {
...config,
@@ -63,6 +63,7 @@ export const IPC_CHANNELS = {
appSaveConfig: "ccr:app:save-config",
appSetOnboardingFinished: "ccr:app:set-onboarding-finished",
appSetTrayDetailOpen: "ccr:app:set-tray-detail-open",
appSetThemePreference: "ccr:app:set-theme-preference",
appSetProxyNetworkCaptureEnabled: "ccr:app:set-proxy-network-capture-enabled",
appSelectPluginDirectory: "ccr:app:select-plugin-directory",
appShowMainWindow: "ccr:app:show-main-window",
@@ -71,6 +72,7 @@ export const IPC_CHANNELS = {
appUpdateDownload: "ccr:app:update-download",
appUpdateInstall: "ccr:app:update-install",
appUpdateStatusChanged: "ccr:app:update-status-changed",
appThemePreferenceChanged: "ccr:app:theme-preference-changed",
browserBack: "ccr:browser:back",
browserCloseTab: "ccr:browser:close-tab",
browserForward: "ccr:browser:forward",
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/config/app-config-store.ts";
import { loadAppConfig, saveAppConfig, saveAppThemePreference } from "@ccr/core/config/config.ts";
test("theme preference persistence changes only the theme field", async () => {
const current = await loadAppConfig();
const markerHost = "theme-preference.test";
await replacePersistedAppConfig({
...current,
HOST: markerHost,
theme: "system"
});
const savedTheme = await saveAppThemePreference("dark");
const persisted = await loadPersistedAppConfig();
assert.equal(savedTheme, "dark");
assert.equal(persisted.theme, "dark");
assert.equal(persisted.HOST, markerHost);
assert.equal((await loadAppConfig()).theme, "dark");
const staleConfig = {
...current,
HOST: "theme-preference-stale-save.test",
theme: "system"
};
const savedConfig = await saveAppConfig(staleConfig);
assert.equal(savedConfig.theme, "dark");
assert.equal(savedConfig.HOST, staleConfig.HOST);
});
test("theme preference persistence rejects unsupported values", async () => {
await assert.rejects(
saveAppThemePreference("sepia"),
/Invalid theme preference/
);
});
+12 -2
View File
@@ -11,7 +11,7 @@ import { closeBotGatewayQrWindow, openBotGatewayQrWindow } from "./bot-gateway-q
import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch";
import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch";
import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config";
import { loadAppConfig, saveApiKeysConfig, saveAppConfig, saveAppThemePreference } from "@ccr/core/config/config";
import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants";
import { deepLinkService } from "./deep-link";
import { gatewayService } from "@ccr/core/gateway/service";
@@ -58,6 +58,11 @@ const pluginMarketplace: PluginMarketplaceEntry[] = [
const onboardingFinishedAtSettingKey = "onboardingFinishedAt";
const imageExportTargets = new Map<string, string>();
function applyAppThemePreference(theme: AppConfig["theme"]): void {
applyNativeThemePreference(theme);
trayController.refreshTheme(theme);
}
ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
const chatgptAppPath = findInstalledCodexAppExecutable().executable;
const opencodeAppPath = findInstalledOpenCodeAppExecutable().executable;
@@ -271,7 +276,7 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt
}
const launchAtLoginChanged = Boolean(config.launchAtLogin) !== Boolean(previousConfig.launchAtLogin);
let savedConfig = await saveAppConfig(config);
applyNativeThemePreference(savedConfig.theme);
applyAppThemePreference(savedConfig.theme);
if (launchAtLoginChanged) {
try {
syncLaunchAtLogin(savedConfig);
@@ -299,6 +304,11 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt
invalidateProviderAccountSnapshotCache();
return savedConfig;
});
ipcMain.handle(IPC_CHANNELS.appSetThemePreference, async (_event, theme: unknown) => {
const savedTheme = await saveAppThemePreference(theme);
applyAppThemePreference(savedTheme);
return savedTheme;
});
ipcMain.handle(IPC_CHANNELS.appSaveApiKeys, async (_event, apiKeys: ApiKeyConfig[]) => {
const savedConfig = await saveApiKeysConfig(apiKeys);
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig);
+6
View File
@@ -148,6 +148,7 @@ contextBridge.exposeInMainWorld("ccr", {
selectPluginDirectory: () => invoke(IPC_CHANNELS.appSelectPluginDirectory) as Promise<PluginDirectorySelection | undefined>,
setOnboardingFinished: () => invoke(IPC_CHANNELS.appSetOnboardingFinished) as Promise<boolean>,
setProxyNetworkCaptureEnabled: (enabled: boolean) => invoke(IPC_CHANNELS.appSetProxyNetworkCaptureEnabled, enabled) as Promise<ProxyNetworkSnapshot>,
setThemePreference: (theme: AppConfig["theme"]) => invoke(IPC_CHANNELS.appSetThemePreference, theme) as Promise<AppConfig["theme"]>,
setTrayDetailOpen: (open: boolean, provider?: string) => invoke(IPC_CHANNELS.appSetTrayDetailOpen, open, provider) as Promise<void>,
showMainWindow: () => invoke(IPC_CHANNELS.appShowMainWindow) as Promise<void>,
startGateway: () => invoke(IPC_CHANNELS.appStartGateway) as Promise<GatewayStatus>,
@@ -181,6 +182,11 @@ contextBridge.exposeInMainWorld("ccr", {
ipcRenderer.on(IPC_CHANNELS.appOpenUpdate, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.appOpenUpdate, handler);
},
onThemePreferenceChanged: (callback: (theme: AppConfig["theme"]) => void) => {
const handler = (_event: Electron.IpcRendererEvent, theme: AppConfig["theme"]) => callback(theme);
ipcRenderer.on(IPC_CHANNELS.appThemePreferenceChanged, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.appThemePreferenceChanged, handler);
},
onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => {
const handler = (_event: Electron.IpcRendererEvent, status: AppUpdateStatus) => callback(status);
ipcRenderer.on(IPC_CHANNELS.appUpdateStatusChanged, handler);
+26 -10
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { deflateSync } from "node:zlib";
import { loadAppConfig } from "@ccr/core/config/config";
import { APP_NAME } from "@ccr/core/config/constants";
import { APP_NAME, IPC_CHANNELS } from "@ccr/core/config/constants";
import { getProviderAccountSnapshots } from "@ccr/core/providers/account-service";
import { getTodayUsageTotals, onUsageRecorded } from "@ccr/core/usage/store";
import windowsManager from "./windows";
@@ -144,6 +144,18 @@ class TrayController {
this.applyTrayIcon(this.resolveTrayIconId(nextPreference));
}
refreshTheme(theme: AppConfig["theme"]): void {
for (const window of [this.popover, this.detailPopover]) {
if (!window || window.isDestroyed()) {
continue;
}
applyTrayWindowMaterial(window);
if (!window.webContents.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.appThemePreferenceChanged, theme);
}
}
}
setDetailOpen(open: boolean, _provider?: string): void {
if (open) {
this.detailOpen = false;
@@ -566,15 +578,7 @@ function normalizeDetailProvider(provider?: string): string | undefined {
function reinforceTrayWindowMaterial(window: BrowserWindow): void {
const applyMaterial = () => {
if (window.isDestroyed()) {
return;
}
if (process.platform === "darwin") {
window.setBackgroundColor("#00000000");
window.setVibrancy("under-window");
return;
}
window.setBackgroundColor(trayWindowBackgroundColor());
applyTrayWindowMaterial(window);
};
applyMaterial();
@@ -585,6 +589,18 @@ function reinforceTrayWindowMaterial(window: BrowserWindow): void {
}
}
function applyTrayWindowMaterial(window: BrowserWindow): void {
if (window.isDestroyed()) {
return;
}
if (process.platform === "darwin") {
window.setBackgroundColor("#00000000");
window.setVibrancy("under-window");
return;
}
window.setBackgroundColor(trayWindowBackgroundColor());
}
function trayWindowBackgroundColor(): string {
return nativeTheme.shouldUseDarkColors
? trayWindowDarkBackgroundColor
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels.ts";
import { nativeThemeSource } from "@ccr/electron/main/native-theme.ts";
test("native theme source maps explicit preferences and system fallback", () => {
@@ -8,3 +9,8 @@ test("native theme source maps explicit preferences and system fallback", () =>
assert.equal(nativeThemeSource("system"), "system");
assert.equal(nativeThemeSource(undefined), "system");
});
test("theme preference IPC uses separate save and renderer notification channels", () => {
assert.equal(IPC_CHANNELS.appSetThemePreference, "ccr:app:set-theme-preference");
assert.equal(IPC_CHANNELS.appThemePreferenceChanged, "ccr:app:theme-preference-changed");
});
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
className={cn("overflow-hidden rounded-lg border border-border bg-card text-card-foreground transition-shadow duration-200", className)}
ref={ref}
style={{
boxShadow: "0 1px 2px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.02), inset 0 1px 0 rgba(255,255,255,0.5)",
boxShadow: "0 1px 2px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.02), inset 0 1px 0 var(--card-inset-highlight)",
...style
}}
{...props}
+2 -2
View File
@@ -16,7 +16,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ children, className, onChange, onValueChange, options, ...props }, ref) => (
<select
className={cn(
"h-8 w-full min-w-0 appearance-none rounded-md border border-input bg-background bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%239aa5b1%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22><path d=%22m6 9 6 6 6-6%22/></svg>')] bg-[length:16px] bg-[right_8px_center] bg-no-repeat px-3 pr-8 text-[12px] text-foreground shadow-[inset_0_1px_1px_rgba(0,0,0,0.03)] outline-none transition-[background-color,border-color,box-shadow,color] hover:border-muted-foreground/45 focus:border-primary/60 focus:ring-2 focus:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50",
"theme-aware-select h-8 w-full min-w-0 appearance-none rounded-md border border-input bg-background bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%239aa5b1%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22><path d=%22m6 9 6 6 6-6%22/></svg>')] bg-[length:16px] bg-[right_8px_center] bg-no-repeat px-3 pr-8 text-[12px] text-foreground shadow-[inset_0_1px_1px_rgba(0,0,0,0.03)] outline-none transition-[background-color,border-color,box-shadow,color] hover:border-muted-foreground/45 focus:border-primary/60 focus:ring-2 focus:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
onChange={(event) => {
@@ -28,7 +28,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
>
{options
? options.map((option) => (
<option disabled={option.disabled} key={option.value} value={option.value}>
<option className="theme-aware-select-option" disabled={option.disabled} key={option.value} value={option.value}>
{option.label}
</option>
))
+45 -11
View File
@@ -261,6 +261,7 @@ function App() {
const [compactLayout, setCompactLayout] = useState(() => window.matchMedia("(max-width: 720px)").matches);
const [toast, setToast] = useState<AppToast>();
const [languagePreference, setLanguagePreference] = useState<AppLanguagePreference>(() => readLanguagePreference());
const [themePreference, setThemePreference] = useState<AppConfig["theme"]>(() => fallbackConfig.theme || "system");
const [systemLanguage, setSystemLanguage] = useState<ResolvedLanguage>(() => detectSystemLanguage());
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => detectSystemTheme());
const [requestLogError, setRequestLogError] = useState("");
@@ -299,14 +300,14 @@ function App() {
useEffect(() => {
const root = document.documentElement;
const theme = draftConfig.theme || "system";
const theme = themePreference;
if (theme === "system") {
root.removeAttribute("data-theme");
return;
}
root.dataset.theme = theme;
}, [draftConfig.theme]);
}, [themePreference]);
useEffect(() => {
document.documentElement.lang = resolvedLanguage === "zh" ? "zh-CN" : "en";
@@ -715,6 +716,7 @@ function App() {
[agentAnalysisEnabled, networkCaptureEnabled, requestLogsEnabled]
);
const autoSaveRequestId = useRef(0);
const themePreferenceRequestId = useRef(0);
const onboardingProfileDraftSource = useRef("");
const providerProbeRequestId = useRef(0);
const providerConnectivityRequestId = useRef(0);
@@ -847,7 +849,10 @@ function App() {
const requestId = autoSaveRequestId.current + 1;
autoSaveRequestId.current = requestId;
const configToSave = draftConfig;
const configToSave = normalizeConfig({
...draftConfig,
theme: themePreference
});
const options = deferProfileApplyOnSave ? { applyProfile: false } : undefined;
const timer = window.setTimeout(() => {
void window.ccr?.saveConfig(configToSave, options)
@@ -865,12 +870,13 @@ function App() {
}, 400);
return () => window.clearTimeout(timer);
}, [dirty, draftConfig, deferProfileApplyOnSave]);
}, [dirty, draftConfig, deferProfileApplyOnSave, themePreference]);
function syncConfigState(config: AppConfig) {
const normalized = normalizeConfig(config);
setSavedConfig(normalized);
setDraftConfig(normalized);
setThemePreference(normalized.theme || "system");
}
function showToast(message: string) {
@@ -990,14 +996,18 @@ function App() {
async function persistConfig(config: AppConfig, setError: (message: string) => void, options?: AppSaveConfigOptions): Promise<boolean> {
autoSaveRequestId.current += 1;
const configWithTheme = normalizeConfig({
...config,
theme: themePreference
});
if (!window.ccr) {
syncConfigState(config);
syncConfigState(configWithTheme);
return true;
}
try {
const saveOptions = options ?? (deferProfileApplyOnSave ? { applyProfile: false } : undefined);
const saved = await window.ccr.saveConfig(config, saveOptions);
const saved = await window.ccr.saveConfig(configWithTheme, saveOptions);
syncConfigState(saved);
setError("");
return true;
@@ -2158,10 +2168,34 @@ function App() {
function changeThemePreference(value: string) {
const theme = normalizeThemePreference(value);
updateConfig((config) => ({
...config,
theme
}));
const previousTheme = themePreference;
setThemePreference(theme);
if (!window.ccr?.setThemePreference) {
updateConfig((config) => ({
...config,
theme
}));
return;
}
const requestId = themePreferenceRequestId.current + 1;
themePreferenceRequestId.current = requestId;
void window.ccr.setThemePreference(theme)
.then((savedTheme) => {
if (themePreferenceRequestId.current !== requestId) {
return;
}
setThemePreference(savedTheme);
setActionError("");
})
.catch((error) => {
if (themePreferenceRequestId.current !== requestId) {
return;
}
setThemePreference(previousTheme);
setActionError(formatError(error));
});
}
function changeLaunchAtLogin(launchAtLogin: boolean) {
@@ -3216,7 +3250,7 @@ function App() {
providers: draftConfig.Providers,
systemLanguage,
systemTheme,
themePreference: draftConfig.theme || "system",
themePreference,
toolHub: draftConfig.toolHub,
providerAccountSnapshots,
trayBalanceProgress: normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress),
@@ -264,9 +264,9 @@ function TokenActivityGrid({
gridRow: cell.dayIndex + 1
}}
>
<span className={`pointer-events-none absolute z-30 hidden min-w-[96px] rounded-md border border-white/10 bg-slate-950/95 px-2 py-1.5 text-left text-[10px] text-slate-100 shadow-[0_10px_24px_rgba(0,0,0,.32)] group-hover:block ${trayActivityTooltipPositionClass(cell, activity.weekCount)}`}>
<span className={`tray-activity-tooltip pointer-events-none absolute z-30 hidden min-w-[96px] rounded-md border px-2 py-1.5 text-left text-[10px] group-hover:block ${trayActivityTooltipPositionClass(cell, activity.weekCount)}`}>
<span className="block font-bold">{cell.dateLabel}</span>
<span className="mt-0.5 block font-medium text-slate-400">{formatActivityTokenCount(cell.totalTokens)} {t("tokens")}</span>
<span className="tray-activity-tooltip-detail mt-0.5 block font-medium">{formatActivityTokenCount(cell.totalTokens)} {t("tokens")}</span>
</span>
</span>
))}
+5 -1
View File
@@ -164,8 +164,12 @@ export function useTrayThemePreference(): void {
};
syncTheme();
const unsubscribeThemePreference = window.ccr?.onThemePreferenceChanged?.(applyTrayThemePreference);
window.addEventListener("focus", syncTheme);
return () => window.removeEventListener("focus", syncTheme);
return () => {
unsubscribeThemePreference?.();
window.removeEventListener("focus", syncTheme);
};
}, []);
}
+32
View File
@@ -41,6 +41,7 @@
--foreground: #23272f;
--card: #ffffff;
--card-foreground: #23272f;
--card-inset-highlight: rgba(255, 255, 255, .5);
--popover: #ffffff;
--popover-foreground: #23272f;
--primary: #0f766e;
@@ -102,6 +103,7 @@
--foreground: #e8ecef;
--card: #181b1f;
--card-foreground: #e8ecef;
--card-inset-highlight: transparent;
--popover: #181b1f;
--popover-foreground: #e8ecef;
--primary: #2dd4bf;
@@ -156,6 +158,7 @@
--foreground: #e8ecef;
--card: #181b1f;
--card-foreground: #e8ecef;
--card-inset-highlight: transparent;
--popover: #181b1f;
--popover-foreground: #e8ecef;
--primary: #2dd4bf;
@@ -213,6 +216,16 @@
width: 100%;
}
.theme-aware-select {
color-scheme: inherit;
}
.theme-aware-select-option,
.theme-aware-select optgroup {
background-color: var(--popover);
color: var(--popover-foreground);
}
html {
overflow: hidden;
}
@@ -261,6 +274,10 @@
--tray-status-error: light-dark(#b4232d, #fecdd3);
--tray-status-warning: light-dark(#8a4b00, #fef3c7);
--tray-status-ok: light-dark(#006b5f, #ccfbf1);
--tray-tooltip-fill: light-dark(rgba(255, 255, 255, .96), rgba(36, 36, 40, .96));
--tray-tooltip-border: light-dark(rgba(60, 60, 67, .2), rgba(255, 255, 255, .14));
--tray-tooltip-shadow: light-dark(rgba(31, 35, 41, .16), rgba(0, 0, 0, .38));
--tray-tooltip-highlight: light-dark(rgba(255, 255, 255, .82), rgba(255, 255, 255, .08));
background: transparent;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
overflow: hidden;
@@ -809,6 +826,21 @@
border-radius: 11px;
}
.tray-activity-tooltip {
-webkit-backdrop-filter: blur(18px) saturate(150%);
backdrop-filter: blur(18px) saturate(150%);
background: var(--tray-tooltip-fill);
border-color: var(--tray-tooltip-border);
box-shadow:
0 10px 28px var(--tray-tooltip-shadow),
inset 0 1px 0 var(--tray-tooltip-highlight);
color: var(--tray-text-primary);
}
.tray-activity-tooltip-detail {
color: var(--tray-text-secondary);
}
.tray-segmented {
background: var(--tray-segmented-fill);
border: 1px solid var(--tray-segmented-border);
+2
View File
@@ -135,6 +135,7 @@ declare global {
selectPluginDirectory: () => Promise<PluginDirectorySelection | undefined>;
setOnboardingFinished: () => Promise<boolean>;
setProxyNetworkCaptureEnabled: (enabled: boolean) => Promise<ProxyNetworkSnapshot>;
setThemePreference?: (theme: AppConfig["theme"]) => Promise<AppConfig["theme"]>;
setTrayDetailOpen: (open: boolean, provider?: string) => Promise<void>;
showMainWindow: () => Promise<void>;
startGateway: () => Promise<GatewayStatus>;
@@ -152,6 +153,7 @@ declare global {
onOpenSettingsRequest: (callback: () => void) => () => void;
onOpenUpdateRequest: (callback: () => void) => () => void;
onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => () => void;
onThemePreferenceChanged?: (callback: (theme: AppConfig["theme"]) => void) => () => void;
onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => () => void;
};
}
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@ccr/ui/components/ui/
import { Checkbox } from "@ccr/ui/components/ui/checkbox.tsx";
import { Input } from "@ccr/ui/components/ui/input.tsx";
import { Label } from "@ccr/ui/components/ui/label.tsx";
import { Select } from "@ccr/ui/components/ui/select.tsx";
import { Switch } from "@ccr/ui/components/ui/switch.tsx";
import { Textarea } from "@ccr/ui/components/ui/textarea.tsx";
import { collapseSidebarToExpandInspectorMorph, playPauseMorph } from "@ccr/ui/lib/morph-icon.ts";
@@ -40,6 +41,18 @@ test("Button unstyled mode keeps caller supplied styling only", () => {
assert.doesNotMatch(html, /inline-flex/);
});
test("Select marks the control and native options for theme-aware rendering", () => {
const html = renderToStaticMarkup(
<Select options={[
{ label: "System", value: "system" },
{ label: "Dark", value: "dark" }
]} value="dark" />
);
assert.match(html, /theme-aware-select/);
assert.equal((html.match(/theme-aware-select-option/g) ?? []).length, 2);
});
test("Badge renders the selected visual variant", () => {
const html = renderToStaticMarkup(
<Badge className="status-badge" variant="warning">
@@ -68,6 +81,8 @@ test("Card primitives compose the expected document structure", () => {
assert.match(html, /settings-card/);
assert.match(html, /<h2 class="[^"]*text-\[13px\][^"]*">Provider settings<\/h2>/);
assert.match(html, /<div class="p-4">Ready<\/div>/);
assert.match(html, /var\(--card-inset-highlight\)/);
assert.doesNotMatch(html, /rgba\(255,255,255,0\.5\)/);
});
test("Switch renders accessible checked and disabled state", () => {
@@ -296,6 +296,8 @@ test("TokenActivityPanel renders summary, grid, and legend", () => {
assert.match(html, /Activity/);
assert.match(html, /Longest streak/);
assert.match(html, /aria-label="Activity Tokens"/);
assert.match(html, /tray-activity-tooltip/);
assert.doesNotMatch(html, /bg-slate-950/);
assert.match(html, /Less/);
assert.match(html, /More/);
});