# 外观设置增强：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/ui（OKLch 色彩）做基础主题，但用户无法调整：

- **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 的任务完成检查清单）
