chore(docs): remove .docs directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-11 16:41:37 +08:00
parent c8dfd00317
commit b06e8d5f1b
2 changed files with 0 additions and 3349 deletions

View File

@@ -1,675 +0,0 @@
# 外观设置增强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 的任务完成检查清单)