Files
codeg/.docs/dev-design/2026-04-11-外观设置增强-缩放与主题色.md
xintaofei ab49ff43bc docs(dev-design): add appearance settings enhancement design
新增设计文档:外观设置页面增加 Window Zoom Level 和 Theme Color 两项偏好。
采用 shadcn 官方主题化方案(data-theme 属性 + CSS 变量),localStorage 持久化,
FOUC 防闪烁 inline 脚本,12 个预设主题色盘,6 档窗口缩放。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:11:18 +08:00

676 lines
30 KiB
Markdown
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.
# 外观设置增强Window Zoom Level 与 Theme Color 预设
| 字段 | 值 |
| ---------- | ------------------------------------------ |
| 创建日期 | 2026-04-11 |
| 作者 | xintaofei |
| 关联 Issue | TBD |
| 类型 | 开发详细设计Dev Design |
| 状态 | Draft |
## 1. 背景与目标
### 1.1 背景
当前 `设置 / 外观` 页面 (`src/components/settings/appearance-settings.tsx`) 只有一个"主题模式"选择器(`system / light / dark`,由 `next-themes` 驱动)。项目已经使用 Tailwind CSS v4 + shadcn/uiOKLch 色彩)做基础主题,但用户无法调整:
- **UI 整体缩放**(类似 VSCode `Window: Zoom Level`)——不同分辨率 / 视力偏好下需要整体放大或缩小界面
- **主题强调色**shadcn 官方提供 12 个预设Zinc / Slate / Stone / Gray / Neutral / Red / Rose / Orange / Green / Blue / Yellow / Violet
### 1.2 目标
1.`设置 / 外观` 页面新增两个偏好项:**Window Zoom** 和 **Theme Color**
2. 参考 [shadcn/ui Theming 文档](https://ui.shadcn.com/docs/theming) 的做法,提供 12 个官方主题预设,切换后 primary / accent / ring / border 等所有 shadcn CSS 变量联动更新
3. 保持与现有 `next-themes` 的 light/dark 切换**完全正交**——用户可以在 `Blue` 主题下自由切换浅色/深色
4. 首次加载无闪烁FOUC刷新后保持用户选择
5. 提供"恢复默认"按钮
### 1.3 非目标
- **不跨设备同步**:两个新增偏好均存于浏览器 `localStorage`,每台设备/每个浏览器独立(符合"缩放本就是设备属性"的直觉)
- **不自定义具体颜色值**:只支持 12 个预设,不开放 color picker 或自定义面板
- **不影响 Monaco 编辑器 / 终端等排印学专属字号**:本次缩放只作用于 UI 层,代码编辑器保持独立字号
- **不改动后端**:零后端代码改动,无需新增 API 或数据库字段
## 2. 需求确认摘要
| 决策点 | 选择 |
| -------------------- | ---------------------------------------------------------------------------------------------- |
| 缩放作用范围 | 全局窗口缩放(调 `html { font-size }`,所有 rem 连锁) |
| 缩放档位与交互 | 离散百分比 + shadcn `Select``80% / 90% / 100% / 110% / 125% / 150%`,默认 100%,无快捷键 |
| 主题色定制粒度 | 预设主题色盘12 个 shadcn 官方预设,每个包含 light + dark 两套变量),默认 Zinc |
| 持久化层 | 全部 `localStorage`,每设备独立,不走后端 |
| 预设主题列表 | 12 个 shadcn 标准预设Zinc / Slate / Stone / Gray / Neutral / Red / Rose / Orange / Green / Blue / Yellow / Violet |
| Provider 嵌套顺序 | `<ThemeProvider>` (外) → `<AppearanceProvider>` (内) |
| 跨标签页同步 | 开启(`storage` 事件监听) |
| Hook 拆分 | `useAppearance` + `useThemeColor` + `useZoomLevel` |
| Theme Color UI | 按钮网格(响应式 3/4/6 列),非下拉 |
| 色盘圆点代表色 | 硬编码在 `theme-presets.ts``THEME_COLOR_PREVIEW`,和 `globals.css` 双写但数据可控 |
| 卡片布局 | 三张卡片堆叠在同一个 `/settings/appearance` 页面,不拆子路由 |
| 重置按钮范围 | 只重置 Theme Color 和 Zoom不动 Theme Mode |
| 重置按钮位置与样式 | 页面底部右侧,`variant="outline"` + `size="sm"``RotateCcw` 图标,默认值时 disabled无确认框 |
## 3. 整体架构与数据流
### 3.1 三条正交的运行时状态
| 偏好项 | localStorage key | DOM 写入位置 | 变更来源 |
| ----------- | --------------------- | ----------------------------------- | --------------------------------- |
| Theme Mode | `theme`next-themes| `<html class="dark">` | 既有 `next-themes`,本次不动 |
| Theme Color | `codeg-theme-color` | `<html data-theme="zinc">` | 新增 `useThemeColor()` |
| Zoom Level | `codeg-zoom-level` | `<html style="font-size: 16px">` | 新增 `useZoomLevel()` |
三者完全独立读写,互不干扰。`.dark` 类和 `[data-theme="xxx"]` 属性在 CSS 层叠中是两个正交维度。
### 3.2 数据流示例:用户点选 "Blue" 主题色
```
用户点击 Blue 按钮
useThemeColor().setThemeColor("blue")
① localStorage.setItem("codeg-theme-color", "blue")
② document.documentElement.setAttribute("data-theme", "blue")
CSS 中 [data-theme="blue"] { --primary: ...; ... } 命中
所有使用 --primary / --accent / --ring / --border 的 Tailwind 类立即重算
Monaco 编辑器的 useMonacoThemeSync监听 .dark class不会误触发
```
### 3.3 首次加载 / 硬刷新的防闪烁
`<head>` 顶部注入同步执行的 inline `<script>`,纯 JS 实现,在 HTML 解析阶段立即从 `localStorage` 读出值并写入 `<html>``data-theme``style.fontSize`。React hydration 前 DOM 已经是正确状态,和 `next-themes` 消除 dark/light 闪烁用的是同一套手法。
### 3.4 与 `next-themes` 的边界
- `next-themes` 继续负责 `<html class="dark/light">`
- 本方案只负责 `data-theme` 属性和 `font-size` inline 样式
- `next-themes` 配置保持 `attribute="class"`**不能改为 `data-theme`**(会和本方案冲突)
## 4. 文件结构
### 4.1 新增文件
```
src/
├── lib/
│ ├── theme-presets.ts # 12 个预设的元数据 + 类型定义
│ └── appearance-script.ts # FOUC 防闪烁 inline 脚本字符串
├── components/
│ └── appearance-provider.tsx # Zoom + ThemeColor 的 React Context
└── hooks/
└── use-appearance.ts # useAppearance / useThemeColor / useZoomLevel
```
### 4.2 修改文件
| 文件 | 改动要点 |
| ------------------------------------------------- | ---------------------------------------------------------------- |
| `src/app/globals.css` | 重组 CSS 变量,用 `[data-theme]` 属性选择器承载 12 个预设 |
| `src/app/layout.tsx` | 注入 FOUC inline 脚本;用 `<AppearanceProvider>` 包裹 children |
| `src/components/settings/appearance-settings.tsx` | 新增 Theme Color、Zoom Level、Reset 三个 UI 单元 |
| `src/i18n/messages/*.json`10 个语言文件) | 扩展 `AppearanceSettings` 命名空间 |
## 5. `globals.css` 组织方式
### 5.1 目标结构
```css
/* 1. 结构性变量(所有主题共享) */
:root {
--radius: 0.625rem;
font-size: 16px; /* zoom 会动态覆盖这里 */
}
/* 2. 每个预设的 light 变量 */
[data-theme="zinc"] { --background: oklch(...); --primary: oklch(...); /* ... */ }
[data-theme="slate"] { ... }
[data-theme="stone"] { ... }
[data-theme="gray"] { ... }
[data-theme="neutral"] { ... }
[data-theme="red"] { ... }
[data-theme="rose"] { ... }
[data-theme="orange"] { ... }
[data-theme="green"] { ... }
[data-theme="blue"] { ... }
[data-theme="yellow"] { ... }
[data-theme="violet"] { ... }
/* 3. 每个预设的 dark 变量(与 next-themes 的 .dark 组合) */
[data-theme="zinc"].dark { --background: ...; ... }
[data-theme="slate"].dark { ... }
/* ... 其余 11 个 ... */
/* 4. 兜底inline 脚本尚未写入时,或 localStorage 完全空时) */
:root:not([data-theme]) { /* 等同 [data-theme="zinc"] */ }
:root:not([data-theme]).dark { /* 等同 [data-theme="zinc"].dark */ }
/* 5. @theme inline 映射保持不变 */
@theme inline {
--color-background: var(--background);
--color-primary: var(--primary);
/* ... 其余现有映射 ... */
}
```
### 5.2 变量值来源
从 shadcn 官方 registry`https://ui.shadcn.com/r/themes/<name>.json`)或 shadcn CLI 的预设数据中提取 OKLch 值,确保与官方完全一致,便于未来跟随升级。
### 5.3 迁移策略
当前 `globals.css``:root` 下的变量本质上就是 Zinc 预设,**原样搬到 `[data-theme="zinc"]`**,确保老用户升级后视觉 100% 无差异。
### 5.4 文件体积
预计 +400 行纯数据 CSS全塞在 `globals.css`(与 shadcn 官方 registry 一致)。不拆分成独立文件,便于跟随官方 registry 升级。
## 6. React 层详细设计
### 6.1 `src/lib/theme-presets.ts`
```ts
export const THEME_COLORS = [
"zinc", "slate", "stone", "gray", "neutral",
"red", "rose", "orange", "green", "blue", "yellow", "violet",
] as const
export type ThemeColor = (typeof THEME_COLORS)[number]
export const DEFAULT_THEME_COLOR: ThemeColor = "zinc"
/** UI 预览用的代表色OKLch 字符串,对应各预设的 primary 色 light 版本)。
* 只用于 Appearance 页面绘制"色盘圆点",不会写入真实样式。 */
export const THEME_COLOR_PREVIEW: Record<ThemeColor, string> = {
zinc: "oklch(0.21 0.006 285.885)",
slate: "oklch(0.21 0.034 264.665)",
// ... 其余 10 个在实现时对照 shadcn registry 填入
}
export const ZOOM_LEVELS = [80, 90, 100, 110, 125, 150] as const
export type ZoomLevel = (typeof ZOOM_LEVELS)[number]
export const DEFAULT_ZOOM_LEVEL: ZoomLevel = 100
```
### 6.2 `src/lib/appearance-script.ts`
导出一个**字符串**供 `layout.tsx``<script dangerouslySetInnerHTML>` 注入。脚本自包含、不依赖外部模块符号。
```ts
const STORAGE_KEY_THEME_COLOR = "codeg-theme-color"
const STORAGE_KEY_ZOOM_LEVEL = "codeg-zoom-level"
const SCRIPT = `
(function() {
try {
var VALID_COLORS = ["zinc","slate","stone","gray","neutral","red","rose","orange","green","blue","yellow","violet"];
var VALID_ZOOMS = [80, 90, 100, 110, 125, 150];
var storedColor = localStorage.getItem("${STORAGE_KEY_THEME_COLOR}");
var color = VALID_COLORS.indexOf(storedColor) >= 0 ? storedColor : "zinc";
document.documentElement.setAttribute("data-theme", color);
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";
} catch (e) {}
})();
`
export const APPEARANCE_INIT_SCRIPT = SCRIPT
export { STORAGE_KEY_THEME_COLOR, STORAGE_KEY_ZOOM_LEVEL }
```
要点:
- 纯字符串 + `dangerouslySetInnerHTML`,避免 Next.js 将其当作模块编译
- 白名单校验:非法值兜底到默认
- `try/catch` 包裹:隐私模式 / 禁用 storage 时走默认值,不抛错
- Storage key 常量从此文件导出并被 Provider 复用,保证读写同一 key
### 6.3 `src/components/appearance-provider.tsx`
```tsx
"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"
type Ctx = {
themeColor: ThemeColor
setThemeColor: (c: ThemeColor) => void
zoomLevel: ZoomLevel
setZoomLevel: (z: ZoomLevel) => void
}
export const AppearanceContext = createContext<Ctx | null>(null)
export function AppearanceProvider({ children }: { children: React.ReactNode }) {
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.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.includes(level) ? level : DEFAULT_ZOOM_LEVEL
})
const setThemeColor = useCallback((c: ThemeColor) => {
setThemeColorState(c)
document.documentElement.setAttribute("data-theme", c)
try {
localStorage.setItem(STORAGE_KEY_THEME_COLOR, c)
} catch {}
}, [])
const setZoomLevel = useCallback((z: ZoomLevel) => {
setZoomLevelState(z)
document.documentElement.style.fontSize = `${(16 * z) / 100}px`
try {
localStorage.setItem(STORAGE_KEY_ZOOM_LEVEL, String(z))
} catch {}
}, [])
// 跨标签页同步
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_THEME_COLOR && e.newValue) {
const c = e.newValue as ThemeColor
if (THEME_COLORS.includes(c)) {
setThemeColorState(c)
document.documentElement.setAttribute("data-theme", c)
}
}
if (e.key === STORAGE_KEY_ZOOM_LEVEL && e.newValue) {
const z = parseInt(e.newValue, 10) as ZoomLevel
if (ZOOM_LEVELS.includes(z)) {
setZoomLevelState(z)
document.documentElement.style.fontSize = `${(16 * z) / 100}px`
}
}
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)
}, [])
return (
<AppearanceContext.Provider value={{ themeColor, setThemeColor, zoomLevel, setZoomLevel }}>
{children}
</AppearanceContext.Provider>
)
}
```
### 6.4 `src/hooks/use-appearance.ts`
```ts
"use client"
import { useContext } from "react"
import { AppearanceContext } from "@/components/appearance-provider"
export function useAppearance() {
const ctx = useContext(AppearanceContext)
if (!ctx) throw new Error("useAppearance must be used within AppearanceProvider")
return ctx
}
export function useThemeColor() {
const { themeColor, setThemeColor } = useAppearance()
return { themeColor, setThemeColor }
}
export function useZoomLevel() {
const { zoomLevel, setZoomLevel } = useAppearance()
return { zoomLevel, setZoomLevel }
}
```
### 6.5 `src/app/layout.tsx` 整合
```tsx
import { APPEARANCE_INIT_SCRIPT } from "@/lib/appearance-script"
import { AppearanceProvider } from "@/components/appearance-provider"
// ...
<html lang={locale} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: APPEARANCE_INIT_SCRIPT }} />
</head>
<body className={jetbrainsMono.variable}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<AppearanceProvider>
{/* ... 现有内容 ... */}
</AppearanceProvider>
</ThemeProvider>
</body>
</html>
```
`suppressHydrationWarning` 必要——inline 脚本在 hydration 前修改了 `<html>` 的属性和 style。
## 7. UI 设计(`appearance-settings.tsx`
### 7.1 页面整体结构
```
┌─ 标题Theme Appearance ────────────────────────────────┐
│ │
│ ┌─ Card: Theme Mode ──────────────────────────┐ 已有 │
│ │ [ Follow system ▾ ] │ │
│ │ Current effective theme: Dark │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─ Card: Theme Color ─────────────────────────┐ 新增 │
│ │ 标题 + 描述 │ │
│ │ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │ │
│ │ │● │ │● │ │● │ │● │ │● │ │● │ 12 个按钮 │ │
│ │ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ 响应式网格 │ │
│ │ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │ │
│ │ │● │ │● │ │● │ │● │ │● │ │● │ │ │
│ │ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ │ │
│ │ Current color: Zinc │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─ Card: Zoom Level ──────────────────────────┐ 新增 │
│ │ 标题 + 描述 │ │
│ │ [ 100% ▾ ] │ │
│ │ Current zoom: 100% │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ [ ↺ Reset to defaults ] 新增 │
└─────────────────────────────────────────────────────────┘
```
所有卡片共用 `rounded-xl border bg-card p-4 space-y-4` 样式,与现有 Theme Mode 卡片一致。
### 7.2 Theme Color 按钮网格
```tsx
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
{THEME_COLORS.map((color) => {
const isActive = themeColor === color
return (
<button
key={color}
type="button"
onClick={() => setThemeColor(color)}
className={cn(
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
isActive && "border-primary ring-2 ring-primary/30"
)}
aria-pressed={isActive}
>
<span
className="size-4 shrink-0 rounded-full border"
style={{ backgroundColor: THEME_COLOR_PREVIEW[color] }}
/>
<span className="truncate">{t(`themeColor.options.${color}`)}</span>
</button>
)
})}
</div>
```
要点:
- 色盘圆点用 inline `style` 硬渲染,不使用 CSS 变量(每个圆点必须显示自己对应的代表色,不能跟随当前 `--primary`
- 选中态用 `border-primary + ring-primary/30`,会跟随当前 `--primary` 动态变化——切到 Blue 后高亮就是蓝色,交互"会呼吸"
- 响应式:移动端 3 列 / 平板 4 列 / 桌面 6 列
- `aria-pressed` 保证无障碍
### 7.3 Zoom Level 下拉
```tsx
<Select
value={String(zoomLevel)}
onValueChange={(v) => setZoomLevel(parseInt(v, 10) as ZoomLevel)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("zoomLevel.placeholder")} />
</SelectTrigger>
<SelectContent>
{ZOOM_LEVELS.map((z) => (
<SelectItem key={z} value={String(z)}>
{z}%{z === 100 && ` (${t("zoomLevel.default")})`}
</SelectItem>
))}
</SelectContent>
</Select>
```
下方跟一行小字 `Current zoom: 100%`,格式与 "Current effective theme" 对齐。
### 7.4 Reset to Defaults 按钮
```tsx
const isDefault =
themeColor === DEFAULT_THEME_COLOR && zoomLevel === DEFAULT_ZOOM_LEVEL
const handleReset = () => {
setThemeColor(DEFAULT_THEME_COLOR)
setZoomLevel(DEFAULT_ZOOM_LEVEL)
}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
disabled={isDefault}
onClick={handleReset}
title={t("resetHint")}
>
<RotateCcw className="mr-2 size-4" />
{t("resetToDefaults")}
</Button>
</div>
```
要点:
- **只重置 Theme Color 和 Zoom**,不动 Theme Mode
- 当两者都是默认值时 disabled 灰显,按钮保持可见避免布局跳动
- 无确认对话框(行为完全可逆)
- `title` 属性承载 tooltip 文案,明确说明范围,避免用户担心 Theme Mode 被重置
### 7.5 组件骨架
```tsx
export function AppearanceSettings() {
const t = useTranslations("AppearanceSettings")
const { theme, setTheme, resolvedTheme } = useTheme()
const { themeColor, setThemeColor } = useThemeColor()
const { zoomLevel, setZoomLevel } = useZoomLevel()
const isDefault =
themeColor === DEFAULT_THEME_COLOR && zoomLevel === DEFAULT_ZOOM_LEVEL
const handleReset = () => {
setThemeColor(DEFAULT_THEME_COLOR)
setZoomLevel(DEFAULT_ZOOM_LEVEL)
}
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">{t("sectionTitle")}</h2>
<p className="text-sm text-muted-foreground">{t("sectionDescription")}</p>
</div>
<ThemeModeCard t={t} theme={theme} setTheme={setTheme} resolvedTheme={resolvedTheme} />
<ThemeColorCard t={t} value={themeColor} onChange={setThemeColor} />
<ZoomLevelCard t={t} value={zoomLevel} onChange={setZoomLevel} />
<div className="flex justify-end">
<Button variant="outline" size="sm" disabled={isDefault} onClick={handleReset} title={t("resetHint")}>
<RotateCcw className="mr-2 size-4" />
{t("resetToDefaults")}
</Button>
</div>
</div>
)
}
```
三个子组件(`ThemeModeCard``ThemeColorCard``ZoomLevelCard`)在同一个文件内定义,保持内聚;若将来某个膨胀到 >80 行再拆出。
## 8. 国际化i18n
扩展 `AppearanceSettings` 命名空间。以下为 `en.json` 最终形态,其他 9 个语言文件(`zh-CN` / `zh-TW` / `ja` / `ko` / `es` / `de` / `fr` / `pt` / `ar`)同步补齐。
```json
"AppearanceSettings": {
"sectionTitle": "Theme Appearance",
"sectionDescription": "Choose light, dark, or follow system. Settings are saved automatically.",
"themeMode": "Theme mode",
"placeholder": "Select theme mode",
"system": "Follow system",
"light": "Light",
"dark": "Dark",
"currentTheme": "Current effective theme: {theme}",
"resolvedTheme": {
"light": "Light",
"dark": "Dark",
"unknown": "--"
},
"themeColor": {
"sectionTitle": "Theme color",
"sectionDescription": "Pick a color palette for accents, buttons, and highlights.",
"current": "Current color: {color}",
"options": {
"zinc": "Zinc",
"slate": "Slate",
"stone": "Stone",
"gray": "Gray",
"neutral": "Neutral",
"red": "Red",
"rose": "Rose",
"orange": "Orange",
"green": "Green",
"blue": "Blue",
"yellow": "Yellow",
"violet": "Violet"
}
},
"zoomLevel": {
"sectionTitle": "Window zoom",
"sectionDescription": "Scale the entire interface. Applies immediately and persists per device.",
"placeholder": "Select zoom level",
"default": "Default",
"current": "Current zoom: {zoom}%"
},
"resetToDefaults": "Reset to defaults",
"resetHint": "Reset theme color and window zoom to defaults."
}
```
要点:
- 新增键全部收敛在 `themeColor.*` / `zoomLevel.*` / `resetToDefaults` / `resetHint`**不改动现有键**,降低多语言迁移成本
- 颜色名Zinc、Slate 等)走 i18n不同语种可决定是否翻译中文多数保留英文原名日文可用片假名
- `zoomLevel.sectionDescription` 明确 "persists per device",管理用户对跨设备同步的预期
## 9. 边界情况与兼容性
### 9.1 FOUC 与 hydration mismatch
- SSR 产出的 HTML 里 `<html>` 没有 `data-theme``style.fontSize`
- 浏览器在解析 `<head>` 时同步执行 inline 脚本,立即写入正确值
- React hydration 启动时看到"多出来"的属性,会触发 mismatch 警告
- 解决:`<html suppressHydrationWarning>`,与 `next-themes` 同一套做法
- Provider `useState` 初始值直接从 DOM 读,与 inline 脚本写入值天然一致
### 9.2 Next.js 静态导出
项目 `next.config.ts` 使用 `output: "export"``app/layout.tsx``<head>` 会被注入到**每一个**导出的 HTML 文件中,无需额外处理。
### 9.3 `localStorage` 不可用
隐私模式、嵌入 WebView、旧浏览器可能禁用
- **inline 脚本**`try/catch` 包裹,失败时走默认值
- **Provider setter**`localStorage.setItem``try/catch` 包裹,失败时 state 和 DOM 仍然更新,本次会话内生效
### 9.4 localStorage 值被篡改 / 旧版本残留
inline 脚本和 Provider 都做白名单校验:
- `THEME_COLORS.includes(storedColor)` / `ZOOM_LEVELS.includes(storedZoom)`
- 校验失败 → 回退到默认值
- **不主动清理 localStorage**,避免覆盖用户其他意图;下次 set 时自然被合法值覆盖
### 9.5 Monaco / 终端等硬编码 `fontSize` 组件
本次**不跟随** Zoom Level。理由
- 代码编辑器字号是排印学选择,与 UI 元素尺寸语义不同
- VSCode 本身也是 `Window: Zoom Level``Editor: Font Size` 两个独立配置
- Tauri WebView 的浏览器级缩放(`Ctrl +/-`)会整体缩放所有 px 值,用户仍有办法放大代码区
- 文案 "Scale the entire interface" 不承诺代码编辑器
未来如有反馈可单独加一个 "Editor Font Size" 设置项,或把 Monaco 的 `fontSize` 改为读取 CSS 变量接入同一缩放通道。
### 9.6 `next-themes` 与 `data-theme` 的潜在冲突
`next-themes` 配置 `attribute="class"`——只写 class不碰 `data-theme` 属性,两者正交。**不能将 `next-themes` 的 attribute 改为 `data-theme`**,会产生冲突。建议在 `appearance-provider.tsx` 顶部加一行注释作为防护提醒。
### 9.7 Docker / 服务器模式首次访问
- `localStorage` 是空 → inline 脚本走默认Zinc + 100%
- 和桌面模式首次启动行为完全一致
- 多用户共用服务器:每个浏览器的 `localStorage` 独立,互不干扰
### 9.8 SSR 中的 `document` 访问
`AppearanceProvider``"use client"`,其 `useState` 初始化回调在客户端才执行。作为保险仍然加 `if (typeof document === "undefined") return DEFAULT_...` 判断。
## 10. 测试计划
项目目前未配置测试框架(见 `CLAUDE.md`)。本次**不引入测试框架**,开发过程中手动覆盖以下场景:
| # | 场景 | 预期 |
| - | ---------------------------------------------- | ---------------------------------------------- |
| 1 | 首次打开(无 localStorage | Zinc 主题、100% 缩放、无闪烁 |
| 2 | 切主题色 → 刷新 | 保持所选色、无闪烁 |
| 3 | 切缩放 → 刷新 | 保持所选缩放、无闪烁 |
| 4 | 切 Dark → 切主题色 | 两者独立生效Dark 下主题色正确 |
| 5 | 切主题色 → 切 Dark | 先后顺序不影响结果 |
| 6 | 多标签页:一边改另一边观察 | 跨标签页实时同步 |
| 7 | 手动把 localStorage 值改成非法字符串 | 回退到默认值 |
| 8 | Tauri 桌面模式 | 行为正确 |
| 9 | Web 服务器模式 | 行为正确 |
| 10 | Reset 按钮:非默认值时点击 | Theme Color → Zinc、Zoom → 100%Theme Mode 不变 |
| 11 | Reset 按钮:默认值时 | disabled 灰显 |
| 12 | 缩放 150% 下访问各主要页面 | 布局不溢出、不错乱 |
## 11. 风险与缓解
| 风险 | 缓解 |
| ---------------------------------------------- | ----------------------------------------------------------------- |
| `globals.css` 体积膨胀到 ~900 行 | 纯数据无逻辑,维护心智零增加;如未来不适再拆 `theme-presets.css` |
| 某些页面布局在大缩放下溢出 | 手动测试 150% 档位,发现问题就地修 |
| Monaco 在主题色变更时同步延迟 | `useMonacoThemeSync` 当前只监听 `.dark` class主题色变更通过 CSS 变量直接生效Monaco 的 `vs-dark` 主题不受影响;若未来发现 Monaco 自定义主题需要同步主题色,扩展 `useMonacoThemeSync` 监听 `data-theme` 属性变化 |
| shadcn 预设 OKLch 值抄漏某个变量 | 实现时对照官方 registry 逐项 checklist 核对globals.css 的 `@theme inline` 映射会天然暴露未定义变量 |
## 12. 实施顺序建议
1. `theme-presets.ts` + `appearance-script.ts`(基础常量和脚本)
2. `globals.css` 重组(先迁 Zinc 作为 `[data-theme="zinc"]`,确认视觉无差后再补齐其他 11 个)
3. `appearance-provider.tsx` + `use-appearance.ts`
4. `layout.tsx` 整合(注入脚本 + Provider 嵌套)
5. `appearance-settings.tsx` UI 改造
6. i18n 补齐 10 种语言
7. 手动测试 12 个场景
8. `pnpm eslint .` + `pnpm build` + `cargo check`(按 CLAUDE.md 的任务完成检查清单)