返回文章

样式层:错的颜色,根本不许出现在调色板上

这是五层约束系列的第二篇。母篇《从能跑到可交付:一套前端 AI 协作系统的设计笔记 》是总览,第一篇《语言层:代码写错的那一秒,就该被拦住 》讲了编译期约束。这一篇讲样式:颜色、间距、圆角、字号这些"看起来无所谓"的值,为什么要被收进一个只有固定选项的调色板里。我用的系统是开源的 frontend-harness-cli ,文中规则都能在仓库里找到对应实现。

三个页面,三种蓝

先看一个具体的场景。

你让 AI 写一个列表页,主按钮是蓝色,它选了 #2563EB,挺好看。隔两天,你让它写详情页,要求"跟列表页风格一致"。它看不到上次的上下文——就算看得到,它也是凭印象估——写出了 #1D6FF2。再隔几天,设置页来了,这次是 #2B7FFF。

三个蓝色,单独看哪个都没错。把三个页面并排放在一起,任何人都会觉得这不是一个产品,是三个外包拼的。

同样的事发生在间距上:这个页面卡片间距 14px,那个 16px,另一个 15px;圆角一会儿 8,一会儿 10,一会儿 12。每一处差异都小到不值得单独提一个 bug,合起来就是一句话:“这东西看着不精致。“而"不精致"没有报错、没有红字、没有任何工具会提醒你——它只存在于用户的第一印象里。

为什么第二层是样式层

语言层保证的是代码形状合法:能编译、能跑。它对"十个页面像不像一个产品"一言不发,因为那不是形状问题。

样式问题的根源也很具体:AI 没有跨上下文的视觉记忆。 人写页面时会记得"我们按钮一直用那个蓝”,因为他上周刚写过;模型每一次生成都是全新的目测。让它目测一百次,就有一百次采样误差。你跟它说"注意保持一致"没有用——它每一次都真心觉得自己写得和上次一样。

所以这一层的思路不是"要求它更仔细”,而是把选项收窄到选不错:颜色、间距、圆角、阴影、字号,全部预先定义成一组变量(Design Token);写样式时只能从这组变量里取,不许现场发明数值。调色板上只有 8 种颜色,它就选不出第 9 种错的。这不是审美管理,是把一致性从"靠记性"变成"靠结构"。

也要先说清这一层保的是什么:一致性,不是美。 一套 Token 可以非常一致地丑。好不好看是第五层美学层的事;这一层只负责让一百个页面看起来出自同一只手。

设计一:所有视觉值只能来自 Token

具体规则写在栈包(harness init 装进项目的 standards 文件)的样式一节里:

  • 颜色、间距、圆角、阴影、字号,统一沉淀为 Token——CSS 变量,或者 UI 组件库的主题配置,二选一,但只能有一个来源;
  • 组件里不许直接写裸的颜色值(十六进制、rgb 都不行),要用颜色必须引用 Token;
  • 间距、圆角同理,不许随手写一个"差不多"的像素数。

为什么对裸值这么严?因为裸值的失败方式很阴险:#2563EB 写在组件里,它渲染出来和 Token 里的品牌蓝看起来一模一样。没有人会在评审时发现它,没有测试会挂,它就这么混进去了。等到品牌色要整体调整,你改了 Token,全站都变了,只有这个组件还停在旧颜色上——而你根本不知道它在哪。扫描器给这条规则的判语是:“一个裸的十六进制色看起来是对的,却在悄悄地分叉你的调色板——用 Token。”

换句话说,裸值的问题不是"错",是分叉:从写下它的那一刻起,这个组件就脱离了全局的样式系统,开始独立演化。

设计二:Token 的名字必须真实存在

收窄选项之后,AI 找到了一个新的失败方式:编造 Token 名。

它知道该用变量,也知道变量名大概长什么样,于是写出 var(--color-brand-500)、var(--spacing-card) 这种看起来非常合理的引用——但你的 Token 文件里压根没有定义过这两个名字。CSS 变量的残酷之处在于:引用一个未定义的变量,不报错。 浏览器把这个属性当作空值处理,元素就静默地失去了那部分样式。按钮没有背景色、卡片没有内边距,页面打开不白屏、不抛错,只是"哪里不太对",而且非常难查——你盯着组件代码看,它明明"用了 Token",写得规规矩矩。

这是 AI 特有的失败:人会去 Token 文件里确认名字,模型的模式是"按命名规律生成一个八成对的名字",而八成对的名字在 CSS 变量这里等于不存在。

所以这一层有一条专门的校验:扫描时把全项目用到的 var(--x) 逐个拿去和 Token 源文件里定义的变量对账,用了没定义的,当场报出来。判语同样直白:“未定义的 Token 会渲染成空值。“这条规则查的不是品味,是引用的真实性——和语言层查"这个变量存不存在"是同一类问题,只不过 CSS 自己不查,只能由工具补上。

设计三:样式必须有作用域,还要能在服务端渲染

Token 管的是"值”,作用域管的是"这段样式会影响谁”。

AI 写样式时很喜欢顺手写全局选择器:为了让这个按钮变红,给 .btn 加一条规则——而 .btn 全站共用,三个不相干的页面跟着一起变了。这种污染和裸值一样不报错,只是范围更广。所以规则是:组件样式走 CSS Modules(*.module.css / *.module.scss),类名在构建时自动加作用域;或者用项目既有的 CSS-in-JS 方案。二者有一个共同前提——

样式必须能在服务端正确渲染。 在 Next.js 的 App Router 里,CSS-in-JS 需要一个注册表(registry)把样式收集起来随服务端 HTML 一起发出;不配,页面就是先裸奔一帧再上色,用户看到闪烁,布局还会跳。对 AI 来说,“本地开发没问题"和"服务端首屏没问题"是两回事,这条要写进规范而不是等它踩。

同一条线上的还有内联样式。style={{ color: ... }} 写起来最快,AI 用得最顺手,但它绕过了 Token 和作用域两道关:值是裸的,复用是零。规则是内联样式只许用于运行时才能算出来的值(比如根据数据算出的宽度百分比),其余一律走样式文件。扫描会统计每个文件的内联样式数量,超上限就报:“内联样式正是生成的界面各漂各的原因。”

设计四:图片和字体也是样式

这一节容易被当成"资源问题”,其实失败方式全是样式的失败方式。

图片:必须走 next/image,并且写明尺寸和 alt。写尺寸是因为图片加载前浏览器要先知道它占多大地方,不知道就先按零排版,图到了再把下面的内容整体推下去——布局偏移,用户读到的段落突然跳走。alt 的地位要单独说:在我的扫描规则里,图片缺 alt 是一条独立的检查项,判语是"alt 是界面契约的一部分"——它不是给搜索引擎的礼貌,是给读屏用户和加载失败时的界面完整性。

字体:走 next/font 加载,让构建工具在服务端把字体安排妥当,而不是运行时去第三方拉一个字体文件再替换——替换的那一瞬,整页文字会换一副面孔重新排版,同样是布局偏移。字体和图片在这里的道理相同:它们都是在页面渲染之后才姗姗来迟的东西,迟到就会推挤已经排好的布局;这一层要做的,是在写代码时就把它们的位置和尺寸钉死。

机器怎么验这一层

这一层落在扫描的界面(UI)维度上,四条规则都是真查,不是目测:

  • raw-hex-colors:数 Token 文件之外出现的裸十六进制颜色,每个文件有上限(maxRawHexPerFile),超了就报,并附上那句"悄悄分叉调色板"的判语;
  • unknown-design-token:把全项目引用的 var(--x) 和 Token 源里的定义对账,用了未定义的就报;
  • too-many-inline-styles:数每个文件的内联样式数量,超上限就报;
  • img-missing-alt:图片没有 alt 就报。

配合的还有规范层:Token 的来源、作用域方案、内联样式的例外情形,都写在 standards 里,规划者在技术设计里引用它,评判者评测时对着它查。再加上语言层那套三档逻辑同样适用——规范是提醒,扫描扣分是代价,真拿裸值糊弄过评测,属于评测层的账。

这一层管不了什么

边界照例说清。

这一层保证的是一致和不漂移:同一个蓝全站一致、间距出自同一套刻度、引用的名字都真实存在。它不保证这套 Token 本身好看——刻度定得难看,全站会整整齐齐地难看。它也不保证页面摆得对:布局错位、信息层级混乱、该突出的不突出,那是美学层和评测阶段的事。

还有一件事它管不了:数据本身对不对。 页面渲染得再一致,如果显示的数字是错的、状态是旧的,一致性反而会让错误显得更可信。那是下一篇状态层要管的问题——状态先定义、数据只有一个来源,为什么比样式更难缠。