mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-30 17:11:12 +08:00
fix(theme): synchronize dark mode across app and tray
This commit is contained in:
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+2
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user