新增设计文档:外观设置页面增加 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>
30 KiB
外观设置增强: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 目标
- 在
设置 / 外观页面新增两个偏好项:Window Zoom 和 Theme Color - 参考 shadcn/ui Theming 文档 的做法,提供 12 个官方主题预设,切换后 primary / accent / ring / border 等所有 shadcn CSS 变量联动更新
- 保持与现有
next-themes的 light/dark 切换完全正交——用户可以在Blue主题下自由切换浅色/深色 - 首次加载无闪烁(FOUC),刷新后保持用户选择
- 提供"恢复默认"按钮
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-sizeinline 样式 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 目标结构
/* 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
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>
)
}
三个子组件(ThemeModeCard、ThemeColorCard、ZoomLevelCard)在同一个文件内定义,保持内聚;若将来某个膨胀到 >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-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. 实施顺序建议
theme-presets.ts+appearance-script.ts(基础常量和脚本)globals.css重组(先迁 Zinc 作为[data-theme="zinc"],确认视觉无差后再补齐其他 11 个)appearance-provider.tsx+use-appearance.tslayout.tsx整合(注入脚本 + Provider 嵌套)appearance-settings.tsxUI 改造- i18n 补齐 10 种语言
- 手动测试 12 个场景
pnpm eslint .+pnpm build+cargo check(按 CLAUDE.md 的任务完成检查清单)