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

30 KiB
Raw Blame History

外观设置增强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 ZoomTheme Color
  2. 参考 shadcn/ui 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 Select80% / 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.tsTHEME_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 themenext-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-themestyle.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/*.json10 个语言文件) 扩展 AppearanceSettings 命名空间

5. globals.css 组织方式

5.1 目标结构

/* 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 官方 registryhttps://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

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> 注入。脚本自包含、不依赖外部模块符号。

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

"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

"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 整合

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 按钮网格

<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 下拉

<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 按钮

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 组件骨架

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>
  )
}

三个子组件(ThemeModeCardThemeColorCardZoomLevelCard)在同一个文件内定义,保持内聚;若将来某个膨胀到 >80 行再拆出。

8. 国际化i18n

扩展 AppearanceSettings 命名空间。以下为 en.json 最终形态,其他 9 个语言文件(zh-CN / zh-TW / ja / ko / es / de / fr / pt / ar)同步补齐。

"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-themestyle.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 setterlocalStorage.setItemtry/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 LevelEditor: Font Size 两个独立配置
  • Tauri WebView 的浏览器级缩放(Ctrl +/-)会整体缩放所有 px 值,用户仍有办法放大代码区
  • 文案 "Scale the entire interface" 不承诺代码编辑器

未来如有反馈可单独加一个 "Editor Font Size" 设置项,或把 Monaco 的 fontSize 改为读取 CSS 变量接入同一缩放通道。

9.6 next-themesdata-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 的任务完成检查清单)