fix(frontend,macos): reduce dark mode white flash on window open

Detect dark/light mode before React hydrates to eliminate the visible
white-to-dark flash when opening windows in dark mode.

Frontend:
- Inline script now reads next-themes localStorage key and applies
  .dark class, colorScheme, and backgroundColor on <html> before first
  paint
- Add CSS-only fallback via prefers-color-scheme media query in an
  inline <style> tag that fires before any JS executes

macOS backend:
- Detect system dark mode via `defaults read -g AppleInterfaceStyle`
  (cached with OnceLock) and set native window background color to
  match dark theme in apply_platform_window_style
- Persist user appearance mode preference (dark/light/system) to DB
  alongside zoom level so new windows use the correct background
- Add update_appearance_mode Tauri command; frontend syncs on mount,
  on settings change, and on cross-window storage events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-13 11:15:13 +08:00
parent e05ae76453
commit 41b28001af
7 changed files with 145 additions and 9 deletions

View File

@@ -1,7 +1,8 @@
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering as AtomicOrdering};
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::atomic::AtomicU32;
use sea_orm::DatabaseConnection;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
@@ -22,7 +23,7 @@ static CURRENT_ZOOM: AtomicU32 = AtomicU32::new(100);
#[cfg(target_os = "macos")]
fn traffic_light_position() -> tauri::LogicalPosition<f64> {
let zoom = CURRENT_ZOOM.load(Ordering::Relaxed) as f64;
let zoom = CURRENT_ZOOM.load(AtomicOrdering::Relaxed) as f64;
// Only Y scales with zoom: overlay content shifts vertically with
// font-size changes, but the horizontal inset remains constant.
tauri::LogicalPosition::new(TRAFFIC_LIGHT_X, TRAFFIC_LIGHT_Y * zoom / 100.0)
@@ -38,7 +39,7 @@ pub async fn load_saved_zoom(conn: &DatabaseConnection) {
if let Ok(Some(raw)) = app_metadata_service::get_value(conn, ZOOM_LEVEL_DB_KEY).await {
if let Ok(zoom) = raw.parse::<u32>() {
let clamped = zoom.clamp(50, 300);
CURRENT_ZOOM.store(clamped, Ordering::Relaxed);
CURRENT_ZOOM.store(clamped, AtomicOrdering::Relaxed);
}
}
}
@@ -48,6 +49,34 @@ pub async fn load_saved_zoom(conn: &DatabaseConnection) {
}
}
// ---------------------------------------------------------------------------
// Appearance mode persistence (dark / light / system)
// ---------------------------------------------------------------------------
const APPEARANCE_MODE_DB_KEY: &str = "appearance_mode";
/// Encoded appearance mode: 0 = system (default), 1 = dark, 2 = light.
static CACHED_APPEARANCE_MODE: AtomicU8 = AtomicU8::new(0);
const MODE_SYSTEM: u8 = 0;
const MODE_DARK: u8 = 1;
const MODE_LIGHT: u8 = 2;
fn mode_from_str(s: &str) -> u8 {
match s {
"dark" => MODE_DARK,
"light" => MODE_LIGHT,
_ => MODE_SYSTEM,
}
}
/// Load saved appearance mode from DB. Called once at startup.
pub async fn load_saved_appearance_mode(conn: &DatabaseConnection) {
if let Ok(Some(raw)) = app_metadata_service::get_value(conn, APPEARANCE_MODE_DB_KEY).await {
CACHED_APPEARANCE_MODE.store(mode_from_str(&raw), AtomicOrdering::Relaxed);
}
}
pub struct SettingsWindowState {
owner_window_label: Mutex<Option<String>>,
}
@@ -60,6 +89,32 @@ pub fn folder_window_label(folder_id: i32) -> String {
format!("folder-{folder_id}")
}
/// Detect macOS system dark mode via `defaults read`.
/// Result is cached for the process lifetime via `OnceLock`.
#[cfg(target_os = "macos")]
fn is_system_dark_mode() -> bool {
use std::sync::OnceLock;
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
std::process::Command::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
.map(|o| o.status.success()) // key exists only in dark mode
.unwrap_or(false)
})
}
/// Determine whether the window should use a dark background, considering
/// both the user's explicit preference (from DB) and the OS appearance.
#[cfg(target_os = "macos")]
fn should_use_dark_background() -> bool {
match CACHED_APPEARANCE_MODE.load(AtomicOrdering::Relaxed) {
MODE_DARK => true,
MODE_LIGHT => false,
_ => is_system_dark_mode(), // "system" or unknown — follow OS
}
}
pub(crate) fn apply_platform_window_style<'a, R, M>(
builder: WebviewWindowBuilder<'a, R, M>,
) -> WebviewWindowBuilder<'a, R, M>
@@ -69,6 +124,12 @@ where
{
#[cfg(target_os = "macos")]
{
let builder = if should_use_dark_background() {
// oklch(0.145 0 0) ≈ rgb(9,9,11) — matches CSS --background in dark mode
builder.background_color(tauri::window::Color(9, 9, 11, 255))
} else {
builder
};
builder
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay)
@@ -722,7 +783,7 @@ pub async fn update_traffic_light_position(
let clamped = zoom.clamp(50.0, 300.0) as u32;
#[cfg(target_os = "macos")]
CURRENT_ZOOM.store(clamped, Ordering::Relaxed);
CURRENT_ZOOM.store(clamped, AtomicOrdering::Relaxed);
// Persist to DB so the next launch reads the correct value.
let _ = app_metadata_service::upsert_value(
@@ -736,3 +797,24 @@ pub async fn update_traffic_light_position(
Ok(())
}
/// Persist the user's appearance mode ("dark" / "light" / "system") to DB
/// and update the in-memory cache so that subsequent window creations use the
/// correct native background color.
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn update_appearance_mode(
db: tauri::State<'_, AppDatabase>,
mode: String,
) -> Result<(), AppCommandError> {
CACHED_APPEARANCE_MODE.store(mode_from_str(&mode), AtomicOrdering::Relaxed);
let _ = app_metadata_service::upsert_value(
&db.conn,
APPEARANCE_MODE_DB_KEY,
&mode,
)
.await;
Ok(())
}

View File

@@ -83,9 +83,9 @@ mod tauri_app {
}
}
// Load saved zoom level for traffic-light positioning before
// any window is created.
// Load saved appearance settings before any window is created.
tauri::async_runtime::block_on(windows::load_saved_zoom(&db.conn));
tauri::async_runtime::block_on(windows::load_saved_appearance_mode(&db.conn));
// Install bundled expert skills into the central store
// (`~/.codeg/skills/`). Runs in the background and does
@@ -341,6 +341,7 @@ mod tauri_app {
windows::open_push_window,
windows::open_project_boot_window,
windows::update_traffic_light_position,
windows::update_appearance_mode,
project_boot::detect_package_manager,
project_boot::create_shadcn_project,
system_settings::get_system_proxy_settings,

View File

@@ -51,7 +51,13 @@ export default async function RootLayout({
suppressHydrationWarning
>
<body>
{/* Apply appearance preferences (theme color + zoom) before first paint to prevent FOUC */}
{/* CSS-only dark background: applies before JS executes, preventing white flash in dark mode */}
<style
dangerouslySetInnerHTML={{
__html: `@media(prefers-color-scheme:dark){html:not(.light){background-color:#09090b;color-scheme:dark}}`,
}}
/>
{/* Apply appearance preferences (theme color + zoom + dark class) before first paint to prevent FOUC */}
<script dangerouslySetInnerHTML={{ __html: APPEARANCE_INIT_SCRIPT }} />
{/* Suppress benign ResizeObserver loop warnings (W3C spec §3.3) */}
<script>{`window.addEventListener("error",function(e){if(e.message&&e.message.indexOf("ResizeObserver")!==-1){e.stopImmediatePropagation();e.preventDefault()}});window.onerror=function(m){if(typeof m==="string"&&m.indexOf("ResizeObserver")!==-1)return true}`}</script>

View File

@@ -22,6 +22,14 @@ function syncTrafficLightPosition(zoom: number) {
)
}
function syncAppearanceMode(mode: string) {
if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window))
return
import("@/lib/tauri").then((t) =>
t.updateAppearanceMode(mode).catch(() => {})
)
}
type AppearanceContextValue = {
themeColor: ThemeColor
setThemeColor: (color: ThemeColor) => void
@@ -89,9 +97,14 @@ export function AppearanceProvider({
}
}, [])
// Sync traffic-light position on mount (initial zoom)
// Sync traffic-light position and appearance mode on mount
useEffect(() => {
syncTrafficLightPosition(zoomLevel)
try {
syncAppearanceMode(localStorage.getItem("theme") ?? "system")
} catch {
// localStorage unavailable
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -113,6 +126,10 @@ export function AppearanceProvider({
syncTrafficLightPosition(zoom)
}
}
// Sync appearance mode to Tauri DB when changed in another window
if (e.key === "theme") {
syncAppearanceMode(e.newValue ?? "system")
}
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)

View File

@@ -66,7 +66,18 @@ export function AppearanceSettings() {
</label>
<Select
value={theme ?? "system"}
onValueChange={(value) => setTheme(value as ThemeMode)}
onValueChange={(value) => {
setTheme(value as ThemeMode)
// Persist to Tauri DB so native window background matches on next open
if (
typeof window !== "undefined" &&
"__TAURI_INTERNALS__" in window
) {
import("@/lib/tauri").then((t) =>
t.updateAppearanceMode(value).catch(() => {})
)
}
}}
>
<SelectTrigger className="w-56">
<SelectValue placeholder={t("placeholder")} />

View File

@@ -32,6 +32,21 @@ const SCRIPT = `
var storedZoom = parseInt(localStorage.getItem("${STORAGE_KEY_ZOOM_LEVEL}") || "", 10);
var zoom = VALID_ZOOMS.indexOf(storedZoom) >= 0 ? storedZoom : 100;
document.documentElement.style.fontSize = (16 * zoom / 100) + "px";
// 在 next-themes 水合之前同步检测暗色模式,防止白色闪屏。
// next-themes 使用 localStorage key "theme"attribute="class"。
var storedMode = localStorage.getItem("theme");
var isDark = storedMode === "dark" ||
(storedMode !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
if (isDark) {
document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = "dark";
// 直接设置背景色,比等待 CSS 类匹配更快,覆盖"系统浅色 + 应用深色"场景
document.documentElement.style.backgroundColor = "#09090b";
} else {
document.documentElement.style.colorScheme = "light";
document.documentElement.style.backgroundColor = "";
}
} catch (e) {
// localStorage 不可用时静默走默认
}

View File

@@ -454,6 +454,10 @@ export async function updateTrafficLightPosition(zoom: number): Promise<void> {
return invoke("update_traffic_light_position", { zoom: zoom as number })
}
export async function updateAppearanceMode(mode: string): Promise<void> {
return invoke("update_appearance_mode", { mode })
}
// Folder history commands
export async function loadFolderHistory(): Promise<FolderHistoryEntry[]> {