Files
codeg/src/components/appearance-provider.tsx
xintaofei 41b28001af 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>
2026-04-13 11:15:13 +08:00

146 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { createContext, useCallback, useEffect, useState } from "react"
import {
THEME_COLORS,
DEFAULT_THEME_COLOR,
type ThemeColor,
ZOOM_LEVELS,
DEFAULT_ZOOM_LEVEL,
type ZoomLevel,
} from "@/lib/theme-presets"
import {
STORAGE_KEY_THEME_COLOR,
STORAGE_KEY_ZOOM_LEVEL,
} from "@/lib/appearance-script"
function syncTrafficLightPosition(zoom: number) {
if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window))
return
import("@/lib/tauri").then((t) =>
t.updateTrafficLightPosition(zoom).catch(() => {})
)
}
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
zoomLevel: ZoomLevel
setZoomLevel: (zoom: ZoomLevel) => void
}
export const AppearanceContext = createContext<AppearanceContextValue | null>(
null
)
/**
* AppearanceProvider 管理 themeColor 和 zoomLevel 两个外观偏好。
*
* 与 next-themes 完全正交next-themes 负责 <html class="dark/light">
* 这里负责 <html data-theme="..."> 和 <html style="font-size: ...">。
*
* 注意next-themes 的 attribute 配置必须保持 "class"。如果改为 "data-theme"
* 会与本 Provider 冲突,导致主题色无法生效。
*/
export function AppearanceProvider({
children,
}: {
children: React.ReactNode
}) {
// 初始值从 DOM 读取appearance-script.ts 在 hydration 前已经写好),
// 而不是从 localStorage 读 —— 避免 SSR 与 CSR 不一致导致的双闪烁。
const [themeColor, setThemeColorState] = useState<ThemeColor>(() => {
if (typeof document === "undefined") return DEFAULT_THEME_COLOR
const attr = document.documentElement.getAttribute(
"data-theme"
) as ThemeColor | null
return attr && (THEME_COLORS as readonly string[]).includes(attr)
? attr
: DEFAULT_THEME_COLOR
})
const [zoomLevel, setZoomLevelState] = useState<ZoomLevel>(() => {
if (typeof document === "undefined") return DEFAULT_ZOOM_LEVEL
const px = parseFloat(document.documentElement.style.fontSize || "16")
const level = Math.round((px / 16) * 100) as ZoomLevel
return (ZOOM_LEVELS as readonly number[]).includes(level)
? level
: DEFAULT_ZOOM_LEVEL
})
const setThemeColor = useCallback((color: ThemeColor) => {
setThemeColorState(color)
document.documentElement.setAttribute("data-theme", color)
try {
localStorage.setItem(STORAGE_KEY_THEME_COLOR, color)
} catch {
// 隐私模式 / 禁用 storage 时静默忽略,本次会话内仍然生效
}
}, [])
const setZoomLevel = useCallback((zoom: ZoomLevel) => {
setZoomLevelState(zoom)
document.documentElement.style.fontSize = `${(16 * zoom) / 100}px`
syncTrafficLightPosition(zoom)
try {
localStorage.setItem(STORAGE_KEY_ZOOM_LEVEL, String(zoom))
} catch {
// 同上
}
}, [])
// 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
}, [])
// 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_THEME_COLOR && e.newValue) {
const color = e.newValue as ThemeColor
if ((THEME_COLORS as readonly string[]).includes(color)) {
setThemeColorState(color)
document.documentElement.setAttribute("data-theme", color)
}
}
if (e.key === STORAGE_KEY_ZOOM_LEVEL && e.newValue) {
const zoom = parseInt(e.newValue, 10) as ZoomLevel
if ((ZOOM_LEVELS as readonly number[]).includes(zoom)) {
setZoomLevelState(zoom)
document.documentElement.style.fontSize = `${(16 * zoom) / 100}px`
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)
}, [])
return (
<AppearanceContext.Provider
value={{ themeColor, setThemeColor, zoomLevel, setZoomLevel }}
>
{children}
</AppearanceContext.Provider>
)
}