主题系统设计方案:令牌结构与页面配置
第10章的组件零散用了几个
--ai-chat-*变量,tokens.css也已经沉淀了一套暗色为主的配色。但这套东西目前是"能跑"而不是"成体系"——改一个品牌色要动十几处,主题切换逻辑写死在 playground 页面里,组件库本身没有任何主题配置入口。这一节不重复讲 CSS Variables 怎么用,而是结合现有
tokens.css和PlaygroundDemo.vue的真实代码,先审视现状的问题,再给出一套可行的主题系统设计方案,作为后续改造 UI 的蓝图。核心要回答一个问题:主题到底该怎么在页面上设置与配置?落地状态:这份蓝图后来整份交给了 AI 实施(过程见第九节),现已在 ai-chat-ui 仓库完整落地——
tokens.css重构为三层令牌、useTheme/useThemePresetcomposable、Conversation的三个主题 Props、文档站的主题配置器(Theme Builder)全部就位。阅读时前七节是"怎么想方案",第八、九节是"怎么让 AI 把方案变成代码"。
一、现状审视:现有 tokens.css 的六个问题
先看真实代码。packages/vue/src/styles/tokens.css 现在长这样(节选):
/* 暗色(默认) */
:root {
--ai-chat-color-accent: #6366f1;
--ai-chat-color-accent-hover: #818cf8;
--ai-chat-sidebar-bg: #171717;
--ai-chat-color-bg-primary: #212121;
--ai-chat-color-bg-secondary: #1a1a1a;
--ai-chat-msg-user-bg: #2f2f2f;
--ai-chat-input-bg: #2f2f2f;
--ai-chat-input-border: #404040;
--ai-chat-color-text-primary: #fafafa;
--ai-chat-color-text-secondary: #a1a1aa;
--ai-chat-color-border: #2e2e2e;
--ai-chat-radius-md: 8px;
/* ... */
/* 旧组件兼容(逐步废弃) */
--ai-chat-border-color: var(--ai-chat-color-border);
--ai-chat-primary: var(--ai-chat-color-accent);
--ai-chat-user-bg: var(--ai-chat-msg-user-bg);
--ai-chat-assistant-bg: var(--ai-chat-color-bg-secondary);
/* ... */
}
/* 亮色主题 */
[data-theme='light'] {
--ai-chat-sidebar-bg: #f9fafb;
--ai-chat-color-bg-primary: #ffffff;
--ai-chat-color-text-primary: #111827;
/* ... */
}
css
而 PlaygroundDemo.vue 里的主题切换是这么写的(节选):
const theme = ref<'system' | 'light' | 'dark'>('system')
const systemIsDark = ref(false) // 初值 false:首屏按亮色渲染,避免暗色闪现
const resolvedTheme = computed(() =>
theme.value === 'system' ? (systemIsDark.value ? 'dark' : 'light') : theme.value,
)
onMounted(() => {
const mql = window.matchMedia('(prefers-color-scheme: dark)')
systemIsDark.value = mql.matches
mql.addEventListener('change', onSystemChange)
})
function toggleTheme() {
theme.value = resolvedTheme.value === 'dark' ? 'light' : 'dark'
}
ts
模板上:
<div class="pg-app" :data-theme="resolvedTheme === 'light' ? 'light' : undefined">
html
对照这份真实代码,能挑出六个问题:
- 扁平结构,没有分层。所有变量都堆在
:root里,没有"原始色阶 → 语义 → 组件"的层次。想把主色#6366f1换成紫色,得在:root和[data-theme='light']里逐个找accent相关变量改,没有"改一处颜色、全局联动"的能力。 - 默认态是暗色,亮色靠覆盖。
:root直接写死暗色值,亮色要靠[data-theme='light']覆盖。模板里:data-theme="... === 'light' ? 'light' : undefined"—— 只在亮色时标属性,暗色"不标",靠:root隐式生效。这套"无属性=暗色"的约定不直观,也导致 system 模式首屏容易暗色闪现(代码里用systemIsDark初值 false 专门规避,治标不治本)。 - 命名分类混杂。同样是背景,有
--ai-chat-color-bg-primary(color-bg-xx)、--ai-chat-sidebar-bg(直接区域-bg)、--ai-chat-msg-user-bg(组件-bg)、--ai-chat-input-bg(组件-bg)四种写法,分不清谁是语义层、谁是组件层。缺统一的{category}-{property}格式。 - 主题逻辑写死在页面里,没抽成 composable。
theme、matchMedia、toggleTheme全部内联在PlaygroundDemo.vue。组件库的其它消费者想用同样的主题切换,得把这段逻辑抄一遍。主题能力没有沉淀进组件库。 - 没有主题预设能力。想从蓝色系换紫色系、绿色系,只能手动改一堆变量值,组件库不提供"一套预设"的概念。
- 残留"旧组件兼容(逐步废弃)"区。说明令牌命名经历过一次迁移但没收尾,新旧两套变量并存,维护时容易改漏。
这六个问题概括成一句话:现在的 tokens.css 是一份"配色表",不是一个"主题系统"。
二、方案目标与设计原则
这一节的目标是把它从"配色表"升级成"主题系统",让"改主题"从改十几个变量变成改一处。五条设计原则:
- 三层令牌,职责单一。原始层管色阶原材料,语义层管含义(在暗色模式翻转),组件层管使用。每层只关心自己的事。
- 默认态规范化。
:root给一套完整默认,light/dark 都用[data-theme]显式标注,消除"无属性=暗色"的隐式约定。 - 命名统一。所有变量遵循
--ai-chat-{category}-{property}[-{variant}],一眼看出属于哪一层。 - 逻辑下沉 composable。主题切换逻辑抽成
useTheme,沉淀进组件库,所有消费者复用同一份状态。 - 配置分三档。粗粒度改语义令牌、中粒度用预设、细粒度传自定义变量,满足从"换主色"到"完全换肤"的不同需求。
一个重要前提:这是增量改造,不是推倒重来。现有 tokens.css 的语义层其实做得不错(--ai-chat-color-bg-primary、--ai-chat-color-text-primary 这些已经是语义令牌了),缺的是原始色阶层和命名收敛。方案围绕"补全 + 收敛"展开。
三、三层令牌结构方案
第一层:原始令牌(Primitive)——色阶原材料
这一层只定义色阶和基础数值,不直接用在组件里。现在 tokens.css 缺的就是这一层。
:root {
/* 主色色阶(accent 派生) */
--ai-chat-color-accent-500: #6366f1;
--ai-chat-color-accent-600: #4f46e5;
--ai-chat-color-accent-hover: #818cf8;
/* 中性灰阶(从亮到暗) */
--ai-chat-neutral-0: #ffffff;
--ai-chat-neutral-50: #f9fafb;
--ai-chat-neutral-100: #f3f4f6;
--ai-chat-neutral-200: #e5e7eb;
--ai-chat-neutral-700: #404040;
--ai-chat-neutral-800: #262626;
--ai-chat-neutral-900: #171717;
--ai-chat-neutral-950: #0a0a0a;
/* 危险色 */
--ai-chat-color-danger-500: #ef4444;
/* 圆角阶梯 */
--ai-chat-radius-sm: 6px;
--ai-chat-radius-md: 8px;
--ai-chat-radius-lg: 12px;
--ai-chat-radius-xl: 16px;
}
css
有了色阶,换品牌色只需要改这一层的几个 accent-500/600,整个体系自动联动。这是主题预设的基础。
第二层:语义令牌(Semantic)——含义,暗色在这里翻转
这一层把原材料"翻译"成含义:背景是什么、文本是什么、边框是什么。暗色模式只重定义这一层。
:root {
/* 语义层引用原始层 */
--ai-chat-accent: var(--ai-chat-color-accent-500);
--ai-chat-accent-hover: var(--ai-chat-color-accent-hover);
--ai-chat-bg-primary: var(--ai-chat-neutral-0);
--ai-chat-bg-secondary: var(--ai-chat-neutral-50);
--ai-chat-bg-sidebar: var(--ai-chat-neutral-900);
--ai-chat-text-primary: var(--ai-chat-neutral-900);
--ai-chat-text-secondary: #6b7280;
--ai-chat-text-muted: #9ca3af;
--ai-chat-border: var(--ai-chat-neutral-200);
--ai-chat-input-bg: var(--ai-chat-neutral-100);
--ai-chat-input-border: var(--ai-chat-neutral-200);
}
/* 暗色:只翻转语义层 */
[data-theme='dark'] {
--ai-chat-bg-primary: var(--ai-chat-neutral-900);
--ai-chat-bg-secondary: var(--ai-chat-neutral-800);
--ai-chat-bg-sidebar: var(--ai-chat-neutral-950);
--ai-chat-text-primary: var(--ai-chat-neutral-50);
--ai-chat-text-secondary: #a1a1aa;
--ai-chat-text-muted: #71717a;
--ai-chat-border: #2e2e2e;
--ai-chat-input-bg: #2f2f2f;
--ai-chat-input-border: var(--ai-chat-neutral-700);
}
css
注意对比现有代码:现在 :root 直接写 --ai-chat-color-bg-primary: #212121(暗色值),语义层和原始层混在一起。方案是把"原材料 neutral-900"和"含义 bg-primary"拆开,暗色翻转就变得很干净。
第三层:组件令牌(Component)——使用,组件内引用
组件只引用语义层,不知道具体颜色:
.ai-chat-bubble--user .ai-chat-bubble__content {
background: var(--ai-chat-msg-user-bg);
border-radius: var(--ai-chat-bubble-radius);
}
css
组件令牌在语义层之上做一次"组件语义化"映射:
:root {
--ai-chat-msg-user-bg: var(--ai-chat-bg-secondary);
--ai-chat-bubble-radius: var(--ai-chat-radius-lg);
--ai-chat-code-bg: var(--ai-chat-bg-secondary);
/* ... */
}
css
三层的好处:换品牌色改第一层、切暗色改第二层、调单个组件改第三层,互不干扰。现有代码在第二层(语义)已经到位,方案的核心工作量是补第一层 + 把 :root 的暗色值挪进 [data-theme='dark']、亮色值进 :root。
四、命名规范方案
统一格式:--ai-chat-{category}-{property}[-{variant}]
| 层 | category | 示例 |
|---|---|---|
| 原始层 | color-{name}-{step} / neutral-{step} / radius-{size} | --ai-chat-color-accent-500 |
| 语义层 | {区域/含义} | --ai-chat-bg-primary、--ai-chat-text-secondary、--ai-chat-border |
| 组件层 | {component}-{property} | --ai-chat-msg-user-bg、--ai-chat-bubble-radius |
现有 → 目标的重点 rename(增量迁移):
| 现有命名 | 目标命名 | 原因 |
|---|---|---|
--ai-chat-sidebar-bg | --ai-chat-bg-sidebar | 统一进 bg-* 语义族 |
--ai-chat-color-bg-primary | --ai-chat-bg-primary | 去掉冗余 color-(语义层不暴露原始层前缀) |
--ai-chat-color-text-primary | --ai-chat-text-primary | 同上 |
--ai-chat-color-border | --ai-chat-border | 同上 |
--ai-chat-color-accent | --ai-chat-accent | 语义层统一无 color- |
旧兼容区 --ai-chat-primary 等 | 删除 | 迁移收尾,不留两套 |
迁移策略:先用新名新增、旧名保留指向新名(即现在"旧组件兼容"区的做法,但反过来用),组件逐步切到新名,最后删旧名。
落地时的务实收敛:实施阶段对命名做了一次决策——放弃去掉
color-前缀的重命名,承认--ai-chat-color-*就是既定命名。理由很直接:color-bg-primary/color-text-primary这类带类别前缀的写法本就是对标大组件库的事实命名规则(如 Element Plus 的--el-color-primary、--el-text-color-primary一族),强行改名是为一层"洁癖"付出全库替换的代价,收益接近零。最终落地只做了三件事:删掉旧兼容区、把sidebar-bg收敛进color-bg-sidebar族、统一三层语义(原始层accent-500/400、语义层color-accent、组件层bubble-radius)。教训:命名方案在蓝图阶段可以激进,实施阶段按"业界事实标准 + 迁移成本"收敛。
五、暗色模式方案
结构规范化
现有"亮色才标 data-theme、暗色靠 :root 隐式"的写法不直观。方案改为总是显式标注:
// 模板上始终输出解析后的具体值
<div :data-theme="resolvedTheme"> <!-- 永远是 'light' 或 'dark' -->
ts
:root {
/* 一套完整默认(建议亮色,符合"内容优先"的无障碍惯例) */
--ai-chat-bg-primary: var(--ai-chat-neutral-0);
/* ... */
}
[data-theme='dark'] {
--ai-chat-bg-primary: var(--ai-chat-neutral-900);
/* ... */
}
css
关于默认态选亮还是暗:现有代码暗色优先,是对标 ChatGPT/Claude 这类 AI 产品的调性,有合理性。方案不强制翻转产品调性,但建议无论默认是哪边,都让 :root 持有一套完整默认值、[data-theme] 双态显式定义,避免"无属性=某种主题"的隐式约定。这样 system 模式的首屏闪烁问题也能从根上缓解。
落地决策:默认亮色。实施时 AI 把这个问题抛回来确认,最终选择
:root= 亮色、[data-theme='dark']翻转语义层。理由:暗色本质是"系统处于暗色时才切入"的被动状态,把亮色作为无属性默认更符合prefers-color-scheme的语义,也消除了旧代码"不标属性 = 暗色"的隐式约定。落地后的tokens.css文件头注释即写明:"默认 = 亮色(:root);暗色通过[data-theme='dark']仅翻转语义层"。
useTheme composable
把 PlaygroundDemo.vue 里那段内联逻辑抽出来,补上它缺的持久化(现有代码没有 localStorage,刷新就回到 system):
// packages/vue/src/composables/useTheme.ts
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
type Theme = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'ai-chat-theme'
// 模块级单例:同一页面多个聊天实例共享一份主题状态
const theme = ref<Theme>(
(localStorage.getItem(STORAGE_KEY) as Theme) || 'system'
)
const systemDark = ref(false)
function resolve(t: Theme): 'light' | 'dark' {
return t === 'system' ? (systemDark.value ? 'dark' : 'light') : t
}
const resolvedTheme = computed(() => resolve(theme.value))
let mql: MediaQueryList | null = null
function onSystemChange(e: MediaQueryListEvent) {
systemDark.value = e.matches
}
export function useTheme() {
onMounted(() => {
if (!mql) {
mql = window.matchMedia('(prefers-color-scheme: dark)')
systemDark.value = mql.matches
mql.addEventListener('change', onSystemChange)
}
})
onUnmounted(() => {
// 单例:不在这里 removeEventListener,由最后实例负责
})
// 持久化 + 应用 data-theme(现有代码缺这两步)
watch(resolvedTheme, (val) => {
localStorage.setItem(STORAGE_KEY, theme.value)
document.documentElement.setAttribute('data-theme', val)
}, { immediate: true })
const setTheme = (t: Theme) => { theme.value = t }
const toggleTheme = () => {
theme.value = resolvedTheme.value === 'dark' ? 'light' : 'dark'
}
return { theme, resolvedTheme, setTheme, toggleTheme }
}
ts
关键设计:
- 模块级单例。
theme、systemDark定义在函数外,同一页面多个聊天实例共享同一份主题,不会各切各的。 - 补持久化。
watch里写localStorage+ 设data-theme,解决现有代码"刷新丢失"的问题。 - 从 PlaygroundDemo 抽取。逻辑和现有页面几乎一一对应,不是凭空设计,迁移成本低。
边界情况
- Shiki 代码高亮:高亮主题独立于 CSS 变量,暗色下要切
'github-dark',亮色切'github-light',用resolvedTheme派生。 - 首屏闪烁:在
<head>里内联一段小脚本,同步读 localStorage 设置data-theme,避免 Vue 挂载前的闪烁。 - 过渡动画:给容器加
transition: background-color 0.3s, color 0.3s,切换更平滑。
六、主题预设方案
暗色是"黑白翻转",预设是"换品牌色"。预设只改原始层的 accent 色阶,语义层引用原始层,自动联动:
// packages/vue/src/themes/presets.ts
export interface ThemePreset {
name: string
vars: Record<string, string> // 只含原始层 accent 变量
}
export const presets: Record<string, ThemePreset> = {
default: {
name: '默认靛蓝',
vars: {
'--ai-chat-color-accent-500': '#6366f1',
'--ai-chat-color-accent-600': '#4f46e5',
'--ai-chat-color-accent-hover': '#818cf8',
},
},
purple: {
name: '优雅紫',
vars: {
'--ai-chat-color-accent-500': '#8b5cf6',
'--ai-chat-color-accent-600': '#7c3aed',
'--ai-chat-color-accent-hover': '#a78bfa',
},
},
green: {
name: '自然绿',
vars: {
'--ai-chat-color-accent-500': '#059669',
'--ai-chat-color-accent-600': '#047857',
'--ai-chat-color-accent-hover': '#10b981',
},
},
warm: {
name: '暖橙',
vars: {
'--ai-chat-color-accent-500': '#ea580c',
'--ai-chat-color-accent-600': '#c2410c',
'--ai-chat-color-accent-hover': '#f97316',
},
},
}
ts
用 inline style 应用到容器(CSS 变量值是动态的,inline style 优先级最高,能确保覆盖 :root):
// packages/vue/src/composables/useThemePreset.ts
export function useThemePreset(
containerRef: Ref<HTMLElement | undefined>,
preset: Ref<string | undefined>,
customVars: Ref<Record<string, string> | undefined>,
) {
function apply() {
const el = containerRef.value
if (!el) return
el.removeAttribute('style') // 清旧值
const p = presets[preset.value || 'default']
if (p) for (const [k, v] of Object.entries(p.vars)) el.style.setProperty(k, v)
if (customVars.value)
for (const [k, v] of Object.entries(customVars.value)) el.style.setProperty(k, v)
}
onMounted(apply)
watch([preset, customVars], apply, { deep: true })
}
ts
预设和暗色天然兼容:预设改第一层(原始),暗色改第二层(语义),两者在不同层,紫色 + 暗色 = 暗色背景 + 紫色强调色,自动生效。
七、如何在页面上设置与配置主题
这是这一节的核心。主题配置分三个层次,从"组件库封装好"到"宿主完全接管",粒度从粗到细。
层次 1:组件库 Props(声明式)
容器组件(如 Conversation)暴露三个 prop,使用者声明式配置:
<template>
<!-- 用预设 -->
<Conversation theme="purple" />
<!-- 自定义任意变量 -->
<Conversation :custom-theme="{ '--ai-chat-color-accent-500': '#e11d48' }" />
<!-- 预设 + 暗色 -->
<Conversation theme="green" dark-mode />
</template>
vue
// Conversation.vue
defineProps<{
theme?: 'default' | 'purple' | 'green' | 'warm' // 选预设
customTheme?: Record<string, string> // 传任意 CSS 变量
darkMode?: boolean | 'auto' // 暗色控制
}>()
ts
容器内部用 useThemePreset 把预设和自定义变量应用到根元素。适合"一次性配好、不太动"的场景。
层次 2:useTheme composable(运行时切换)
页面上要放一颗"切换主题"按钮(playground 右上角那颗太阳/月亮就是),用 useTheme 绑定:
<script setup lang="ts">
import { useTheme } from '@ai-chat/vue'
const { resolvedTheme, toggleTheme } = useTheme()
</script>
<template>
<button @click="toggleTheme">
{{ resolvedTheme === 'dark' ? '☀️' : '🌙' }}
</button>
</template>
vue
对照现有代码的改造:PlaygroundDemo.vue 现在那二十多行内联逻辑(theme / systemIsDark / matchMedia / toggleTheme),换成一句 const { resolvedTheme, toggleTheme } = useTheme() 即可,逻辑下沉进组件库,页面变干净,且额外获得 localStorage 持久化。
适合"用户随时切换"的场景。
层次 3:宿主覆盖 CSS 变量(零 JS)
完全不用组件库的 API,直接在自己页面的 CSS 里覆盖变量。组件全部引用语义层,自动生效:
/* 只改主色(粗粒度) */
:root {
--ai-chat-accent: #e11d48;
}
/* 完全换肤(细粒度) */
.my-brand .ai-chat {
--ai-chat-bg-primary: #1a1a2e;
--ai-chat-text-primary: #eaeaea;
--ai-chat-bubble-radius: 4px;
}
css
适合"宿主已有设计系统、只想微调"的场景,零 JS 接入。
配置优先级
三种方式可能同时存在,优先级从高到低:
inline style(customTheme / 预设应用)
↓ 覆盖
[data-theme='dark'] / [data-theme='light'](暗色翻转)
↓ 覆盖
:root 默认值
text
也就是:用户传的 customTheme 永远最优先,其次是暗色模式翻转,最后是默认值。这保证了"自定义不被暗色覆盖、暗色不被默认覆盖"。
三种方式总结
| 层次 | 谁来用 | 怎么用 | 粒度 | 典型场景 |
|---|---|---|---|---|
| Props | 组件库使用者 | <Conversation theme="purple" dark-mode /> | 中 | 接入时一次性配好 |
| useTheme | 页面开发者 | useTheme() + 切换按钮 | 运行时 | 用户随时切换主题 |
| CSS 变量覆盖 | 宿主样式 | :root { --ai-chat-accent: #e11d48; } | 任意 | 微调、对接现有设计系统 |
设计时这三种一种都没落地——组件库没暴露 Props,没有 composable,宿主想改只能直接覆盖变量。方案把这三种补齐,覆盖从"零接入"到"完全自定义"的全部需求。(落地核对:三种现已全部实现——Conversation 暴露 theme / customTheme / darkMode 三个 Props;useTheme 支持 target: 'html' | 'component' 与 persist 选项,比蓝图还多了"组件级暗色不碰宿主 <html>"的能力;CSS 覆盖方案写进了文档站"主题定制"页。)
八、改造路线图(已按此落地)
这一节最初只出方案、不动 ai-chat-ui 代码;随后把整份设计文档交给 AI 实施,路线图六步全部完成:
- tokens.css 补原始色阶层 + 收敛命名 + 删旧兼容区 ✅ —— 现文件为三层结构:原始层(accent 色阶、neutral 0–950 灰阶、圆角阶梯、字体、动画时长、尺寸)→ 语义层(
:root亮色 +[data-theme='dark']暗色双份)→ 组件层(bubble-radius等映射)。旧兼容区已删。命名按第四节的务实收敛执行。 - 规范化 data-theme 机制 ✅ ——
:root持完整亮色默认,暗色显式写在[data-theme='dark'],只翻转语义层、原始层不动。 - 抽 useTheme composable ✅ —— 模块级单例 + localStorage 持久化 +
target/persist选项(真实代码见packages/vue/src/composables/useTheme.ts,89 行)。 - 加 presets + useThemePreset ✅ —— 四套预设(default 靛蓝 / purple 优雅紫 / green 自然绿 / warm 暖橙),只含原始层 accent 三档变量;
useThemePreset以 inline style 应用,且清理时只移除预设/自定义相关的 key,不误伤宿主 inline style。 - Conversation/容器组件暴露 Props ✅ ——
theme?: PresetKey、customTheme?: Record<string, string>、darkMode?: boolean | 'auto'。 - 边界收尾 ✅ —— 全文件包进
@layer(见 11-03 样式隔离一节);Shiki 高亮跟随主题。
九、主题配置器:把蓝图交给 AI 落地
主题系统一方面给宿主开发者用(第七节的三档配置),另一方面也要给最终用户一个可视化的配置入口——不写代码也能调出自己喜欢的配色,并把成果导出成 CSS 带走。这就是主题配置器(Theme Builder)的由来。这一节的价值不在配置器本身,而在把一份设计文档交给 AI 落地的完整过程。
9.1 需求与启动方式:先 Plan 模式,再交给 subagent 团队
把课程资料里的设计方案文件直接丢给模型,同时追加一段主题配置器的需求:
基于这份主题系统设计方案,设计一个用户侧的主题配置 UI 页面:
可以对主题里的变量进行设置、实时预览,
并支持把配置导出为 CSS 文件,
以便后续复制到自己的项目中形成自定义主题配色。
text
建议先用 Plan 模式 让它跑一遍——读上下文、理解诉求,而不是上来就写代码。AI 很快抛回四个关键问题(这就是 Plan 模式的价值:它把"界面复杂度、产出物形态"这些你没想清楚的决策点提前摆上桌):
| AI 的问题 | 决策 | 理由 |
|---|---|---|
| 用户能编辑哪些变量?(精选 12–18 个语义令牌 vs 全部令牌) | 全部令牌 | 配置器本身就是给愿意深度定制的人用的,精选版反而要维护两套口径 |
| 导出什么格式? | CSS 覆盖文件 | 宿主 import 一行就生效,零 JS |
| 页面放在哪里? | docs 文档站新页面 | 主题配置器和文档站同一技术栈,天然集成 |
| 重构后 tokens.css 默认主题方向? | 亮色 | 见第五节的落地决策 |
9.2 事实来源之争:三个方案选 A
回答完问题,AI 给出三个方案:
- 方案 A(推荐):新建
tokens-meta.ts类型化目录作为令牌的"事实来源"(单一来源),CSS 与它两份来源靠测试防漂移; - 方案 B:运行时解析
tokens.css反推令牌表——真正来源只有一份 CSS,但每次启动要解析; - 方案 C:由
tokens-meta.ts构建期生成tokens.css——单一来源且无运行时开销,但构建链变复杂。
三者的唯一区别是令牌事实来源放哪、何时加载。选了 A:主题站不需要运行时动态生成样式,类型化的 meta 文件对 TS 项目最友好,测试防漂移的成本可接受。这个决策直接决定了后面 buildThemeCss 的导出逻辑(见 9.4)。
9.3 执行:一句话启动 subagent 团队
方案与计划确认后,关键的一句:
以上设计认可。开启多人的 subagent 团队在后台开干,完成并测试好之后通知我。
text
需求文档、实施计划这些环节全部跳过——前面的问答已经把方案钉死了,再写文档是流程空转。是否走完整流程由任务复杂度决定,不是每次都要全流程。
执行期间有一个容易被忽略的收益:之前配置好的 git 工作流自动生效——subagent 团队在独立分支上工作,完全不干扰你正在主线调试的会话,这就是多会话并行开发的底气。整个主题系统 + 配置器交付用时约十几分钟。
9.4 真实落地代码(对照仓库核实)
交付物在仓库里的位置与分工:
packages/vue/src/theme/tokens-meta.ts——令牌事实来源。每个令牌一条TokenMeta记录:key(CSS 变量名)、layer(primitive/semantic/component 三层)、group(中文分组名,如"原始·主色色阶""语义·背景",配置器按它折叠分组)、label、type(color/size/duration/font/text,决定控件形态:色板 or 滑块)、light/dark双模式默认值、derived(var()引用项标记为只读,显示解析色 + 指向)。共 74 个令牌、20 个分组。packages/playground/src/components/theme-css.ts——导出与回导的核心,两个纯函数:buildThemeCss(meta, overrides, { includeAll }):默认只导出与默认值不同的令牌(改了主色就只导主色相关行),includeAll: true才全量导出;产出自带:root {}与[data-theme='dark'] {}两个块;buildOverridesFromCss(css, meta):把导出的 CSS 文本解析回 overrides(导入功能),只认 meta 里已知的 key,未知变量自动忽略。
packages/playground/src/components/ThemeBuilder.vue(约 1350 行)+ThemeBuilderPage.vue(页面壳,VitePresslayoutfrontmatter 挂载)——配置器本体。
配置器成品能力(全部实测可用):亮/暗双编辑模式分别调值、令牌搜索、分组默认折叠、四套预设一键应用、圆角/字号类滑块实时预览、重置回默认、导出 CSS 弹窗(附使用说明)、导入 CSS 回读、左侧编辑区可拖宽(300–720px,宽度记忆到 localStorage)、右侧真实 Conversation 组件 + 覆盖标题/列表/引用/表格/代码块的 Markdown 预览、overrides 本地持久化。
9.5 导出的 CSS 怎么用
在配置器里调好后导出,把内容存进自己项目(如 src/styles/custom.css)并在入口 import:
import './styles/custom.css'
ts
保存后整站热更新,主色、图标色、错误/警告/成功状态色全部即时生效——因为导出的就是第七节"层次 3:宿主覆盖 CSS 变量"的产物,组件只认语义层变量,覆盖文件落在哪一层(原始 or 语义)决定了联动范围。
9.6 多轮打磨与效果对齐技巧
首版交付后的典型迭代(每轮一句提示词):
- 左侧编辑区独立滚动、分组默认折叠;右侧预览区占满视口高度、填充更完整的 Markdown 内容——理解非常准确,一轮到位;
- 遗留小 bug(如主色 hover 态色值、左栏过宽挤压缩略图、变量名换行)继续丢给 AI 修。
每个人的 AI 产出的代码几乎必然不一样,怎么办?记住一句:要效果一样,不要代码一样。发现某处和预期不符,把目标效果截图发给 AI,让它解析图里的效果再对齐——比描述文字准得多。学这一节学的不是某段代码,而是"找准方向 → Plan 问答钉死方案 → subagent 执行 → 截图对齐效果"这套与 AI 协作的流程。
另一个顺手的收益:因为改的是 packages/ 里的源码,之前配置的文档同步钩子自动触发——useTheme、useThemePreset 两个新 composable 的 API 文档被自动生成并挂到文档站。如果你的钩子没生效,一句"监视 packages 目录代码变化,有变化就更新对应文档"即可补上。
小结
- 现有
tokens.css曾是"配色表"不是"主题系统":扁平无分层、暗色默认、命名混杂、逻辑写死在页面、无预设、残留旧兼容区——六个问题。 - 方案把它升级为三层令牌(原始→语义→组件)+ 命名务实收敛 + composable + 三档配置,增量改造、不推倒重来;默认亮色、暗色仅翻转语义层。
- 主题在页面上的配置分三个层次:组件库 Props(声明式)、useTheme composable(运行时切换)、宿主覆盖 CSS 变量(零 JS)。优先级 inline style >
[data-theme]>:root。三档均已落地。 - 主题配置器:令牌事实来源
tokens-meta.ts+ 纯函数导出buildThemeCss+ ThemeBuilder UI;Plan 问答钉死方案、subagent 团队执行、截图对齐效果——这份"文档交给 AI 落地"的流程比配置器代码本身更值得复用。
下一节讲响应式设计——但不是普通的媒体查询,而是组件库场景下的容器查询,让组件适配任意容器宽度。
↑