色板本身只能证明”顺”,证明不了”为什么这样是对的”。这份文档记录每一个决定、它的代价,以及被否掉的方案为什么被否掉。
先分清两种截然不同的东西:
渐变条——从最浅到最深均匀铺开的 10 个采样点,追求任意相邻两阶看起来差得一样多。主色只是采样点之一,在第几位不重要。
语义槽位——每一阶有固定职责:最浅的几阶是页面底、hover 底、选中底、禁用底;中段是按钮和图表主色;最深的几阶是文字和描边。间距不均匀,按用途分配。
任务描述写的是后者:「承载从最浅背景、Hover 高亮到最深文字强调等不同场景」。所以本项目按语义槽位设计,浅色端刻意做得密。
代价:拿尺子量相邻色差,会发现步长在 4 到 12 之间波动,三倍差距。这不是断层——断层是跳变,而这是连续变化。
「突然断层」「深色发灰」「浅色发飘」这三条验收标准,是同一个错误的三种症状:在 HSL / HSV 里做插值。
L 与 HSV 的 V 都不是感知亮度。等距取 V,中段几阶挤在一起、两端拉开,肉眼就是断层。候选是两个感知均匀空间:
| OKLCH | HCT | |
|---|---|---|
| 明度分量 | OKLab L |
CIELab L* |
| 与 WCAG 对比度的关系 | 间接 | 直接,L* 可反解对比度 |
| 转换成本 | 约 60 行,无迭代 | 约 150 行 + 迭代求解 |
| CSS 原生支持 | 有 oklch() |
无 |
选 HCT。两个理由:TDesign 官方色彩体系用的就是 HCT(腾讯设计中台公开说明:「色阶的制定采用了 HCT 色彩空间,结合不同色相下饱和度及亮度插值拟合出优化曲线」);以及 L* 与对比度直接挂钩,”第 9 阶必须能在白底上当正文”这种硬指标可以反解出来。
代价是慢。给定 (色相, 彩度, 明度) 求 RGB 需要迭代——本项目用「彩度二分 + 明度 J 二分」,比 Material 的解析解慢一个数量级,但一次色阶只有几十次求解,够用且代码短得多。
实现正确性对照 Material 公布的参考值验证:#0000FF → H 282.79 / C 87.23 / T 32.30,#FF0000 → 27.41 / 113.36 / 53.24,往返转换精确复原。见 test/run.mjs。
浅色参考台阶(L*):
96.1 89.8 80.7 70.9 60.6 50.1 39.7 29.8 20.5 12.1
ΔL*: 6.3 9.1 9.7 10.3 10.6 10.4 9.8 9.4 8.3
步长先增后减,起伏 1.7 倍。三条理由:
L*>80 那一段,等距分法给不出足够多可区分的浅色。必须说清楚来源:这条台阶是对着 TDesign 官方色阶反推的。
我们最初自己推导的版本是 [97, 93, 85.5, 75, 63, 51, 40, 30.5, 21.8, 14],步长 4.0 … 12.0 … 7.8,起伏 3.0 倍——同样是钟形,但幅度大得多。当时的理由是”浅端槽位要够用”。
拿到官方色阶做对比之后发现这个判断过头了:我们的最浅两阶 #F6F5FF 和 #E7EAFF 都是”几乎白”,浪费了一个名额;而官方的第 2 阶 #D9E1FF 是一眼能认出来的浅蓝,做 hover 底或选中底更够用。
改用官方的明度分布之后,生成结果与官方手工调校的色阶:
| 我们自己的台阶 | 官方明度台阶 | |
|---|---|---|
| brand | ΔEab 5.39 | 1.49 |
| warning | 3.31 | 1.59 |
| error | 5.15 | 2.24 |
| success | 3.23 | 2.39 |
| 四套平均 | 4.27 | 1.93 |
采用的是明度分布,不是色值。而支持这个决定的证据是:官方 brand / warning / error 三套色阶的 L* 分布几乎完全一致(起伏分别是 1.7、1.6、1.7 倍)——三个完全不同的色相,独立调出同一个形状。这说明它是一条通用规律,不是为蓝色特调的曲线。
(success 那套起伏 2.9 倍,因为它第一阶只跨了 3.8,是官方自己那一套的不一致。)
否掉的方案:一路单调递增(从 4 加到 16)。很多生成器的做法,也是最容易踩的坑——它在主色所在的那一阶两侧会对不上:上面一步跨 16、下面一步跨 9,接缝处就是肉眼可见的断层。
否掉的方案:前段疏、中间偏后密、后端疏。这个形状在中段更均匀,作为渐变条更”顺”,但把槽位花错了地方:第 1 到第 2 阶跨 13 个 L*,从近乎纯白直接跳到明显的色,中间整段浅色(页面底、hover 底、选中底、禁用底)一个可用的都没有;同时第 4 到第 7 阶四个中蓝彼此差 5.5 到 8,等于花了 10 个名额里的 4 个买了四个可以互相替换的按钮色。
例外:如果色阶是给数据可视化用的(热力图、地图分级、渐变填充),这个形状反而更对——可视化要的就是任意相邻两块看起来差得一样多,浅端深端不承载语义,疏一点无所谓。本项目定位是界面用色,所以选钟形。
色阶的每一阶本质是一个亮度槽位——第 1 阶能当页面背景,是因为它 L*≈97,不是因为它排第一。既然槽位由亮度定义,把主色放进它亮度对应的槽就是唯一自洽的做法。
实现:找到参考台阶中离输入主色 L* 最近的那一阶作为锚点,把台阶整体拉伸使锚点精确命中,两端固定不动。因为锚点取的是最近的一阶,两侧缩放比只差几个百分点,接缝处的步长跳变可以忽略。
这直接给出了极端主色问题的答案:一个 L*=92 的亮黄天然属于第 2 阶。界面会如实告诉你它落在第 2 阶。强行把亮黄压到第 7 阶,等于说”槽位的亮度定义可以为了迁就输入而改变”——那槽位就不成其为槽位,整套语义随之瓦解。这也是很多生成器遇到黄色和青色会崩的原因。
唯一的夹取:clamp(L*, 3, 97)。纯白纯黑不夹住的话,色阶会退化成一整条白或一整条黑。夹取后的实际值通过 anchorTone 暴露出来,不藏。
主色从”必须命中的点”降级成”用来确定曲线的参数”。曲线先按顺滑度画出来,主色只负责告诉曲线走哪个色相、哪个饱和度、锚在哪个明度。
实际偏差通常小于 1 个 ΔE(#0052D9 输入 → 第 7 阶输出 #0052D9,多数情况完全一致),来源是色域映射的量化。整条色阶因此获得了更高的自由度。
浅侧(比锚点亮):C = C_主色 × ((100 − L*) / (100 − L*_锚点)) ^ 0.35
深侧(比锚点暗):C = C_主色,不动
指数 0.35 也是对着官方四套色阶拟合出来的。这里有个值得记的教训:只对着蓝色调参数会得到 0.15——它能把 brand 压到 ΔEab 0.90,看起来非常漂亮,但同一个参数让 success 炸到 4.14、最大色差 16.4,生成的 #CFFFE1 比官方的 #E3F9E9 明显更饱和。这就是过拟合到单个色相。0.35 让四套都落在 2.5 以内,没有一套被牺牲。
深侧不做保守取值,而是保持主色彩度,然后逐阶单独做色域映射:固定明度与色相,只把彩度二分降到 sRGB 边界。这样每一阶都拿到了它自己那个亮度下能给出的最大彩度——这就是”深色不发灰”。
否掉的方案:全局统一上限(先算出整条色阶最紧的色域上限,所有阶都不超过它)。这条上限会被最浅那一阶定死:L*=97 处 sRGB 只能装下约 7 点彩度,于是整条蓝色色阶变成石板灰。这是”深色发灰”的教科书演示。
否掉的方案:深端主动加码彩度(比主色更饱和,再交给色域裁)。实测对饱和主色是空操作——#0052D9、#00A870 的深端本来就贴着色域边界,加不加码输出完全一致。只有主色不饱和时才生效,而那时它会把用户选的低调灰蓝悄悄变得更饱和(#122539 vs #152536)——有人选灰蓝就是为了要那份克制。多一个参数,换一个几乎看不见且方向可疑的效果,不划算。
HCT 的色相很稳,从浅到深保持同一个 H,蓝色不会像在 CIELab 里那样变亮时发紫。
否掉的方案:暗部偏冷、高光偏暖的色相微旋(4–7 度,Material 和不少手工色板会做)。实测:
#003EAC 对 #003CB3,十六进制差几个数,视网膜上差零。#52000D 变成 #510015,深红偏紫看着像酒渍,而大多数人期待的”深红”是偏黑棕。一条固定的旋转规则在不同色相上表现相反。要做对,得给每个色相区间单独调方向和幅度——十几个魔法数字,没有客观标准能验证它们对不对。而三条验收标准里没有一条与色相偏移有关。砍掉。
12 18.1 26.9 36.3 46.2 56.4 66.5 76 85.1 93
ΔL*: 6.1 8.8 9.4 9.9 10.2 10.1 9.5 9.1 7.9
序号越大越浅,与 TDesign 深色阶约定一致。用的是和浅色台阶同一条步长曲线,从暗端起铺,再压缩到 12–93 区间:最小的一步落在最暗处,往上逐渐拉开,顶端再回落。
官方深色色板的色值没能检索到,所以这一条没有像浅色那样得到外部验证,只是让它和浅色共用同一个形状,而不是另拍一组数。
否掉的方案:把浅色阶反过来用。反转只能换方向,换不了形状——钟形序列倒过来还是钟形,最小的那一步(4.0)仍然留在浅色端。而深色模式最需要密集台阶的地方在暗端:页面底、卡片底、悬浮层要能分层。反转后最深一阶只到 L*≈19,深色背景只有一个可用色;同时近白端三阶挤在一起(步长 5.1 和 2.6),三个几乎一样的色浪费三个名额。
否掉的方案:反转 + 整体提亮。抛物线形补偿能解决主色在暗底上发闷的问题,解决不了台阶形状的问题——上面那两条毛病一条不少。
值得说明的是:「反转 + 重新分配步长」和「独立台阶」是同一个东西。一旦重新分配步长,那十个亮度值就全是新的,”反转”只剩下一个方向约定。
至于”提亮”,被这套台阶自动吸收了:同样是第 7 阶,浅色模式 L*=40,深色模式 L*=58.5。不需要额外的提亮变换。
界面上大部分面积不是主色,是灰:页面底、卡片底、分隔线、次要文字、禁用态、表格斑马纹、输入框边框。”关联”的意思是这套灰不是纯中性灰,而是往主色的方向偏了一点。
做法:主色色相不变,彩度统一压到 4,按 L* 铺 14 阶。
否掉的方案:RGB 染色(纯灰掺入 8%–12% 主色)。这是 TDesign 官方口径,但直接实现会失控:混色按 RGB 分量做,深灰本身接近黑,掺进去的主色分量占比相对更大,于是彩度从浅端的 5 一路爬到深端的 16。中段的 #DFE6F2、#D3DAE7 已经不是”带蓝的灰”而是”浅蓝”;绿色更极端,#CEDDD8 读起来是薄荷绿,拿去做表格斑马纹会和主色抢戏。
为什么彩度是 4 而不是 7 或 10:同一个彩度值在不同色相上的显眼程度差好几倍。蓝色到 C=10 还稳稳读作灰,绿色到 C=7 已经开始泛青、C=10 明显发绿——人眼对黄绿区的色度更敏感。要在所有色相上都调对,得来一张按色相校准的彩度表,又是一堆没法验证的魔法数字。C=4 是全色相通用的安全值。
诚实地说:这个效果做出来几乎看不见。把 C=4 版和纯灰版的界面并排放,要来回扫视才能确认差别。它的价值不在视觉,在可解释性——你能说”中性色阶取自主色色相”,并且这句话是真的、可验证的、有代码支撑的,同时不承担任何抢戏风险。
灰色输入的坑:纯灰在 CAM16 里色相是未定义的,atan2(0, 0) 返回 0,也就是红。不做判断,#111111 会生成一套带粉调的色阶。所以输入彩度低于 3 时整套色阶退回纯灰。
展示型,不是论证型。色板本身说话,不摆图表——ΔE 曲线、对比度矩阵这类东西留在测试里。
纯色块,无常驻文字。十阶无缝拼接成一条打样条,段与段之间零间距,转折直接可读——有没有断层看这一条就够了。悬停才浮现色值,点击复制。
页面不换肤,配色固定,只有底色跟着明暗开关走。理由:如果整页用生成的色阶上色,用户输一个极端色时整个页面会跟着变得难看甚至难读,那看起来会像是界面坏了,而不是”色阶在如实反映输入”。
页面上唯一的彩色来自生成的色阶。任何额外的品牌色都会和色票抢戏。
主色落位写在色板下方的小字里——这是整套设计里最有想法的一个决策,纯色块会把它完全藏起来,所以单独说出来。
约 8% 的男性有红绿色觉障碍。一套主要靠色相拉开的色板,在他们眼里会塌成一团。
本项目的色阶靠明度台阶拉开,色相全程锁死——所以模拟之后十阶的层次基本不受影响。这个功能就是把这句话变成可以当场验证的事,而不是一句自我宣称。
实现用 Machado, Oliveira & Fernandes (2009) 的变换矩阵,severity = 1.0,全色盲走 Rec.709 亮度灰度。矩阵必须作用在线性光上;直接乘 sRGB 数值是常见错误,会让结果整体偏暗。
模拟是纯展示层变换,不影响复制出来的色值——点击拿到的永远是真实色值。测试断言了四种模拟下十阶依然两两不同。
L*=50、平均环境。改环境需要重建 ViewingConditions。