3.1 把颜色分成了五种角色,但角色只是名字。美术给的色卡上写的是一串 hex(#f59e0b),程序里要的是一个能引用的名字(accent)。从色卡到可引用变量,中间要补的是分层:不是把 hex 抄进代码,而是建立 primitive → semantic → component 三层引用。本站有一篇 美术方向与风格圣经提纲
,那是 73 行的口号式提纲,回答「我们要什么感觉」;本节回答更靠后的一步——「感觉」怎么落成一张能被 lint 校验的 token 表,以及改一个色卡时到底有多少地方跟着变。
三层 token:色卡、语义、组件
- primitive(原始色卡):
slate-950、amber-500这类名字加一个 sRGB 三元组。它描述「是什么颜色」,不带用途。 - semantic(语义变量):
bg、surface、accent这类名字,指向一个 primitive。它描述「用在哪」,是唯一被 UI 引用的层。 - component(组件变量):
button-bg、coin-icon这类名字,指向一个 semantic。它描述「具体到哪个部件」。
规矩只有一条:component 只准引用 semantic,semantic 只准引用 primitive,谁也不准直接写 hex。这条规矩的价值在「重染」时兑现——改一个 primitive,所有引用它的 component 自动跟着变,一处都不用搜替换。
把三层写成一张表(D 层)
下面这段是 D 层配置,即三层 token 表本身。semantic 层必须与 visual_spec.json 的 tokens 逐位一致——这是「同一份规范」的落点。
{
"version": 1,
"project": "stardust-inn",
"note": "三层 token 表:primitive(原始色卡)→ semantic(语义变量)→ component(组件变量)。semantic 层必须与 visual_spec.json 的 tokens 逐位一致;component 层只引用 semantic,不直接写颜色。",
"layers": ["primitive", "semantic", "component"],
"primitives": {
"slate-950": [31, 41, 51],
"slate-800": [45, 58, 71],
"slate-400": [148, 163, 184],
"slate-50": [236, 239, 241],
"amber-500": [245, 158, 11],
"green-500": [34, 197, 94],
"orange-600": [234, 88, 12],
"red-500": [239, 68, 68]
},
"semantic": {
"bg": "slate-950",
"surface": "slate-800",
"ink": "slate-50",
"muted": "slate-400",
"accent": "amber-500",
"ok": "green-500",
"warn": "orange-600",
"danger": "red-500"
},
"component": {
"button-bg": "accent",
"button-ink": "ink",
"panel-bg": "surface",
"panel-ink": "ink",
"alert-bg": "danger",
"alert-ink": "ink",
"hint-ink": "muted",
"scene-bg": "bg",
"coin-icon": "accent",
"heart-icon": "danger"
}
}
引用完整性、同源与重染传播(S 层)
下面这段是 S 层脚本,解析 component → semantic → primitive 三跳,检查有没有悬空引用,并做一次「重染」推演:把 amber-500 换成 3.1 里那个天蓝,看多少个 component 会跟着变。
#!/usr/bin/env python3
# file: sim/sp03_tokens.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp03_tokens.py
"""色彩 token 化:把色卡(primitive)变成可引用变量(semantic / component)。
读 config/visual_spec.json(只读)与 config/sp03_tokens.json,做四笔账:
1. 引用完整性:component → semantic → primitive 每一跳是否都能解析;
2. 分层计数:每层多少个 token;
3. 同源校验:semantic 层解析出的 8 个色值是否与 visual_spec.json 逐位一致;
4. 重染传播:改一个 primitive,多少个 component 会跟着变。
产出 out/sp03_tokens.json。纯标准库;判定只用 sRGB 8-bit 整数三元组,无浮点。
"""
from __future__ import annotations
import json
import os
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/visual_spec.json")
TOKENS = os.path.join(ROOT, "config/sp03_tokens.json")
OUT = os.path.join(ROOT, "out")
def load(p):
with open(p, encoding="utf-8") as f:
return json.load(f)
def resolve_semantic(name, semantic, primitives):
ref = semantic.get(name)
if ref is None:
return None, f"semantic 缺 {name}"
if ref not in primitives:
return None, f"semantic.{name} 指向不存在的 primitive: {ref}"
return list(primitives[ref]), None
def resolve_component(name, component, semantic, primitives):
ref = component.get(name)
if ref is None:
return None, f"component 缺 {name}"
rgb, err = resolve_semantic(ref, semantic, primitives)
if err:
return None, f"component.{name} -> {err}"
return rgb, None
def main() -> int:
spec = load(SPEC)
cfg = load(TOKENS)
prim, sem, comp = cfg["primitives"], cfg["semantic"], cfg["component"]
dangling, resolved, sem_resolved = [], {}, {}
for name in comp:
rgb, err = resolve_component(name, comp, sem, prim)
if err:
dangling.append(err)
else:
resolved[name] = rgb
for name in sem:
rgb, err = resolve_semantic(name, sem, prim)
if err:
dangling.append(err)
else:
sem_resolved[name] = rgb
spec_tokens = {k: list(v) for k, v in spec["tokens"].items()}
same_origin = sem_resolved == spec_tokens
drift = {k: {"token": sem_resolved.get(k), "spec": spec_tokens.get(k)}
for k in spec_tokens if sem_resolved.get(k) != spec_tokens.get(k)}
retint_from, retint_to = "amber-500", [56, 189, 248]
affected = sorted(name for name, ref in comp.items() if sem.get(ref) == retint_from)
summary = {
"project": spec["project"],
"layers": cfg["layers"],
"counts": {"primitive": len(prim), "semantic": len(sem), "component": len(comp)},
"dangling": dangling,
"resolvedComponent": resolved,
"sameOriginAsSpec": same_origin,
"drift": drift,
"retint": {"from": retint_from, "to": retint_to,
"affectedComponents": affected, "affectedCount": len(affected)},
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "sp03_tokens.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"layers primitive={len(prim)} semantic={len(sem)} component={len(comp)}")
print(f"dangling={len(dangling)} sameOriginAsSpec={same_origin} drift={len(drift)}")
print(f"retint {retint_from} -> {retint_to}: {len(affected)} 个 component 跟着变 {affected}")
print("wrote out/sp03_tokens.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
layers primitive=8 semantic=8 component=10
dangling=0 sameOriginAsSpec=True drift=0
retint amber-500 -> [56, 189, 248]: 2 个 component 跟着变 ['button-bg', 'coin-icon']
wrote out/sp03_tokens.json
三行输出。第一行是分层计数:8 个 primitive、8 个 semantic、10 个 component。第二行里 dangling=0 说明三跳都能解析;sameOriginAsSpec=True 说明 semantic 层解析出的 8 个色值与 visual_spec.json 的三元组逐位一致(整数类,不是「看起来一样」),drift=0 是它的反面计数。第三行是重染推演:把 amber-500 换成天蓝 [56,189,248],只有 2 个 component(button-bg、coin-icon)跟着变——因为只有它们引用了 accent。这就是分层的作用:一次改色卡,影响面是可算的,而不是「全局搜 #f59e0b 看运气」。
把解析与 lint 写成 TS 内核(P 层)
下面这段是 P 层 TS 内核,做同一套解析与悬空引用 lint。它不重算颜色(色值就是整数三元组,直接比对),所以本节只有整数类断言、没有容差项。
// file: core/src/sp03/tokens.ts
// tier: P
// verify: node --test 'core/test/sp03_*.test.ts'
// 三层 token 的 TS 侧解析器与引用完整性 lint。色值真源是 sRGB 8-bit 整数三元组,
// 解析结果必须是整数三元组(逐位一致),不引入任何浮点派生量。
export type TokenLayers = {
primitives: Record<string, number[]>;
semantic: Record<string, string>;
component: Record<string, string>;
};
/** 解析 semantic → primitive,返回整数三元组副本(找不到返回 null)。 */
export function resolveSemantic(name: string, l: TokenLayers): number[] | null {
const ref = l.semantic[name];
if (ref === undefined) return null;
const rgb = l.primitives[ref];
return rgb === undefined ? null : rgb.slice();
}
/** 解析 component → semantic → primitive。 */
export function resolveComponent(name: string, l: TokenLayers): number[] | null {
const ref = l.component[name];
if (ref === undefined) return null;
return resolveSemantic(ref, l);
}
/** 引用完整性 lint:返回所有悬空引用(semantic 指向缺失 primitive / component 指向缺失 semantic)。 */
export function danglingRefs(l: TokenLayers): string[] {
const out: string[] = [];
for (const [name, ref] of Object.entries(l.semantic)) {
if (l.primitives[ref] === undefined) out.push(`semantic.${name}->${ref}`);
}
for (const [name, ref] of Object.entries(l.component)) {
if (l.semantic[ref] === undefined) out.push(`component.${name}->${ref}`);
}
return out;
}
/** 同源校验:semantic 层解析出的每个 token 是否与 spec 的 tokens 逐位一致。 */
export function sameOrigin(l: TokenLayers, specTokens: Record<string, number[]>): boolean {
const names = Object.keys(specTokens);
if (names.length !== Object.keys(l.semantic).length) return false;
for (const n of names) {
const rgb = resolveSemantic(n, l);
if (rgb === null) return false;
const s = specTokens[n];
if (rgb[0] !== s[0] || rgb[1] !== s[1] || rgb[2] !== s[2]) return false;
}
return true;
}
/** 重染传播:改一个 primitive,哪些 component 会跟着变。 */
export function retintAffected(l: TokenLayers, primitive: string): string[] {
const out: string[] = [];
for (const [name, ref] of Object.entries(l.component)) {
if (l.semantic[ref] === primitive) out.push(name);
}
return out.sort();
}
配套测试读 out/sp03_tokens.json 做跨语言对照:
// file: core/test/sp03_tokens.test.ts
// tier: P
// verify: node --test 'core/test/sp03_*.test.ts'
// 跨语言对照:读 out/sp03_tokens.json,断言 token 解析结果逐位一致(整数三元组),
// 并验证悬空引用能被 lint 检出。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
resolveComponent,
resolveSemantic,
danglingRefs,
sameOrigin,
retintAffected,
} from "../src/sp03/tokens.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp03_tokens.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp03_tokens.json"), "utf8"));
const L = { primitives: cfg.primitives, semantic: cfg.semantic, component: cfg.component };
test("component 解析结果与 Python 逐位一致", () => {
const entries = Object.entries(py.resolvedComponent) as [string, number[]][];
assert.equal(entries.length, py.counts.component);
for (const [name, rgb] of entries) assert.deepEqual(resolveComponent(name, L), rgb, name);
});
test("引用完整性与同源校验", () => {
assert.deepEqual(danglingRefs(L), []);
assert.equal(py.dangling.length, 0);
assert.equal(sameOrigin(L, spec.tokens), true);
assert.equal(py.sameOriginAsSpec, true);
assert.deepEqual(Object.keys(py.drift), []);
});
test("重染传播:受影响 component 与 Python 一致", () => {
assert.deepEqual(retintAffected(L, "amber-500"), py.retint.affectedComponents);
});
test("负例:悬空引用必须被检出,解析返回 null", () => {
const broken = { primitives: { a: [1, 2, 3] }, semantic: { s: "missing" }, component: { c: "s" } };
assert.ok(danglingRefs(broken).length >= 1);
assert.equal(resolveComponent("c", broken), null);
assert.equal(resolveSemantic("s", broken), null);
});
✔ component 解析结果与 Python 逐位一致
✔ 引用完整性与同源校验
✔ 重染传播:受影响 component 与 Python 一致
✔ 负例:悬空引用必须被检出,解析返回 null
ℹ tests 4 / pass 4 / fail 0
跨语言摘要:三层解析结果、分层计数、重染受影响集合全是整数类,逐位一致;本节没有色彩派生量,所以没有容差项。一句话收束:token 化的收益不是「命名好看」,而是引用可校验、影响面可算——它是下一节跨场景一致性的前提。
阅读导航:上一节:3.1 主色/辅色/强调色与明度梯度 · 下一节:3.3 跨场景色彩一致性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。