3.2 色彩 token 化:从色卡到可引用变量

色卡上的 hex 要变成代码里能引用的名字,中间要补一层分层:primitive → semantic → component。本节用《星屑旅店》的三层 token 表做引用完整性 lint、与 visual_spec.json 的同源校验,并推演改一个色卡会影响几个组件。

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 跨场景色彩一致性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则