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
This commit is contained in:
lzt404 committed 2026-10-08 18:36:58 +08:00
1 parent fab5159cd4
commit 8eab980d72
9 files changed
+267 -23

No files matched your search

+52
View File
@@ -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<Option<ThemePreference>, 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<Option<ThemePreference>, 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<tauri::Theme> {
#[cfg(target_os = "linux")]
@@ -47,6 +80,25 @@ fn portal_color_scheme(value: u32) -> Option<tauri::Theme> {
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));
+2
View File
@@ -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,
+2 -2
View File
@@ -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();
+48 -12
View File
@@ -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%);
}
+25
View File
@@ -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;
+56
View File
@@ -0,0 +1,56 @@
import type { ThemePreference } from '../themeController';
const STORAGE_KEY = 'easy-cli-proxy-api.theme';
type Backend = {
load(): Promise<ThemePreference | null>;
save(preference: ThemePreference): Promise<unknown>;
};
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; }
}
+10 -1
View File
@@ -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 {
+13 -8
View File
@@ -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<AppTheme, string> = {
light: '#ffffff',
dark: '#0b0d11',
};
export function detectThemePreference(): ThemePreference {
return preferences.get();
}
export async function initializeThemePreferences(): Promise<void> {
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<ThemePreference | null>('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) {
+59
View File
@@ -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([]);
});