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:
1 parent
fab5159cd4
commit
8eab980d72
9 files changed
+267
-23
No files matched your search
@@ -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]
|
#[tauri::command]
|
||||||
pub(crate) async fn get_linux_system_theme() -> Option<tauri::Theme> {
|
pub(crate) async fn get_linux_system_theme() -> Option<tauri::Theme> {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -47,6 +80,25 @@ fn portal_color_scheme(value: u32) -> Option<tauri::Theme> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn follows_the_freedesktop_color_scheme_values() {
|
fn follows_the_freedesktop_color_scheme_values() {
|
||||||
assert_eq!(portal_color_scheme(1), Some(tauri::Theme::Dark));
|
assert_eq!(portal_color_scheme(1), Some(tauri::Theme::Dark));
|
||||||
|
|||||||
@@ -2663,6 +2663,8 @@ fn main() {
|
|||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
desktop_theme::get_linux_system_theme,
|
desktop_theme::get_linux_system_theme,
|
||||||
|
desktop_theme::get_theme_preference,
|
||||||
|
desktop_theme::save_theme_preference,
|
||||||
health_check,
|
health_check,
|
||||||
detect_core_platform,
|
detect_core_platform,
|
||||||
get_core_status,
|
get_core_status,
|
||||||
|
|||||||
+2
-2
@@ -4,12 +4,12 @@ import { isTauri } from '@tauri-apps/api/core';
|
|||||||
import { AppErrorBoundary } from './AppErrorBoundary';
|
import { AppErrorBoundary } from './AppErrorBoundary';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { I18nProvider } from './i18n';
|
import { I18nProvider } from './i18n';
|
||||||
import { initializeTheme } from './theme';
|
import { initializeTheme, initializeThemePreferences } from './theme';
|
||||||
import { initializeUsagePreferences } from './services/usagePreferences';
|
import { initializeUsagePreferences } from './services/usagePreferences';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await initializeUsagePreferences();
|
await Promise.all([initializeUsagePreferences(), initializeThemePreferences()]);
|
||||||
if (import.meta.env.DEV && !isTauri()) {
|
if (import.meta.env.DEV && !isTauri()) {
|
||||||
const { installBrowserMock } = await import('./mocks/browserMock');
|
const { installBrowserMock } = await import('./mocks/browserMock');
|
||||||
installBrowserMock();
|
installBrowserMock();
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
.auth-files-page {
|
.auth-files-page {
|
||||||
--auth-muted: #64748b;
|
--auth-muted: #475569;
|
||||||
--auth-text: #334155;
|
--auth-text: #1e293b;
|
||||||
--auth-accent: #3b82f6;
|
--auth-accent: #3b82f6;
|
||||||
--auth-border: #e9edf2;
|
--auth-border: #e9edf2;
|
||||||
|
--auth-control: #c5d0dc;
|
||||||
--success-color: #16b653;
|
--success-color: #16b653;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .auth-files-page {
|
[data-theme="dark"] .auth-files-page {
|
||||||
--auth-muted: #94a3b8;
|
--auth-muted: #cbd5e1;
|
||||||
--auth-text: #e2e8f0;
|
--auth-text: #f1f5f9;
|
||||||
--auth-accent: #70a7ff;
|
--auth-accent: #70a7ff;
|
||||||
--auth-border: #293241;
|
--auth-border: #293241;
|
||||||
|
--auth-control: #3d4c60;
|
||||||
--success-color: #34d399;
|
--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; }
|
.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;
|
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-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:focus-within { border-color: var(--auth-accent); }
|
||||||
.auth-files-search > svg { flex: none; }
|
.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-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-search input::placeholder { color: var(--auth-muted); }
|
||||||
.auth-files-filters { display: flex; align-items: center; gap: 8px; }
|
.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 .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; }
|
||||||
.auth-files-page .auth-files-toolbar select:focus-visible { outline: 2px solid var(--auth-accent); outline-offset: 1px; }
|
: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-table-scroll { min-width: 0; overflow: visible; padding: 0 0 2px; }
|
||||||
.auth-file-list-head { display: none; }
|
.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; }
|
.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.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.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.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-muted { color: var(--auth-muted); font-size: 12px; }
|
||||||
.auth-list-recent, .auth-list-usage, .auth-list-quota { display: grid; gap: 4px; }
|
.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 { 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-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; }
|
.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-requests-blocks { gap: 2px; padding: 0; }
|
||||||
.auth-list-recent .auth-file-request-block { height: 12px; border-radius: 2px; }
|
.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-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 { 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-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; }
|
.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-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 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; }
|
.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 { display: grid; gap: 3px; }
|
||||||
.credential-quota-footnotes:empty { display: none; }
|
.credential-quota-footnotes:empty { display: none; }
|
||||||
.credential-quota-error { display: grid; gap: 4px; color: var(--failure-badge-text); }
|
.credential-quota-error { display: grid; gap: 4px; color: var(--failure-badge-text); }
|
||||||
@@ -229,7 +236,7 @@
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
}
|
}
|
||||||
.auth-files-page .auth-file-card {
|
:root:not([data-theme='dark']) .auth-files-page .auth-file-card {
|
||||||
border-color: #edf0f4;
|
border-color: #edf0f4;
|
||||||
box-shadow: 0 1px 2px rgb(35 48 65 / 3%);
|
box-shadow: 0 1px 2px rgb(35 48 65 / 3%);
|
||||||
}
|
}
|
||||||
@@ -243,7 +250,36 @@
|
|||||||
max-width: 100%;
|
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);
|
border-color: color-mix(in srgb, var(--auth-accent) 26%, #edf0f4);
|
||||||
box-shadow: 0 4px 14px rgb(35 83 160 / 7%);
|
box-shadow: 0 4px 14px rgb(35 83 160 / 7%);
|
||||||
}
|
}
|
||||||
@@ -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 { 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-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-panel-heading,
|
||||||
.home-overview-heading {
|
.home-overview-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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
@@ -432,6 +432,10 @@ code, pre, kbd, samp {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
@@ -487,7 +491,9 @@ textarea:focus-visible {
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 264px minmax(0, 1fr);
|
grid-template-columns: 264px minmax(0, 1fr);
|
||||||
min-height: 100vh;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--theme-faf9f5);
|
background: var(--theme-faf9f5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,6 +873,9 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-pill {
|
.state-pill {
|
||||||
|
|||||||
+13
-8
@@ -3,22 +3,30 @@ import { invoke, isTauri } from '@tauri-apps/api/core';
|
|||||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { createThemeController, type AppTheme, type ThemePreference } from './themeController';
|
import { createThemeController, type AppTheme, type ThemePreference } from './themeController';
|
||||||
|
import { ThemePreferences } from './services/themePreferences';
|
||||||
|
|
||||||
export type { AppTheme, ThemePreference } from './themeController';
|
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> = {
|
const WINDOW_BACKGROUND: Record<AppTheme, string> = {
|
||||||
light: '#ffffff',
|
light: '#ffffff',
|
||||||
dark: '#0b0d11',
|
dark: '#0b0d11',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function detectThemePreference(): ThemePreference {
|
export function detectThemePreference(): ThemePreference {
|
||||||
|
return preferences.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeThemePreferences(): Promise<void> {
|
||||||
|
if (!isTauri()) return;
|
||||||
try {
|
try {
|
||||||
const saved = window.localStorage.getItem(STORAGE_KEY);
|
await preferences.initialize({
|
||||||
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved;
|
load: () => invoke<ThemePreference | null>('get_theme_preference'),
|
||||||
|
save: (preference) => invoke('save_theme_preference', { preference }),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
console.error('Failed to load theme preference; using browser preference');
|
||||||
}
|
}
|
||||||
return 'system';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme: AppTheme): void {
|
function applyTheme(theme: AppTheme): void {
|
||||||
@@ -38,10 +46,7 @@ export function initializeTheme() {
|
|||||||
controller = createThemeController({
|
controller = createThemeController({
|
||||||
readPreference: detectThemePreference,
|
readPreference: detectThemePreference,
|
||||||
savePreference(preference) {
|
savePreference(preference) {
|
||||||
try {
|
preferences.set(preference);
|
||||||
window.localStorage.setItem(STORAGE_KEY, preference);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
readMediaTheme: () => media?.matches ? 'dark' : 'light',
|
readMediaTheme: () => media?.matches ? 'dark' : 'light',
|
||||||
listenMedia(listener) {
|
listenMedia(listener) {
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user