From 8eab980d720463e533a9527348b71b30936d38a6 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 8 Oct 2026 18:18:28 +0800 Subject: [PATCH] style(auth-files): use neutral colors for credential action buttons - Remove color accents from refresh, settings, models, delete, details, and import buttons - Unify borders, text, and hover styles across light and dark themes - Preserve status colors for badges, usage metrics, and toggles --- src-tauri/src/desktop_theme.rs | 52 ++++++++++++++++++++++++ src-tauri/src/main.rs | 2 + src/main.tsx | 4 +- src/pages/AuthFileManagementPage.css | 60 ++++++++++++++++++++++------ src/pages/HomeDashboard.css | 25 ++++++++++++ src/services/themePreferences.ts | 56 ++++++++++++++++++++++++++ src/styles.css | 11 ++++- src/theme.ts | 21 ++++++---- tests/themePreferences.test.ts | 59 +++++++++++++++++++++++++++ 9 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 src/services/themePreferences.ts create mode 100644 tests/themePreferences.test.ts diff --git a/src-tauri/src/desktop_theme.rs b/src-tauri/src/desktop_theme.rs index 3bf3299..087e457 100644 --- a/src-tauri/src/desktop_theme.rs +++ b/src-tauri/src/desktop_theme.rs @@ -1,3 +1,36 @@ +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum ThemePreference { + Light, + Dark, + System, +} + +fn read_preference(path: &std::path::Path) -> Result, String> { + match std::fs::read(path) { + Ok(content) => serde_json::from_slice(&content) + .map(Some) + .map_err(|_| "Failed to parse theme preference".to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err("Failed to read theme preference".to_string()), + } +} + +fn write_preference(path: &std::path::Path, preference: ThemePreference) -> Result<(), String> { + let content = serde_json::to_vec(&preference).map_err(|error| error.to_string())?; + super::write_bytes_atomically(path, &content) +} + +#[tauri::command] +pub(crate) fn get_theme_preference() -> Result, String> { + read_preference(&super::core_base_dir()?.join("theme-preference.json")) +} + +#[tauri::command] +pub(crate) fn save_theme_preference(preference: ThemePreference) -> Result<(), String> { + write_preference(&super::core_base_dir()?.join("theme-preference.json"), preference) +} + #[tauri::command] pub(crate) async fn get_linux_system_theme() -> Option { #[cfg(target_os = "linux")] @@ -47,6 +80,25 @@ fn portal_color_scheme(value: u32) -> Option { mod tests { use super::*; + #[test] + fn theme_preference_survives_restart_and_rejects_invalid_values() { + let directory = std::env::temp_dir().join(format!( + "ezcpa-theme-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("theme-preference.json"); + assert_eq!(read_preference(&path).unwrap(), None); + for preference in [ThemePreference::Light, ThemePreference::Dark, ThemePreference::System] { + write_preference(&path, preference).unwrap(); + assert_eq!(read_preference(&path).unwrap(), Some(preference)); + } + std::fs::write(&path, b"\"invalid\"").unwrap(); + assert!(read_preference(&path).is_err()); + std::fs::remove_dir_all(directory).unwrap(); + } + #[test] fn follows_the_freedesktop_color_scheme_values() { assert_eq!(portal_color_scheme(1), Some(tauri::Theme::Dark)); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 843f652..de49cb0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2663,6 +2663,8 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ desktop_theme::get_linux_system_theme, + desktop_theme::get_theme_preference, + desktop_theme::save_theme_preference, health_check, detect_core_platform, get_core_status, diff --git a/src/main.tsx b/src/main.tsx index cf7275b..99259ee 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,12 +4,12 @@ import { isTauri } from '@tauri-apps/api/core'; import { AppErrorBoundary } from './AppErrorBoundary'; import App from './App'; import { I18nProvider } from './i18n'; -import { initializeTheme } from './theme'; +import { initializeTheme, initializeThemePreferences } from './theme'; import { initializeUsagePreferences } from './services/usagePreferences'; import './styles/index.css'; async function bootstrap() { - await initializeUsagePreferences(); + await Promise.all([initializeUsagePreferences(), initializeThemePreferences()]); if (import.meta.env.DEV && !isTauri()) { const { installBrowserMock } = await import('./mocks/browserMock'); installBrowserMock(); diff --git a/src/pages/AuthFileManagementPage.css b/src/pages/AuthFileManagementPage.css index aef049e..0e685ed 100644 --- a/src/pages/AuthFileManagementPage.css +++ b/src/pages/AuthFileManagementPage.css @@ -1,16 +1,18 @@ .auth-files-page { - --auth-muted: #64748b; - --auth-text: #334155; + --auth-muted: #475569; + --auth-text: #1e293b; --auth-accent: #3b82f6; --auth-border: #e9edf2; + --auth-control: #c5d0dc; --success-color: #16b653; gap: 12px; } [data-theme="dark"] .auth-files-page { - --auth-muted: #94a3b8; - --auth-text: #e2e8f0; + --auth-muted: #cbd5e1; + --auth-text: #f1f5f9; --auth-accent: #70a7ff; --auth-border: #293241; + --auth-control: #3d4c60; --success-color: #34d399; } .auth-files-heading { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; min-height: 36px; } @@ -30,14 +32,18 @@ box-shadow: none; } .auth-files-page .auth-files-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 36px; margin-bottom: 4px; padding: 0; } -.auth-files-search { display: flex; align-items: center; gap: 8px; flex: 1 1 220px; min-width: 0; max-width: 380px; color: var(--auth-muted); background: var(--bg-card); border: 1px solid var(--auth-border); border-radius: 7px; padding: 0 10px; } +.auth-files-search { display: flex; align-items: center; gap: 8px; flex: 1 1 220px; min-width: 0; max-width: 380px; color: var(--auth-text); background: var(--bg-card); border: 1px solid var(--auth-control); border-radius: 8px; padding: 0 10px; } .auth-files-search:focus-within { border-color: var(--auth-accent); } .auth-files-search > svg { flex: none; } .auth-files-page .auth-files-search input { width: 100%; min-width: 0; height: 32px; padding: 0; border: 0; outline: 0; box-shadow: none; background: transparent; color: var(--auth-text); font: inherit; font-size: 12px; } .auth-files-search input::placeholder { color: var(--auth-muted); } .auth-files-filters { display: flex; align-items: center; gap: 8px; } -.auth-files-page .auth-files-toolbar .auth-files-filters select { width: auto; min-width: 0; max-width: 100%; height: 32px; padding: 0 8px; border: 1px solid var(--auth-border); border-radius: 7px; background: var(--bg-card); color: var(--auth-muted); font: inherit; font-size: 12px; } -.auth-files-page .auth-files-toolbar select:focus-visible { outline: 2px solid var(--auth-accent); outline-offset: 1px; } +.auth-files-page .auth-files-toolbar .auth-files-filters select { appearance: none; -webkit-appearance: none; width: auto; min-width: 0; max-width: 100%; height: 32px; padding: 0 30px 0 11px; border: 1px solid var(--theme-d5d2cb); border-radius: 6px; outline: 0; box-shadow: none; background-color: var(--theme-ffffff); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23f8fafc' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 9px center; background-size: 14px; color: var(--theme-2d2a26); color-scheme: dark; font: inherit; font-size: 12px; font-weight: 650; line-height: 30px; cursor: pointer; } +:root:not([data-theme='dark']) .auth-files-page .auth-files-toolbar .auth-files-filters select { color-scheme: light; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%232d2a26' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); } +.auth-files-page .auth-files-toolbar .auth-files-filters select::-ms-expand { display: none; } +.auth-files-page .auth-files-toolbar .auth-files-filters select:hover { border-color: var(--border-hover); } +.auth-files-page .auth-files-toolbar .auth-files-filters select:focus, +.auth-files-page .auth-files-toolbar .auth-files-filters select:focus-visible { border-color: var(--ui-accent); outline: 0; box-shadow: none; } .auth-file-table-scroll { min-width: 0; overflow: visible; padding: 0 0 2px; } .auth-file-list-head { display: none; } .auth-file-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); gap: 12px; align-items: stretch; min-width: 0; } @@ -70,10 +76,10 @@ .auth-status-badge.warning { color: var(--amber-text); background: color-mix(in srgb, var(--amber-text) 8%, var(--bg-card)); } .auth-status-badge.error { color: var(--failure-badge-text); background: color-mix(in srgb, var(--failure-badge-text) 7%, var(--bg-card)); } .auth-status-badge.info { color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 8%, var(--bg-card)); } -.auth-status-badge.neutral { color: var(--auth-muted); background: color-mix(in srgb, var(--auth-muted) 10%, var(--bg-card)); } +.auth-status-badge.neutral { color: var(--auth-text); background: color-mix(in srgb, var(--auth-text) 8%, var(--bg-card)); } .auth-list-muted { color: var(--auth-muted); font-size: 12px; } .auth-list-recent, .auth-list-usage, .auth-list-quota { display: grid; gap: 4px; } -.auth-list-recent::before, .auth-list-usage::before, .auth-list-quota::before { content: attr(data-label); color: var(--auth-muted); font-size: 11px; font-weight: 650; line-height: 1.2; } +.auth-list-recent::before, .auth-list-usage::before, .auth-list-quota::before { content: attr(data-label); color: var(--auth-text); font-size: 12px; font-weight: 700; line-height: 1.2; } .auth-list-recent .auth-file-requests { display: grid; grid-template-columns: max-content minmax(0, 1fr); grid-template-areas: "time bars" "empty empty"; align-items: center; gap: 4px 10px; } .auth-list-recent .auth-file-requests-latest { grid-area: time; color: var(--auth-muted); font-size: 12px; font-weight: 650; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .auth-list-recent .auth-file-requests-timeline { display: contents; } @@ -82,7 +88,7 @@ .auth-list-recent .auth-file-requests-blocks { gap: 2px; padding: 0; } .auth-list-recent .auth-file-request-block { height: 12px; border-radius: 2px; } .auth-list-recent .auth-file-requests-timeline { gap: 5px; font-size: 11px; } -.auth-list-recent .auth-file-requests-unavailable { grid-area: empty; justify-self: start; font-size: 12px; line-height: 1.4; text-align: left; color: var(--auth-muted); } +.auth-list-recent .auth-file-requests-unavailable { grid-area: empty; justify-self: start; font-size: 12px; font-weight: 600; line-height: 1.4; text-align: left; color: var(--auth-text); } .auth-list-usage .auth-file-usage { color: var(--auth-text); } .auth-list-usage .auth-file-usage-metrics { grid-template-columns: repeat(auto-fit, minmax(92px, max-content)); grid-template-rows: none; gap: 4px 14px; justify-content: start; align-content: center; } .auth-list-usage .auth-file-usage-metric { gap: 5px; } @@ -156,6 +162,7 @@ .credential-quota-row.unknown .credential-quota-track { background: repeating-linear-gradient(115deg, var(--border-color) 0 4px, transparent 4px 8px); } .credential-quota small, .credential-quota-empty, .credential-quota-loading { font-size: var(--font-size-caption); line-height: 1.6; color: var(--text-tertiary); overflow-wrap: anywhere; } .credential-quota-empty { margin: 0; } +.auth-files-page .credential-quota-dense .credential-quota-empty { color: var(--auth-text); font-size: 12px; font-weight: 600; } .credential-quota-footnotes { display: grid; gap: 3px; } .credential-quota-footnotes:empty { display: none; } .credential-quota-error { display: grid; gap: 4px; color: var(--failure-badge-text); } @@ -229,7 +236,7 @@ -webkit-box-orient: vertical; -webkit-line-clamp: 3; } -.auth-files-page .auth-file-card { +:root:not([data-theme='dark']) .auth-files-page .auth-file-card { border-color: #edf0f4; box-shadow: 0 1px 2px rgb(35 48 65 / 3%); } @@ -243,7 +250,36 @@ max-width: 100%; } -.auth-files-page .auth-file-card:hover { +/* Keep credential actions visually quiet; color is reserved for status and metrics. */ +.auth-files-page .auth-files-heading-actions > .primary-button, +.auth-files-page .auth-card-actions .auth-list-action, +.auth-files-page .auth-list-details-button { + border-color: var(--auth-control); + background: var(--bg-card); + color: var(--auth-text); + box-shadow: none; +} + +.auth-files-page .auth-files-heading-actions > .primary-button:hover:not(:disabled), +.auth-files-page .auth-card-actions .auth-list-action:not(:disabled):hover, +.auth-files-page .auth-list-details-button:hover { + border-color: var(--border-hover); + background: var(--bg-hover); + color: var(--auth-text); +} + +.auth-files-page .auth-card-actions .auth-list-action.danger, +.auth-files-page .auth-card-actions .auth-list-action.muted { + border-color: var(--auth-control); + background: var(--bg-card); + color: var(--auth-text); +} + +.auth-files-page .credential-quota-refresh:not(:disabled) { + color: var(--auth-text); +} + +:root:not([data-theme='dark']) .auth-files-page .auth-file-card:hover { border-color: color-mix(in srgb, var(--auth-accent) 26%, #edf0f4); box-shadow: 0 4px 14px rgb(35 83 160 / 7%); } diff --git a/src/pages/HomeDashboard.css b/src/pages/HomeDashboard.css index 0a3a507..358a8ca 100644 --- a/src/pages/HomeDashboard.css +++ b/src/pages/HomeDashboard.css @@ -128,6 +128,31 @@ .home-dashboard .home-runtime-refresh { width: 32px; height: 32px; min-width: 32px; border-color: transparent; background: transparent; } .home-dashboard .home-runtime-refresh svg { width: 18px; height: 18px; } +.home-dashboard .home-runtime-stop { + border-color: #a33b32; + background: #a33b32; + color: #fff; + box-shadow: none; +} + +.home-dashboard .home-runtime-stop:hover:not(:disabled) { + border-color: #872f28; + background: #872f28; +} + +[data-theme="dark"] .home-dashboard .home-runtime-stop { + border-color: #8d403b; + background: #6b302c; + color: #f6e4e2; +} + +[data-theme="dark"] .home-dashboard .home-runtime-stop:hover:not(:disabled) { + border-color: #b86a64; + background: #81403b; + color: #fff; +} + + .home-panel-heading, .home-overview-heading { display: flex; diff --git a/src/services/themePreferences.ts b/src/services/themePreferences.ts new file mode 100644 index 0000000..f5d1840 --- /dev/null +++ b/src/services/themePreferences.ts @@ -0,0 +1,56 @@ +import type { ThemePreference } from '../themeController'; + +const STORAGE_KEY = 'easy-cli-proxy-api.theme'; +type Backend = { + load(): Promise; + save(preference: ThemePreference): Promise; +}; + +export class ThemePreferences { + private value?: ThemePreference; + private backend?: Backend; + private pending = Promise.resolve(); + + constructor(private storage: () => Storage) {} + + private readLegacy(): ThemePreference | null { + try { + const saved = this.storage().getItem(STORAGE_KEY); + if (saved === 'light' || saved === 'dark' || saved === 'system') return saved; + } catch {} + return null; + } + + get(): ThemePreference { + return this.value ?? this.readLegacy() ?? 'system'; + } + + async initialize(backend: Backend) { + // Do not overwrite a file with fallback defaults if reading it fails. + const saved = await backend.load(); + const legacy = this.readLegacy(); + this.backend = backend; + this.value = saved ?? legacy ?? 'system'; + if (saved !== null) this.cache(saved); + else if (legacy !== null) { + this.set(legacy); + await this.flush(); + } + } + + private cache(preference: ThemePreference) { + try { this.storage().setItem(STORAGE_KEY, preference); } catch {} + } + + set(preference: ThemePreference) { + this.value = preference; + this.cache(preference); + if (this.backend) { + this.pending = this.pending.then(() => this.backend!.save(preference)).then(() => {}, () => { + console.error('Failed to save theme preference'); + }); + } + } + + flush() { return this.pending; } +} diff --git a/src/styles.css b/src/styles.css index a086e59..4b1738a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -432,6 +432,10 @@ code, pre, kbd, samp { box-sizing: border-box; } +html { + scrollbar-width: thin; +} + html, body, #root { @@ -487,7 +491,9 @@ textarea:focus-visible { .app-shell { display: grid; grid-template-columns: 264px minmax(0, 1fr); - min-height: 100vh; + height: 100%; + min-height: 0; + overflow: hidden; background: var(--theme-faf9f5); } @@ -867,6 +873,9 @@ textarea:focus-visible { .workspace { min-width: 0; + height: 100%; + overflow: auto; + scrollbar-width: thin; } .state-pill { diff --git a/src/theme.ts b/src/theme.ts index c4ff665..c93633b 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -3,22 +3,30 @@ 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'; +import { ThemePreferences } from './services/themePreferences'; export type { AppTheme, ThemePreference } from './themeController'; -const STORAGE_KEY = 'easy-cli-proxy-api.theme'; +const preferences = new ThemePreferences(() => window.localStorage); const WINDOW_BACKGROUND: Record = { light: '#ffffff', dark: '#0b0d11', }; export function detectThemePreference(): ThemePreference { + return preferences.get(); +} + +export async function initializeThemePreferences(): Promise { + if (!isTauri()) return; try { - const saved = window.localStorage.getItem(STORAGE_KEY); - if (saved === 'light' || saved === 'dark' || saved === 'system') return saved; + await preferences.initialize({ + load: () => invoke('get_theme_preference'), + save: (preference) => invoke('save_theme_preference', { preference }), + }); } catch { + console.error('Failed to load theme preference; using browser preference'); } - return 'system'; } function applyTheme(theme: AppTheme): void { @@ -38,10 +46,7 @@ export function initializeTheme() { controller = createThemeController({ readPreference: detectThemePreference, savePreference(preference) { - try { - window.localStorage.setItem(STORAGE_KEY, preference); - } catch { - } + preferences.set(preference); }, readMediaTheme: () => media?.matches ? 'dark' : 'light', listenMedia(listener) { diff --git a/tests/themePreferences.test.ts b/tests/themePreferences.test.ts new file mode 100644 index 0000000..a696fcc --- /dev/null +++ b/tests/themePreferences.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from 'bun:test'; +import { ThemePreferences } from '../src/services/themePreferences'; +import type { ThemePreference } from '../src/themeController'; + +function storage(initial?: string) { + let value = initial ?? null; + return { getItem: () => value, setItem: (_key: string, next: string) => { value = next; } } as Storage; +} + +for (const preference of ['light', 'dark', 'system'] as const) { + test(`migrates ${preference} and restores it after browser storage is lost`, async () => { + let disk: ThemePreference | null = null; + const backend = { load: async () => disk, save: async (next: ThemePreference) => { disk = next; } }; + const first = new ThemePreferences(() => storage(preference)); + await first.initialize(backend); + expect(disk).toBe(preference); + const cache = storage(); + const restarted = new ThemePreferences(() => cache); + await restarted.initialize(backend); + expect(restarted.get()).toBe(preference); + expect(cache.getItem('easy-cli-proxy-api.theme')).toBe(preference); + }); +} + +test('native preference wins over a stale browser preference', async () => { + const preferences = new ThemePreferences(() => storage('light')); + await preferences.initialize({ load: async () => 'dark', save: async () => { throw new Error('unexpected write'); } }); + expect(preferences.get()).toBe('dark'); +}); + +test('saves with blocked browser storage and serializes rapid changes', async () => { + const preferences = new ThemePreferences(() => { throw new Error('blocked'); }); + const writes: ThemePreference[] = []; + await preferences.initialize({ load: async () => null, save: async (next) => { await Promise.resolve(); writes.push(next); } }); + preferences.set('dark'); + preferences.set('light'); + preferences.set('system'); + await preferences.flush(); + expect(writes).toEqual(['dark', 'light', 'system']); + expect(preferences.get()).toBe('system'); +}); + +test('a failed native read does not overwrite the saved file', async () => { + const preferences = new ThemePreferences(() => storage('dark')); + const writes: ThemePreference[] = []; + await expect(preferences.initialize({ load: async () => { throw new Error('unreadable'); }, save: async (next) => { writes.push(next); } })).rejects.toThrow('unreadable'); + expect(preferences.get()).toBe('dark'); + preferences.set('light'); + await preferences.flush(); + expect(writes).toEqual([]); +}); + +test('missing preferences use system without writing a default', async () => { + const preferences = new ThemePreferences(() => storage('invalid')); + const writes: ThemePreference[] = []; + await preferences.initialize({ load: async () => null, save: async (next) => { writes.push(next); } }); + expect(preferences.get()).toBe('system'); + expect(writes).toEqual([]); +});