diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index dc332cc..0743278 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -134,6 +134,21 @@ body { } } +/* 主题切换图标:显隐由 data-theme 决定(而非 JS 状态),两个图标始终在 DOM 里, + 服务端/客户端首帧结构一致——既避免水合不一致,又无首帧闪烁。 */ +.theme-icon-moon { + display: inline-block; +} +.theme-icon-sun { + display: none; +} +[data-theme="night"] .theme-icon-moon { + display: none; +} +[data-theme="night"] .theme-icon-sun { + display: inline-block; +} + /* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion,见下)。 */ .toast-enter { animation: toast-in var(--dur-base) var(--ease-standard); diff --git a/apps/web/components/ThemeToggle.tsx b/apps/web/components/ThemeToggle.tsx index 165b5f6..53f01e3 100644 --- a/apps/web/components/ThemeToggle.tsx +++ b/apps/web/components/ThemeToggle.tsx @@ -26,13 +26,6 @@ function readStoredThemeMode(): ThemeMode { } } -// 初始模式直接读 ThemeScript 在水合前写入的 ,避免夜读首帧渲染错误图标。 -// SSR 守卫:服务端无 document → 回落默认(与 ThemeScript 的默认一致)。 -function initialThemeMode(): ThemeMode { - if (typeof document === "undefined") return DEFAULT_THEME_MODE; - return normalizeThemeMode(document.documentElement.dataset.theme); -} - function writeStoredThemeMode(mode: ThemeMode) { try { window.localStorage.setItem(THEME_STORAGE_KEY, mode); @@ -42,7 +35,9 @@ function writeStoredThemeMode(mode: ThemeMode) { } export function ThemeToggle() { - const [mode, setMode] = useState(initialThemeMode); + // 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。 + // 可见的图标由 CSS 按 决定(ThemeScript 已在水合前写入),故无首帧闪烁。 + const [mode, setMode] = useState(DEFAULT_THEME_MODE); useEffect(() => { // 水合后以 localStorage 为权威源校正(dataset 已由 ThemeScript 同样依据写入,通常一致)。 @@ -60,8 +55,8 @@ export function ThemeToggle() { }); }; - const Icon = mode === "night" ? Sun : Moon; - + // 图标显隐交给 CSS(按 data-theme),两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。 + // 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。 return ( );