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:
@@ -1,7 +1,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicU8, Ordering as AtomicOrdering};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::AtomicU32;
|
||||||
|
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
@@ -22,7 +23,7 @@ static CURRENT_ZOOM: AtomicU32 = AtomicU32::new(100);
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn traffic_light_position() -> tauri::LogicalPosition<f64> {
|
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
|
// Only Y scales with zoom: overlay content shifts vertically with
|
||||||
// font-size changes, but the horizontal inset remains constant.
|
// font-size changes, but the horizontal inset remains constant.
|
||||||
tauri::LogicalPosition::new(TRAFFIC_LIGHT_X, TRAFFIC_LIGHT_Y * zoom / 100.0)
|
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(Some(raw)) = app_metadata_service::get_value(conn, ZOOM_LEVEL_DB_KEY).await {
|
||||||
if let Ok(zoom) = raw.parse::<u32>() {
|
if let Ok(zoom) = raw.parse::<u32>() {
|
||||||
let clamped = zoom.clamp(50, 300);
|
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 {
|
pub struct SettingsWindowState {
|
||||||
owner_window_label: Mutex<Option<String>>,
|
owner_window_label: Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
@@ -60,6 +89,32 @@ pub fn folder_window_label(folder_id: i32) -> String {
|
|||||||
format!("folder-{folder_id}")
|
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>(
|
pub(crate) fn apply_platform_window_style<'a, R, M>(
|
||||||
builder: WebviewWindowBuilder<'a, R, M>,
|
builder: WebviewWindowBuilder<'a, R, M>,
|
||||||
) -> WebviewWindowBuilder<'a, R, M>
|
) -> WebviewWindowBuilder<'a, R, M>
|
||||||
@@ -69,6 +124,12 @@ where
|
|||||||
{
|
{
|
||||||
#[cfg(target_os = "macos")]
|
#[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
|
builder
|
||||||
.hidden_title(true)
|
.hidden_title(true)
|
||||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
.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;
|
let clamped = zoom.clamp(50.0, 300.0) as u32;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[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.
|
// Persist to DB so the next launch reads the correct value.
|
||||||
let _ = app_metadata_service::upsert_value(
|
let _ = app_metadata_service::upsert_value(
|
||||||
@@ -736,3 +797,24 @@ pub async fn update_traffic_light_position(
|
|||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ mod tauri_app {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load saved zoom level for traffic-light positioning before
|
// Load saved appearance settings before any window is created.
|
||||||
// any window is created.
|
|
||||||
tauri::async_runtime::block_on(windows::load_saved_zoom(&db.conn));
|
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
|
// Install bundled expert skills into the central store
|
||||||
// (`~/.codeg/skills/`). Runs in the background and does
|
// (`~/.codeg/skills/`). Runs in the background and does
|
||||||
@@ -341,6 +341,7 @@ mod tauri_app {
|
|||||||
windows::open_push_window,
|
windows::open_push_window,
|
||||||
windows::open_project_boot_window,
|
windows::open_project_boot_window,
|
||||||
windows::update_traffic_light_position,
|
windows::update_traffic_light_position,
|
||||||
|
windows::update_appearance_mode,
|
||||||
project_boot::detect_package_manager,
|
project_boot::detect_package_manager,
|
||||||
project_boot::create_shadcn_project,
|
project_boot::create_shadcn_project,
|
||||||
system_settings::get_system_proxy_settings,
|
system_settings::get_system_proxy_settings,
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ export default async function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body>
|
<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 }} />
|
<script dangerouslySetInnerHTML={{ __html: APPEARANCE_INIT_SCRIPT }} />
|
||||||
{/* Suppress benign ResizeObserver loop warnings (W3C spec §3.3) */}
|
{/* 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>
|
<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>
|
||||||
|
|||||||
@@ -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 = {
|
type AppearanceContextValue = {
|
||||||
themeColor: ThemeColor
|
themeColor: ThemeColor
|
||||||
setThemeColor: (color: ThemeColor) => void
|
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(() => {
|
useEffect(() => {
|
||||||
syncTrafficLightPosition(zoomLevel)
|
syncTrafficLightPosition(zoomLevel)
|
||||||
|
try {
|
||||||
|
syncAppearanceMode(localStorage.getItem("theme") ?? "system")
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -113,6 +126,10 @@ export function AppearanceProvider({
|
|||||||
syncTrafficLightPosition(zoom)
|
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)
|
window.addEventListener("storage", onStorage)
|
||||||
return () => window.removeEventListener("storage", onStorage)
|
return () => window.removeEventListener("storage", onStorage)
|
||||||
|
|||||||
@@ -66,7 +66,18 @@ export function AppearanceSettings() {
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={theme ?? "system"}
|
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">
|
<SelectTrigger className="w-56">
|
||||||
<SelectValue placeholder={t("placeholder")} />
|
<SelectValue placeholder={t("placeholder")} />
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ const SCRIPT = `
|
|||||||
var storedZoom = parseInt(localStorage.getItem("${STORAGE_KEY_ZOOM_LEVEL}") || "", 10);
|
var storedZoom = parseInt(localStorage.getItem("${STORAGE_KEY_ZOOM_LEVEL}") || "", 10);
|
||||||
var zoom = VALID_ZOOMS.indexOf(storedZoom) >= 0 ? storedZoom : 100;
|
var zoom = VALID_ZOOMS.indexOf(storedZoom) >= 0 ? storedZoom : 100;
|
||||||
document.documentElement.style.fontSize = (16 * zoom / 100) + "px";
|
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) {
|
} catch (e) {
|
||||||
// localStorage 不可用时静默走默认
|
// localStorage 不可用时静默走默认
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,6 +454,10 @@ export async function updateTrafficLightPosition(zoom: number): Promise<void> {
|
|||||||
return invoke("update_traffic_light_position", { zoom: zoom as number })
|
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
|
// Folder history commands
|
||||||
|
|
||||||
export async function loadFolderHistory(): Promise<FolderHistoryEntry[]> {
|
export async function loadFolderHistory(): Promise<FolderHistoryEntry[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user