主题系统与前端配色开发手册
Poco 多主题架构、语义 Token 契约,以及新增主题、开发页面和排查配色问题的标准流程。
本文是 Poco 前端主题开发的主入口,服务三类工作:
- 接入一套新的主题方案。
- 开发新页面、新组件或新功能。
- 优化现有页面的配色,修复 Light/Dark 或主题之间的视觉兼容问题。
目标不是让所有页面“换成某一种颜色”,而是让页面只表达稳定的视觉语义,由主题决定最终色值。做到这一点后,新增主题主要修改主题定义和注册信息,不再逐页修补。
相关资料:
- 主题视觉验收与踩坑手册:真实问题、审美基线和验收清单。
docs/New_Steel_AI前端开发标准_React_Next_Tailwind_2026-07-23.md:New Steel 的原始设计输入。frontend/app/theme-presets.css:当前主题最终配色实现,色值冲突时以这里为准。frontend/tests/theme-presets.test.mjs:主题组件状态的机器契约,变量完整性冲突时以这里为准。
按任务快速进入
| 当前任务 | 先读 | 主要交付 |
|---|---|---|
| 新增主题 | “主题架构”“新增主题流程”“完成定义” | 完整 Light/Dark 映射、前后端注册、测试和页面验收 |
| 开发新页面 | “新页面与新功能流程”“组件用色规则” | 只消费语义类、复用共享组件、多主题可用 |
| 优化现有配色 | “配色问题诊断流程”与踩坑手册 | 修复正确层级,保护其他主题、模式和布局 |
不可破坏的规则
- 业务组件不得感知主题 ID。 不读取
data-theme-preset,不写new-steel:*,不根据classic或new_steel返回颜色。 - 主题方案和 Light/Dark 是两条独立轴。 切换主题不能修改用户亮暗偏好,切换亮暗模式不能选择品牌主题。
- 默认主题必须是安全基线。 新主题适配不能顺手改变 Classic 的配色、布局或交互。
- 配色修改不能改变布局。 不借主题适配修改间距、尺寸、圆角、排列、溢出或 DOM 结构。
- 独立视觉表面必须显式拥有背景。 页面画布、内容卡片、浮层和嵌套区域不能依赖“刚好接近”的底色。
- 状态必须完整。 Default、Hover、Active、Focus、Selected、Disabled、Loading 不能互相借色。
- 优先修复系统层。 依次检查 Token、共享组件、主题语义类,最后才考虑真正的业务例外。
- 颜色不能只看源码。 最终必须在真实页面、真实数据和真实交互状态下确认观感。
主题架构
两条主题轴
| 轴 | DOM 表达 | 负责内容 | 不负责内容 |
|---|---|---|---|
| 主题方案 | data-theme-preset="classic"、data-theme-preset="new_steel" | 色板、字体、表面、功能色、圆角和阴影 | 用户选择 Light 还是 Dark |
| 亮暗模式 | 根节点 .dark | 当前主题方案在亮暗环境中的变量值 | 选择具体主题方案 |
New Steel 的作用域示例:
:root[data-theme-preset="new_steel"] {
color-scheme: light;
}
:root[data-theme-preset="new_steel"].dark {
color-scheme: dark;
}只有主题定义文件可以使用主题 ID。业务组件只能使用 bg-card、text-muted-foreground、theme-card-hover 等稳定语义。
Token 的三层模型
| 层级 | New Steel 示例 | 使用者 |
|---|---|---|
| 主题私有色板 | --ns-primary、--ns-bg-surface | 只在 theme-presets.css 内维护 |
| 项目语义 Token | --primary、--card、--surface-card-hover | 全部主题共同履行的契约 |
| 组件语义类 | bg-primary、bg-card、theme-card-hover | 共享组件和业务页面 |
不要让业务组件直接消费 --ns-*。下一套主题可以使用完全不同的私有变量前缀,但必须映射到同一组项目语义 Token。
关键源码和职责
| 文件 | 职责 | 新增普通主题时 |
|---|---|---|
frontend/app/theme-presets.css | 各主题的 Light/Dark 色板和语义映射 | 必须新增完整作用域 |
frontend/app/globals.css | Classic 默认值、Tailwind 映射和 theme-* 类 | 现有契约足够时不修改 |
frontend/lib/theme-presets.ts | 主题 ID、默认值、规范化、即时应用和浏览器缓存 | 必须扩展 |
frontend/app/layout.tsx | 服务端把公共配置写入根节点 | 通常不修改 |
frontend/components/shared/theme-provider.tsx | 用户 Light/Dark 偏好 | 不修改 |
frontend/components/docs/docs-theme-preset-sync.tsx | 静态文档加载后校准运行时主题并同步标签页 | 通常不修改 |
frontend/features/admin/components/admin-system-configs-page.tsx | 管理员保存后即时应用主题 | 选项仍由配置驱动时不修改 |
backend/app/schemas/public_config.py | 公开配置主题类型 | 必须扩展 |
backend/app/services/system_config_service.py | 主题白名单、默认值和读取 | 必须扩展 |
frontend/lib/i18n/locales/{zh,en}/translation.json | 后台显示名称 | 必须扩展 |
frontend/tests/theme-presets.test.mjs | 回退、作用域和组件状态契约 | 必须扩展 |
项目语义契约
基础语义 Token
基础 Token 对应 shadcn/ui 和 Tailwind 的常用语义,新增主题至少逐组核对:
| 分组 | 重点 Token |
|---|---|
| 页面与表面 | --background、--foreground、--card、--card-foreground、--popover、--popover-foreground |
| 次级与中性交互 | --secondary、--secondary-foreground、--muted、--muted-foreground、--accent、--accent-foreground |
| Primary | --primary、--primary-foreground、--primary-hover、--primary-active、--primary-subtle、--interactive-foreground、--ring |
| Disabled 与 Loading | --disabled-foreground、--skeleton |
| 功能色 | --destructive*、--success*、--warning*、--info*、--status-*-foreground、--status-solid-foreground |
| 结构 | --border、--input、--chart-1 至 --chart-5 |
| Sidebar | --sidebar*、--sidebar-nav-pressed-foreground |
| 形状与层级 | --radius、--font-sans、--shadow-* |
frontend/app/globals.css 中的 @theme inline 把这些变量映射成 Tailwind 语义类。只有新增项目级语义角色时才修改该映射。
组件状态 Token
基础语义不足以表达“这是页面画布、卡片头还是大面积 Hover”。项目因此维护组件状态 Token:
| 角色 | Token / 类 | 典型用途 |
|---|---|---|
| 公共表头 | --surface-header / theme-surface-header | 页面头、卡片头、面板标题区 |
| 内容表面 | --surface-content / theme-surface-content | 历史消息区、表格正文、带边框列表 |
| 文本产物预览 | --surface-artifact-text-preview / theme-artifact-text-preview | Markdown 等文本产物的阅读表面 |
| 弱卡片 | --surface-muted-card / theme-muted-card | Classic 的弱背景卡片 |
| 嵌套表面 | --surface-inset / theme-surface-inset | 一级卡片内部统计块和信息块 |
| 表单弹窗 | --surface-form-* / theme-form-* | 弹窗 Body 与一级表单分组 |
| 工具执行 | --surface-tool-* / theme-tool-* | 工具步骤、输入和输出载荷 |
| 分享页 | --surface-share-*、--share-* / theme-share-* | 公开分享页表面、身份和状态强调 |
| 表格头尾 | --surface-*-table-* / theme-*-table-* | shadcn Table 和原生 table |
| 中小面积 Hover | --surface-hover-* / theme-hover-* | 导航、列表行、选项 |
| 大卡片 Hover | --surface-card-hover* / theme-card-hover* | 业务卡片和抬升卡片 |
| 特定能力卡片 Hover | --surface-capability-card-hover / theme-capability-card-hover | 首页“更多能力”卡片 |
| Selected | --surface-selected* / theme-*-selected | 选中行、可选择卡片 |
| Tabs | --tab-*、--compact-tab-* / theme-tab-* | 默认、紧凑和下划线式 Tabs |
| 特殊状态 | --ai-note-*、--sidebar-nav-pressed-foreground | AI 听记图形、导航按下瞬间 |
| 输入文字选择 | --text-selection-background、--text-selection-foreground | Input、Textarea 的文字全选状态 |
完整、可执行的变量清单位于 componentStateTokens:
frontend/tests/theme-presets.test.mjs新增主题必须让该测试通过。文档负责解释语义,测试负责防止遗漏。
静态文档的运行时主题
在线手册继续使用静态生成,避免为了读取全局主题而把全部 MDX 页面改为按请求渲染。主题选择通过以下方式保持动态:
- 管理员保存主题后,
applyThemePreset()同时更新根节点和浏览器缓存。 - 文档布局在内容展示前优先应用浏览器缓存,避免新标签页回退到构建期默认主题。
- 文档客户端异步读取公开配置进行校准,并监听
storage事件同步同源标签页。
同步逻辑只能更新 html[data-theme-preset],不得复制主题色值或在文档组件中判断主题 ID。公开配置请求必须保持非阻塞,不能牺牲静态文档的首屏和缓存能力。
新增组件语义角色
只在现有 Token 无法准确表达、并且角色具有复用价值时新增:
- 在
:root提供与 Classic 当前视觉等价的默认值。 - 在所有主题作用域提供映射;Light/Dark 不同则分别覆写。
- 在
globals.css增加与主题名称无关的theme-*类。 - 让业务组件消费新语义类,不读取主题 ID。
- 把 Token 加入
componentStateTokens,补充必要的使用断言。 - 同步本文和踩坑手册。
如果一个“新 Token”只服务一个随意色值,而不是稳定视觉角色,应停止并重新判断问题层级。
组件用色规则
先判断表面归属
| 问题 | 选择 |
|---|---|
| 页面最底层承载区域? | bg-background |
| 独立内容块、卡片、固定页头? | bg-card text-card-foreground |
| Dialog、Popover、Sheet、菜单? | bg-popover text-popover-foreground |
| 一级表面内部的弱分组? | bg-secondary、bg-muted 或已有 theme-* 嵌套表面 |
| 已有专用业务角色? | 使用对应 theme-*,不要退回通用灰色 |
background 是页面画布,不是“所有容器的白色”。一个区域拥有边框、圆角、独立滚动、固定头部或浮层关系时,通常也应该拥有明确的 Surface。
透明背景只适合已知父级表面内的无边界内容。不要依靠 bg-muted/20、bg-background/50 之类的低透明度色独立表达卡片边界;底层主题变化后,它们可能消失或融入画布。
文字与实底前景
| 语义类 | 用途 |
|---|---|
text-foreground | 标题、正文和关键数据 |
text-secondary-foreground | 可连续阅读的说明、描述 |
text-muted-foreground | 时间、ID、元信息、占位提示 |
text-disabled-foreground | 真正禁用的控件 |
text-interactive | 链接、选中态文字、可点击图标 |
text-status-* | Success、Warning、Danger、Info 状态 |
text-primary-foreground | Primary 实底上的文字和图标 |
text-destructive-foreground | Danger 实底上的文字和图标 |
text-status-solid | 其他功能色实底上的文字和图标 |
Secondary、Muted、Disabled 不能因为看起来都是灰色就合并。红色或蓝色实底也不能继续使用普通 text-foreground。
Default、Hover、Selected 必须分开
- Primary Button:Default、Hover、Active 分别使用对应 Token。
- 导航和列表行:允许使用
theme-hover-*。 - 大面积业务卡片:优先保持 Surface,通过边框、轻阴影或抬升表达 Hover。
- Selected:使用稳定的 Selected Token,不能只在当前底色上叠一层极淡透明色。
- 默认静态卡片:使用中性边框;主题色边框只用于 Hover、Selected、Focus 或明确状态。
Hover 不能让卡片变得接近页面画布,也不能看起来和 Selected 一样。Hover/Active 只改视觉状态,不能改变组件尺寸和布局。
Tabs 是一组状态,不是一种颜色
Tabs 至少分别定义:
- List 容器背景。
- Inactive 背景与前景。
- Hover 背景与前景。
- Active 背景、前景、边框和阴影。
- Line 变体的指示线和数量 Badge。
Trigger 的直属图标应使用 currentColor。Badge 等嵌套组件需要通过 group-data-[state=active] 读取 Trigger 状态,不能把 data-[state=active] 写在没有该属性的子元素上。
不要全局覆盖 Tab 内所有 SVG;部分 SVG 可能表达 Success、Warning 等独立状态。
Dialog、表单和输入框
- 新增/编辑/配置表单优先使用共享
FixedFormDialog。 - Dialog 外壳使用 Popover,Body 和表单分组使用对应
theme-form-*。 - 普通 Input/Textarea 保留自己的语义表面。
- Input/Textarea 的文字选择状态统一使用
--text-selection-*,不能借用过浅的 Hover/Accent 背景。 - Composer 或 InputGroup 已经承担边框和背景时,内层输入才使用
bg-transparent dark:bg-transparent。 - 不要全局移除输入背景,也不要只写 Light 下生效的
bg-transparent。
共享组件优先
优先使用:
Button变体处理主操作、次级操作和危险操作。Badge变体处理状态。Tabs变体处理块状、紧凑和下划线导航。Table处理表头、Hover 和 Selected。FixedFormDialog处理表单浮层结构。
如果多个页面出现同类问题,先检查共享组件;如果只有某个主题出现,先检查主题映射;如果只有一种业务角色出现,再考虑项目语义类。
新增主题流程
1. 先完成设计输入
在写代码前分别确认 Light 和 Dark:
- Page、Surface、Subtle、Hover、Selected、Disabled。
- Primary、Secondary、Muted、Disabled 文字。
- 默认和强边框。
- Primary Default、Hover、Active、Subtle、Focus、Solid Foreground。
- Success、Warning、Danger、Info 的前景、弱背景和实底前景。
- Sidebar、Skeleton、Chart、Shadow、Radius 和字体。
只有品牌主色不能支撑一套主题。缺少状态色和前景色时,开发阶段必然产生未经确认的临时混色。
2. 注册稳定主题 ID
主题 ID 使用小写 ASCII snake_case,用户可见名称只写在 i18n。至少同步:
frontend/lib/theme-presets.tsbackend/app/schemas/public_config.pybackend/app/services/system_config_service.pyfrontend/lib/i18n/locales/zh/translation.jsonfrontend/lib/i18n/locales/en/translation.jsonfrontend/content/docs/getting-started/admin.mdx
新增主题不等于修改默认主题。没有单独产品决策时,未知值继续回退到 classic。
3. 定义完整作用域
:root[data-theme-preset="theme_id"] {
color-scheme: light;
/* 主题私有色板 */
--preset-bg-page: /* approved value */;
--preset-bg-surface: /* approved value */;
--preset-primary: /* approved value */;
/* 基础语义映射 */
--background: var(--preset-bg-page);
--card: var(--preset-bg-surface);
--primary: var(--preset-primary);
/* 完整映射 tests/theme-presets.test.mjs 中的组件状态 Token */
}
:root[data-theme-preset="theme_id"].dark {
color-scheme: dark;
/* 覆写 Dark 中不同的色板和语义值 */
}不要复制某个页面的 className 作为主题实现,也不要在共享组件中加入新主题名称。
4. 先验证契约,再看页面
至少补充:
- 新主题 ID 能被前后端接受。
- 未知值仍回退到 Classic。
- Light/Dark 作用域同时存在。
- Primary、Muted、Disabled、Status 和实底前景映射正确。
componentStateTokens全部存在。- 共享组件中没有主题 ID。
- 后台保存成功后才即时应用主题。
5. 灰度启用
由管理员在非生产环境选择新主题。严重问题可立即切回 classic,无需重建前端。不要用紧急逐页改色代替主题回滚。
6. 完成真实页面验收
按主题视觉验收与踩坑手册执行:
- Light/Dark 分开检查。
- 用户侧、管理后台、公开页和动态详情页都覆盖。
- Default、Hover、Selected、Focus、Disabled、Loading、Empty、Error 都有记录。
- 桌面、中间宽度和窄屏都检查。
新页面与新功能流程
- 画出页面画布、一级 Surface、嵌套 Surface 和浮层关系。
- 列出组件需要的 Default、Hover、Selected、Focus、Disabled 和状态色。
- 优先选择现有 shadcn/ui、共享组件和
theme-*语义类。 - 只有现有契约无法表达稳定角色时才新增 Token。
- 在所有已支持主题中检查页面;新页面默认同时考虑 Light/Dark。
- 确认主题切换没有改变布局、滚动、响应式或交互行为。
- 执行 Lint、主题契约测试和真实页面检查。
维护任务如果被明确限定为某个主题或模式,只修改该范围;仍需通过默认映射和静态检查确认其他范围没有被意外改变。
配色问题诊断流程
实际排查顺序:
- 记录当前
data-theme-preset、.dark、路由和状态。 - 查看元素最终计算出的背景、前景、边框和阴影,不只看 className。
- 对照相同组件在另一个主题、模式或参考页面中的表现。
- 判断问题属于色板、基础语义、共享组件、项目语义还是业务例外。
- 修改最上游且最准确的层级。
- 复查目标页面、参考页面、Classic 和未修改模式。
修复时遵循:
先确认语义角色 → 再确认主题与模式 → 再选择 Token → 最后检查真实观感不要从“这里需要一种蓝色”开始选色。
静态检查与验证
前端:
cd frontend
source ~/.nvm/nvm.sh
nvm use 22
pnpm lint
node --test tests/theme-presets.test.mjs后端主题配置:
cd backend
uv run pytest tests/test_system_config_service.py tests/test_public_config_service.py从仓库根目录检查可疑写法:
rg -n '#[0-9A-Fa-f]{3,8}' frontend --glob '*.{ts,tsx}'
rg -n '(text|bg|border)-(red|blue|green|yellow|orange|purple)-' frontend --glob '*.{ts,tsx}'
rg -n 'data-theme-preset|new_steel|classic' frontend/features frontend/components --glob '*.tsx'搜索结果需要逐条判断。图表数据、第三方控件和明确的品牌资产可能是合法例外;不要为了让结果归零而机械替换。
当前路由从源码动态盘点,不在文档中维护容易过时的固定清单:
rg --files frontend/app | rg '/page\.tsx$'New Steel 最终基线
原始设计标准是设计输入;经过真实页面调优后,最终实现以 frontend/app/theme-presets.css 为准。
Light
| 角色 | 最终值 |
|---|---|
| Page / Surface / Subtle | #F8FBFF / #FFFFFF / #F5F5F5 |
| Hover / Selected | #EBF1FF / #EBF1FF |
| Primary / Hover / Active / Subtle | #3370FF / #5C8DFF / #295ACC / #EBF1FF |
| Text Primary / Secondary / Muted / Disabled | #333333 / #666666 / #999999 / #CCCCCC |
| Border / Strong Border | #E6E6E6 / #CCCCCC |
| Success / Warning / Danger | #3DD598 / #FF995E / #FC5A5A |
| Primary、Danger、Status 实底前景 | #FFFFFF |
Dark
| 角色 | 最终值 |
|---|---|
| Page / Surface / Subtle | #091022 / #1F2935 / #192340 |
| Hover / Selected | #1F2A4D / #293867 |
| Primary / Hover / Active / Subtle | #5C8DFF / #85A9FF / #3370FF / #1F2A4D |
| Text Primary / Secondary / Muted / Disabled | #FFFFFF / #D6DAE6 / #ADB5CD / #5C6B9A |
| Border / Strong Border | #334681 / #5C6B9A |
| Primary / Status 实底前景 | #141C34 |
| Danger 实底前景 | #FFFFFF |
功能色默认使用“弱背景 + 功能色前景”。实底只用于少量强状态,并必须使用对应实底前景。
完成定义
新主题完成
- 前后端 ID、白名单、回退、i18n 和管理员配置已同步。
- Light/Dark 基础语义和组件状态 Token 完整。
- 共享组件不感知主题 ID。
- Primary、Danger、Status、文字层级、Hover、Selected 和 Disabled 符合设计。
- 主题测试、后端配置测试、Lint 和差异检查通过。
- 真实页面、动态详情、浮层、桌面和窄屏完成验收。
- 在线文档和已知限制已同步。
页面或配色优化完成
- 问题修复在正确语义层级,没有逐页复制补丁。
- 目标主题和模式观感自然,状态之间可辨认。
- Classic、其他主题和未修改模式没有回归。
- 没有借配色修改布局。
- 组件状态、响应式、键盘焦点和可访问名称仍然成立。
- 已留下可复用的 Token、共享组件规则或经验记录。