第 22 章 设计体系:让产品长期保持一致
同样的东西,在每个地方都长得一样、用法一样。
什么时候读这一章:产品需要长期迭代,页面越改风格越乱的时候。
有一天,我开始和 Agent 讨论重做阿布桌面软件的界面。动手之前先让它把现状数了一遍,结果是:两套颜色命名同时存在,5 个在用的颜色变量没有定义,四十多处手写的遮罩,五百多处手写的按钮。
这些数字是半年里一个任务一个任务攒出来的,没有哪一次任务做错了。每个任务让 Agent 加一个页面或一个弹窗,它每次都写出一个看起来没问题的按钮,圆角比上一个大一点,灰色比上一个深一点,单独看都对。入门篇第 11 章说用现成的组件库能让页面风格统一,那句话在页面少的时候是对的。页面多了,组件库只是一箱零件,哪个零件用在哪里、不够用时怎么办,还需要一套规定。这套规定就是设计体系。
设计体系是什么
设计体系是关于界面的一整套决定,加上让这些决定不被绕开的办法。决定有五类。
颜色。有几种颜色,每一种用在什么地方。阿布改版后是黑白灰:主按钮用正文文字颜色的反色,选中的项目叠一层 7% 的灰;品牌绿只留在头像和应用图标这类表明身份的位置。品牌绿不拿来写字是算过的,它在白底上的对比度只有 2.00:1,文字看不清。
字体和字号。用什么字体,有几档字号,每档用在哪里。阿布的界面控件和侧边栏用 13 像素,对话正文用 14 像素;界面字号一共五档,22、15、13、12、11;字重只用 400、500、600 三种;最小 11 像素。
间距和圆角。东西之间隔多远,角有多圆。阿布的圆角只有三种:窗口 18,卡片 10,控件 7,从外到里一层比一层小。
组件。按钮、输入框、下拉框、开关、弹窗、菜单、提示,每一种只有一个做法,规定好有哪几种样式、哪几种尺寸。
用词。同一个东西只有一个名字,同一个动作只有一个说法。
图标和动效可以算进前四类,也可以单列。阿布的图标继续用 Lucide 这个图标库,尺寸只留 14、16、20 三种,线条统一 1.5;动效时长三档,120、200、300 毫秒。
五类决定写下来只是一份文档。要让它管用,还得有三样东西配合:一个存放这些决定的文件,代码只从这里取值;一组现成的组件,代码只用这一组;一套自动检查,绕开前两样时报错。文档、文件、组件、检查,四样合在一起才叫体系。
阿布桌面软件在公开仓里的基础是这几样:Tailwind CSS 第 4 版管样式,shadcn 的方式管理组件源码,Radix 提供弹窗、菜单、提示这类组件的底层行为,Lucide 提供图标。package.json 里能看到 tailwindcss、shadcn、radix-ui、lucide-react 这几个依赖。这些是零件的来源,设计体系建在它们上面。
为什么有了它,AI 做出的页面才能一致
一个人类设计师做了三个月以后,手上有肌肉记忆,新页面自然和旧页面像。Agent 没有这种肌肉记忆。每个会话开始时它都是新来的,它看到的只有你给的任务书和它读到的那几个文件。读到的文件里按钮是圆角 8,它就写圆角 8;读到的另一个文件里是圆角 6,它就写 6。两个文件它都读到了,它会挑一个,或者再发明一个。
Tailwind 这类工具让这种不一致更容易出现。一个按钮的样子由一串类名决定,灰色有十几档可选,字号有七八档可选,还能在方括号里随手写任意数值。每一个选择 Agent 都能给出理由,每一个选择都和上一次略有出入。五百多个手写按钮就是这样来的。
设计体系对 Agent 的作用是把选择题变成填空题。按钮只能从组件目录里引入,样式只有规定的那几种;颜色只有十几个有名字的角色,没有“深一点的灰”这种说法。可选的东西少了,每次的结果自然接近。第 13 章讲选技术时说要选 AI 最熟悉的,道理相通,都是不让它在你不在场的时候替你做设计决定。
设计体系还让复审有了依据。没有设计体系时,我看一个新页面,只能凭感觉说“这个灰不对”。有了以后,问题变成“这里为什么没用规定的那个颜色”,Agent 自己能查出来,检查脚本也能查出来。
设计变量:把决定存成名字
存放决定的那个文件,里面是一组设计变量,专业上叫设计令牌。一个设计变量就是给一个值起的名字。
阿布的设计变量都在公开仓的一个样式文件里,文件开头的注释写着它是唯一的来源。变量分两层。第一层是原始的颜色,一排从浅到深的灰,加上红、橙、绿、蓝和品牌绿,名字是 --ds-palette-gray-50 这种。第二层是用途,名字说的是“用在哪里”:--ds-surface 是内容区的底色,--ds-label 是正文文字的颜色,--ds-emphasis 是主按钮的底色,--ds-separator 是分隔线的颜色。
组件和页面只许用第二层。注释里有一句规定:组件永远不直接引用第一层的原始颜色。这样分的好处在换肤时最明显。浅色和深色两种外观下,“正文文字的颜色”这个名字不变,背后的值各有一套。页面代码里一个颜色值都没有,只有名字,切换外观时页面一行都不用修改。
用途这一层在代码里以类名的形式出现。阿布公开仓的 AGENTS.md 把它们列成了一张表,我摘录几类。
| 类别 | 类名 | 用在哪里 |
|---|---|---|
| 底色 | bg-desk bg-surface bg-raised bg-field | 窗口底、内容区、浮起的卡片、输入框 |
| 填充 | bg-fill-hover bg-fill-selected bg-fill-pressed | 悬停、选中、按下 |
| 文字 | text-label text-label-secondary text-label-tertiary text-link | 正文、次要文字、更次要的文字、链接 |
| 主操作 | bg-emphasis 配 text-on-emphasis | 主按钮 |
| 状态 | text-success text-warning text-danger text-info | 成功、警告、危险、提示 |
| 圆角 | rounded-window rounded-panel rounded-control | 窗口、卡片、控件 |
| 层级 | z-sticky z-popover z-dialog z-toast z-tooltip | 谁盖在谁上面 |
| 动效 | duration-fast duration-base duration-slow | 三档时长 |
表里没有一个数字。Agent 写页面时要回答的问题从“这里用多少像素的圆角”变成“这是窗口、卡片还是控件”,后一个问题它不会答错。
弹窗、菜单、提示条谁盖住谁,靠的是一个叫 z-index 的数字,数字大的在上面。没有规定时,每个人都想让自己的弹窗在最上面,于是数字越写越大。阿布旧代码里有弹窗把这个数字写成了 9999,改版过程中发现它压在所有新做的浮层之上,从一个还没迁移的旧弹窗里打开新的菜单,菜单会被挡在弹窗后面。五个有名字的层级替换了所有手写的数字。
组件:一种东西只有一个做法
组件管的是结构。阿布新的组件单独放在一个目录下,分三类。表单控件一类,从按钮、输入框到下拉选择、开关、滑块;浮层一类,包括弹窗、确认框、菜单和提示;还有一类负责反馈,空状态、读取失败提示、行内消息、全局提示和转圈都在里面。
每个组件把容易做错的事收在自己里面,阿布桌面软件的项目手册里写了这样几条。
- 所有弹窗都用
Dialog组件,全应用唯一的遮罩由它负责,用户填了内容没保存就关闭时由它来问。四十多处手写遮罩的问题从这里解决。 - 确认一律用
useConfirm(),不用浏览器自带的window.confirm()。 - 只有图标的按钮必须用
IconButton,并且必须带一个文字标签,给读屏软件和悬停提示用。 - 图标只能通过统一的
Icon组件来画,尺寸只有小、中、大三种,对应 14、16、20,线条固定 1.5。页面里不许直接引入图标库。 - 同一时间只开一个弹窗、一个菜单或弹出层,由一个统一的层管理器负责。
项目手册里还有一句处理“组件不够用”的话:缺一种样式时,去扩展组件本身,不要在页面里另写一个一次性的。这一句是给 Agent 看的。它遇到现有按钮不合适时,最顺手的做法就是当场写一个新的。
新组件没有直接放进旧的组件目录。旧目录里有 6 个同名的旧组件,公开仓大约 150 个文件在用,所以新组件单独放一个目录,新旧并存,一个区域一个区域地迁移。
用词:同一个东西只有一个名字
用词是设计体系里最容易被漏掉的一类,因为它不在样式文件里。
阿布一个版本终审时发现三种叫法并存:界面标签写“队员”和“团队”,发版说明写“专家”,技能文档写“代理”。每一处单看都通顺。我当天定了统一成“专家”和“专家团”,作为单独的一批来做,“队长”和“成员”保留为专家团内部的角色词。最近一个正式版本的更新日志里有一条行为变更写的就是这件事:原“队员”现称“专家”,原“团队”现称“专家团”,只改了叫法,功能与数据不变。
同一天还定了另一条。界面上有不少地方直接露着英文的 Agent 这个词,它在不同位置指的是三样东西。定下来的规矩是按所指分开写:指阿布自己的地方写“阿布”;指用户创建和使用的角色,写“专家”或“专家团”;指后台派活的那套机制,写“子代理”;列表的表头、面板的标题这类泛指的地方,用“执行者”或者去掉主语。
这次统一有一条边界:只修改面向用户的文字,也就是界面、更新日志、技能文档。代码里的字段名、工具名、测试用的标识、写给模型的英文提示词里的 agent 和 team,一个都不跟着修改。改名字是为了用户;代码里的名字是给程序和开发者用的,不需要跟着界面变化。
动作的用词也要定。阿布从同一个版本起,用户自己创建的东西,按钮统一叫“删除”;“卸载”只留给插件。两个词混着用,用户会分不清点下去的后果是不是一样。
用词能统一,前提是文案集中在一处。阿布的项目手册要求界面文字一律通过多语言函数读取,不许把中文直接写在组件里。文案集中在多语言文件里,一个词出现在哪些地方,搜索一下就全知道。
修改文案有一个容易忽略的代价。阿布的端到端测试是靠界面上的中文文字来找按钮的,修改一个词,所有用到这个词的测试都要跟着修改。阿布工作区的项目手册把“改了中文文案”和“改了全局组件”列在同一档,都要等全部端到端测试通过。所以用词越早定越省事。
从哪里开始建
产品小的时候,一页纸就够。Bentoly 的仓库里有一份设计规范文档,写了主色是一种蓝色,用在主要按钮、链接和选中状态;强调色是琥珀色,用在提示和警告;中性色用一套灰;间距以 4 像素为基础单位。每条规定下面有一个正确写法和一个要避免的写法,要避免的那个例子是带渐变色的按钮。另一份文档专讲配置面板,定了三条原则:核心配置一两项始终可见,高级选项折叠;每个可选项都有默认值;间距、标签样式、组件高度统一。后面附了一份标准的面板代码,新面板照着它写。两份文件加起来就是一个小产品的设计体系,Agent 每次做新面板前读一遍。
产品大了以后再建,第一步是清点现状。让 Agent 数清楚:有几种颜色写法,有多少处手写的按钮和遮罩,哪些变量用了却没定义。数字有两个用处,一是让你知道问题有多大,二是迁移过程中每做完一批再数一次,能看到它在减少。
再做决定。颜色角色、字号档位、圆角、间距、动效时长,每一类定少数几个值。阿布这一步从开始讨论到定下来用了四天,每个决定都带着理由,例如图标库继续用 Lucide,是因为苹果自己的那套图标(SF Symbols)按授权不能用在 Windows 上。
然后建底座。阿布把整个改版分成若干批来做,第一批叫“底座”,只做设计变量文件和检查规则,界面上没有任何看得见的变化。底座里有一项是对比度测试:设计变量一旦改动,测试会在浅色、深色、以及两者各自的“增强对比度”一共四种外观下,把文字和底色全算一遍,有一组达不到无障碍标准就失败。底座做完时算出来的最低值是 4.85:1。
接着做组件和预览页。组件做完时同时提交了一组基准截图,以后组件的样子一变,截图比对就会发现。预览页把所有组件的所有状态摆在一起,你和 Agent 都在这一页上看“标准长什么样”。
最后一个区域一个区域迁移。阿布从窗口外壳、标题栏和侧边栏开始,接着是对话区、右侧面板,按界面区域一批一批往下走。写这本书时改版整体还没有发布:各个区域迁移完都先合进一条长期存在的改版分支,等全部做完再一起进入集成分支发版。这样安排,用户拿到的每个版本,界面要么全是旧的,要么全是新的。
写成 AI 能读懂的文件
写给 Agent 看的设计规范,要写“用什么”,并给出引入路径和一行示例。阿布项目手册里讲下拉选择的那一条,直接给了从哪个路径引入,以及表单里、设置行里各怎么写。Agent 照着抄,不用推测。
还要写“不许用什么”,并且每一条禁令后面跟着替代的做法。阿布项目手册里的写法是:不要自己定义下拉组件,不要用原生输入框手写边框和焦点样式,缺样式就去扩展组件。
数值只放在一处。设计变量的值只在那一个样式文件里,规范文档里列类名和用途,不重复写数值。两处都写,修改了一处忘了另一处,Agent 读到哪份算哪份。
只靠写在文档里,管不住 Agent。模板仓库的“边界”一章里有一条规则叫“把代码约定写成 lint 规则”,理由只有一句:写在规范里的约定,Agent 读过也会忘;检查报错时它会当场修改。lint 是一种自动检查代码写法的工具,写得不合规定就报错,报错可以拦住提交和合并。
阿布把设计体系的规定写成了几类检查,都在公开仓的 eslint.config.js 里。
- 不许在方括号里写任意数值,比如随手写一个颜色值、一个层级数字、一个圆角像素。
- 不许直接用 Tailwind 自带的颜色,比如某一档灰、纯白。
- 不许手写铺满全屏的遮罩。
- 不许在页面里直接引入图标库和底层组件库,只能通过新组件目录里的组件来用。
- 不许用已经废弃的旧类名。
每条规则报错时会说明应该改用什么。模板仓库里特别强调这一点,报错信息对 Agent 来说就是下一步的指令。
旧代码有几百处不合规定,规则一打开就是满屏报错,谁也改不完。阿布的办法是一份迁移名单:新规则只对名单里的文件生效;一个目录迁移完,就在同一个 PR 里把它加进名单;名单只增不删。已经迁移的目录从此受规则保护,不会被后来的任务改回旧写法;没迁移的目录先不管。迁移进度也不用靠人记,看名单就知道。这套做法的通用版本在模板仓库的 05-进化/mechanisms/lint-ratchet/ 下,有两个文件:eslint.migration-list.mjs 是名单和规则的写法,eslint-rules.test.ts 是规则自己的测试,用来证明规则写对了、真的会报错。规则的正则表达式写错一个字符,规则就悄悄失效,检查照样显示通过,所以规则也要测试。
这些检查的强度有上限。模板仓库写明了:迁移名单这条规则的强度是 L2(有检查,但能绕过),可以用一行关闭检查的注释绕过,“名单永远不删”这一句也没有东西在检查。规则的四个强度第 30 章会讲。知道哪里能绕过,复审时就多看一眼有没有新增的关闭注释。
案例
正例:字号从随手写到八档。 在这次改版之前,阿布已经用同样的办法管过字号。项目手册里定了八档字号,每一档把字的大小、行高、字重绑在一起,从 11 像素的标注到 24 像素的欢迎页标题。检查规则禁止两种写法:在方括号里手写像素值,和使用 Tailwind 自带的那一组字号名。字号迁移完成之后,靠检查让违规数一直保持为零。这件事证明了一条路能走通:先定少数几档,再写成检查,再把旧代码迁过去,以后就不用再管。黑白灰改版是把这条路从字号扩大到颜色、圆角、层级、动效和组件,本章前面列的五档界面字号也是这次改版重新定的。
反例:一个灰色引出的误会。 最近一个正式版本修复过一个很小的问题。“专家”页上下两行导航叠在一起,上面一行是“专家 / 专家团”,下面一行是“市场 / 我的”。上面那行选中的项是陶土色的胶囊,下面那行选中的项却是灰色。那个灰色和同一行里未选中项的悬停颜色一模一样,鼠标划过“我的”,它看起来就像已经被选中了。
两行导航各自挑了一个表示选中的颜色。没有“选中状态用哪个颜色”这条规定时,两处挑的不一样是正常结果。同一个版本里还修复了一处:沙箱(把命令限制在指定范围里运行的隔离环境)恢复卡片上的按钮渲染成了没有样式的纯文字。这类问题每一个都小,但只要每个页面还在各写各的样式,它们就会一直出现,更新日志里总要占几条。设计体系解决的是这一整类问题。
给 AI 的模板
设计规范模板
放在仓库里,文件名可以叫 DESIGN.md,并在项目手册里写一句“做任何界面前先读 DESIGN.md”。
# 设计规范
数值只写在【src/styles/tokens.css】一处,本文件只写名字和用途。
## 1. 颜色
| 名字(类名) | 用在哪里 |
|---|---|
| 【bg-surface】 | 内容区底色 |
| 【text-label】 | 正文文字 |
| 【text-label-secondary】 | 次要文字 |
| 【bg-emphasis + text-on-emphasis】 | 主按钮,一屏最多一个 |
| 【text-danger】 | 危险操作、错误 |
禁止:直接写颜色值;使用样式工具自带的颜色名。
## 2. 字号
| 名字 | 用在哪里 |
|---|---|
| 【text-title】 | 页面标题 |
| 【text-ui】 | 控件、列表 |
| 【text-caption】 | 标注、时间 |
字重只用【400 / 500 / 600】。禁止:手写像素值。
## 3. 圆角、间距、层级、动效
- 圆角:【rounded-window / rounded-panel / rounded-control】
- 间距:以【4】像素为单位,只用【4 / 8 / 12 / 16 / 24】
- 层级:【z-popover / z-dialog / z-toast / z-tooltip】,禁止手写层级数字
- 动效:【duration-fast / duration-base / duration-slow】;【发送、切换、输入】不加动画
## 4. 组件
所有控件从【@/components/ds】引入,不手写。
| 要做的东西 | 用哪个组件 | 备注 |
|---|---|---|
| 按钮 | 【Button】 | 样式:【default / secondary / ghost / destructive】 |
| 只有图标的按钮 | 【IconButton】 | 必须带 label |
| 弹窗 | 【Dialog】 | 遮罩由它负责,不另写 |
| 确认 | 【useConfirm()】 | 不用 window.confirm() |
| 没有数据 | 【EmptyState】 | 一句说明加一个下一步按钮 |
| 读取失败 | 【LoadError】 | 一句原因加“重试” |
组件缺样式时,扩展组件本身,不在页面里另写。
## 5. 图标
只通过【Icon】组件使用,尺寸【14 / 16 / 20】。常用操作的固定图标:
| 操作 | 图标 |
|---|---|
| 【删除】 | 【trash】 |
| 【设置】 | 【settings】 |
## 6. 用词表
| 东西或动作 | 界面上的叫法 | 不要用的叫法 |
|---|---|---|
| 【用户创建的助手】 | 【专家】 | 【队员、代理、Agent】 |
| 【移除用户自己创建的东西】 | 【删除】 | 【卸载、移除】 |
代码里的字段名、工具名、测试标识不跟着界面用词修改。
## 7. 怎样检查
- lint:【npm run lint】,规则在【eslint.config.js】,迁移名单在【同一文件】,只增不删
- 对比度:【设计变量测试的命令】
- 截图比对:【命令】
- 新目录迁移完,在同一个 PR 里把它加进迁移名单
让 Agent 盘点现状的指令
只读,不修改代码。盘点【src/】下的界面代码,给出数字和文件位置:
1. 颜色:出现了多少种不同的颜色写法(直接写的颜色值、样式工具自带的颜色名、自定义变量各多少处);
有没有被使用但没有定义的变量。
2. 字号、圆角、层级数字:各出现了多少种不同的取值,每种多少处。
3. 手写的控件:原生 button、input、select 各多少处;手写的全屏遮罩多少处。
4. 用词:界面文案里指同一个东西的不同叫法,按“东西 → 各种叫法 → 各出现在哪里”列出。
每一项只回数字和“文件:行号”,不贴代码原文,不提改法。
lint 规则和迁移名单的通用写法见模板仓库 03-边界/mechanisms/eslint-boundaries.mjs 和 05-进化/mechanisms/lint-ratchet/;阿布自己的设计规则在公开仓 PM-Shawn/Abu-Cowork 的 eslint.config.js 里,可以对照着写你自己的。
专业上叫什么
设计体系(Design System):一套设计决定、组件和使用规定的总和。公开的大型例子有 Google 的 Material Design 和苹果的人机界面指南(Human Interface Guidelines);把界面分成由小到大几级零件的讲法,常被引用的是 Brad Frost 的《Atomic Design》。
设计令牌(Design Token):给一个设计上的值起的名字,代码只用名字。这个词一般认为出自 Salesforce 的 Lightning Design System 团队。本章正文里叫它设计变量。
组件库(Component Library):一组现成的界面零件。shadcn/ui、Radix UI 属于这一类。组件库是设计体系的一部分,光有组件库不等于有设计体系。
本章提到的无障碍对比度标准是 WCAG(Web Content Accessibility Guidelines),普通大小文字的 AA 级要求是 4.5:1。
本章开头那句话,讲的是尼尔森可用性十原则第 4 条“一致性和标准”。