3.1 主色/辅色/强调色与明度梯度

把「主色、辅色、强调色」从美术口头词翻译成可复算的量:用《星屑旅店》的 8 个 token 算中性明度梯度的单调性与均匀度、5 对对比度的下限,并实测琥珀与天蓝的对比度同为 69 而 OKLab 距离 0.30,说明对比度合格不等于色相有区分度。

《星屑旅店》的美术评审上最常吵的一句话是「这个橙色不够跳」。但「够跳」到底是什么?本节把「主色、辅色、强调色」这三个美术口头词,翻译成一组能被脚本读进来复算的量:相对亮度、WCAG 对比度、OKLab 距离。分工先写清:本站的 数据可视化无障碍配色 与 视觉编码与感知 讲的是图表口径的色盲与编码,本节讲游戏 UI 调色板口径——同一个「对比度」概念,服务的是按钮和图标而不是折线图;色彩空间与 HDR 的完整原理在 色彩管理与 HDR 管线 ,本节只用它的一条结论(对比度只由相对亮度决定),不重推原理。第 3.2 节把这些角色变成可引用变量,第 3.3 节管它们跨场景不变。

三种颜色角色,和一条中性梯度

颜色在界面里不是「好看的颜色」,是「承担职责的颜色」:

  • 主色(primary):品牌识别色,出现在 logo、主按钮、关键强调上。一屏只应有一个主色。
  • 辅色(secondary):与主色成对出现的第二识别色,承担成功态与次级强调。
  • 强调色(accent):短时抓注意力的色,用于限时提示与进度告警,出现面积要小。
  • 危险色(danger):破坏性操作与错误态,语义强于审美。
  • 中性梯度(neutral):从背景到前景的一串灰,承担信息层级——它才是占面积最大的一组。

《星屑旅店》的 8 个语义 token 正好落进这五类角色。下面这张 D 层表只声明「谁扮演什么角色」,色值真源仍在 config/visual_spec.json,本表不新增任何颜色。

{
  "version": 1,
  "project": "stardust-inn",
  "note": "角色分工表:把 visual_spec.json 的 8 个语义 token 分配到主色 / 辅色 / 强调色 / 危险色 / 中性梯度五类角色。色值真源仍是 visual_spec.json,本表只声明【谁扮演什么角色】,不新增任何颜色。",
  "roles": {
    "primary": { "cn": "主色", "tokens": ["accent"], "desc": "品牌识别色:logo、主按钮、关键强调" },
    "secondary": { "cn": "辅色", "tokens": ["ok"], "desc": "第二识别色:成功态与次级强调" },
    "accent": { "cn": "强调色", "tokens": ["warn"], "desc": "短时高注意力:限时提示与进度告警" },
    "danger": { "cn": "危险色", "tokens": ["danger"], "desc": "破坏性操作与错误态" },
    "neutral": { "cn": "中性梯度", "tokens": ["bg", "surface", "muted", "ink"], "desc": "背景到前景的明度梯度,承担信息层级" }
  },
  "neutralRamp": ["bg", "surface", "muted", "ink"],
  "roleSeparations": [
    { "a": "accent", "b": "warn", "minOklabDistance": 0.15 },
    { "a": "accent", "b": "ok", "minOklabDistance": 0.15 },
    { "a": "accent", "b": "danger", "minOklabDistance": 0.15 },
    { "a": "ok", "b": "warn", "minOklabDistance": 0.15 }
  ],
  "candidateAccent": { "name": "sky-400", "rgb": [56, 189, 248], "note": "门禁 ⑥ 用的候选替换色:与琥珀 accent 对比度相同、色相不同" }
}

中性梯度是本节的重点。neutralRamp 声明了从最暗到最亮的四个 token:bg → surface → muted → ink。美术上叫它「明度梯度」,但「梯度」两个字里藏着两个必须分开问的问题:它单调吗(越往后越亮)?它均匀吗(相邻两级差距一致)?前者是可用性问题——梯度乱序会让层级读反;后者是观感问题——跳变太大就会出现「断层」。

明度梯度、对比度下限与色相无关性(S 层)

下面这段是 S 层脚本,读 visual_spec.json 与 sp03_roles.json,把上面两个问题连同对比度下限、色相无关性一起算出来,落 out/sp03_roles.json。

#!/usr/bin/env python3
# file: sim/sp03_roles.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp03_roles.py
"""主色/辅色/强调色与明度梯度:读 config/visual_spec.json(色值真源,只读)与
config/sp03_roles.json(角色分工),做四笔账:中性梯度是否单调且均匀、5 对对比度是否过
下限、琥珀与天蓝的色相无关性、主/辅/强调/危险四色的 OKLab 分离度。
产出 out/sp03_roles.json。纯标准库;整数类逐位一致,色彩派生量按 spec.tolerance 容差一致。
"""

from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
from consume import luminance, contrast10, oklab, oklab_distance  # noqa: E402

SPEC = os.path.join(ROOT, "config/visual_spec.json")
ROLES = os.path.join(ROOT, "config/sp03_roles.json")
OUT = os.path.join(ROOT, "out")


def load(p):
    with open(p, encoding="utf-8") as f:
        return json.load(f)


def l10000(rgb):
    """OKLab L 放大到 1e4 取整:整数类,供跨语言逐位断言。"""
    return round(oklab(rgb)[0] * 10000)


def main() -> int:
    spec, roles = load(SPEC), load(ROLES)
    tokens, floor10 = spec["tokens"], spec["contrastFloor10"]
    lum = {k: luminance(v) for k, v in tokens.items()}
    L = {k: oklab(v)[0] for k, v in tokens.items()}

    ramp = roles["neutralRamp"]
    ramp_l = [l10000(tokens[k]) for k in ramp]
    steps = [ramp_l[i + 1] - ramp_l[i] for i in range(len(ramp_l) - 1)]

    pairs = []
    for a, b in spec["contrastPairs"]:
        c = contrast10(tokens[a], tokens[b])
        pairs.append({"pair": f"{a}/{b}", "contrast10": c, "floor10": floor10, "pass": c >= floor10})

    amber, sky = tokens["accent"], roles["candidateAccent"]["rgb"]
    hue = {
        "amber": {"rgb": amber, "luminance": round(lum["accent"], 13),
                  "contrast10_vs_bg": contrast10(amber, tokens["bg"])},
        "sky": {"rgb": sky, "luminance": round(luminance(sky), 13),
                "contrast10_vs_bg": contrast10(sky, tokens["bg"])},
    }
    hue["luminanceDelta"] = round(abs(lum["accent"] - luminance(sky)), 13)
    hue["oklabDistance"] = round(oklab_distance(amber, sky), 12)
    hue["contrastSame"] = hue["amber"]["contrast10_vs_bg"] == hue["sky"]["contrast10_vs_bg"]

    seps = []
    for r in roles["roleSeparations"]:
        d = oklab_distance(tokens[r["a"]], tokens[r["b"]])
        seps.append({"pair": f"{r['a']}/{r['b']}", "d": round(d, 12),
                     "min": r["minOklabDistance"], "pass": d >= r["minOklabDistance"]})

    summary = {
        "project": spec["project"], "tokenCount": len(tokens), "contrastFloor10": floor10,
        "luminance": {k: round(v, 13) for k, v in lum.items()},
        "oklabL10000": {k: l10000(v) for k, v in tokens.items()},
        "neutralRamp": ramp, "rampL10000": ramp_l, "rampSteps": steps,
        "rampMonotonic": all(s > 0 for s in steps),
        "rampStepRatio100": (max(steps) * 100) // min(steps),
        "minRoundingMargin": round(min(abs(L[k] * 10000 - l10000(tokens[k])) for k in tokens), 6),
        "contrast": pairs, "failing": [p["pair"] for p in pairs if not p["pass"]],
        "hueIndependence": hue, "roleSeparation": seps,
        "roleSeparationFailing": [s["pair"] for s in seps if not s["pass"]],
    }

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "sp03_roles.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"tokens={len(tokens)} contrastFloor10={floor10} failing={summary['failing']}")
    print(f"ramp={ramp} L10000={ramp_l} steps={steps} monotonic={summary['rampMonotonic']} ratio100={summary['rampStepRatio100']}")
    print(f"amber lum={hue['amber']['luminance']} sky lum={hue['sky']['luminance']} delta={hue['luminanceDelta']}")
    print(f"amber/bg cr10={hue['amber']['contrast10_vs_bg']} sky/bg cr10={hue['sky']['contrast10_vs_bg']} oklabDist={hue['oklabDistance']}")
    print(f"roleSeparation failing={summary['roleSeparationFailing']}")
    print("wrote out/sp03_roles.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())
tokens=8 contrastFloor10=45 failing=['danger/surface']
ramp=['bg', 'surface', 'muted', 'ink'] L10000=[2760, 3424, 7107, 9504] steps=[664, 3683, 2397] monotonic=True ratio100=554
amber lum=0.4389035899481 sky lum=0.4401325598899 delta=0.0012289699418
amber/bg cr10=69 sky/bg cr10=69 oklabDist=0.30055049096
roleSeparation failing=[]
wrote out/sp03_roles.json

三行输出各回答一件事。第一行:梯度单调(monotonic=True),但极不均匀——OKLab L 放大 1e4 后四级是 2760 / 3424 / 7107 / 9504,相邻步长 664 / 3683 / 2397,最大步长是最小步长的 5.54 倍(ratio100=554,整数地板除)。也就是说 surface → muted 那一跳比 bg → surface 陡五倍多,视觉上会读到一条「断层」。修法是重排中性 token 的 L 值,让四级尽量等距——这件事交给第 4 章的 OKLab 空间做。

第二行:5 对对比度里有一对不合格。danger/surface 的对比度 ×10 是 31,低于声明下限 45,被记进 failing。这是真实的规格违例,故意留在基线里:它证明这套门禁真的会拦下东西,而不是永远全绿。修法不是调低下限,而是换一个更暗的危险色底或更亮的文字色。

第三行是本节要记牢的一条实测结论。把主色 accent 从琥珀 [245,158,11] 换成候选天蓝 [56,189,248]:两者相对亮度分别是 0.4389035899481 与 0.4401325598899,差 0.0012;与 bg 的对比度都是 69。这不是 bug——WCAG 对比度只由相对亮度决定,与色相无关。所以「对比度合格」既不等于「配色好看」,也不等于「色相有区分度」。要度量色相差异,得换一把尺子:OKLab 距离。琥珀与天蓝的 OKLab 距离是 0.30055049096——对比度看不出差别,色相距离一眼看出。这也是 roleSeparations 那条 0.15 下限的依据:四个角色色两两距离都过线(failing=[]),说明它们真的能被分开。

把校验写成 TS 内核(P 层)

上面这些判定必须在管线里被复算一遍,而不是只在 Python 里跑一次。下面这段是 P 层 TS 内核,复用 core/src/sp00/spec.ts 的 oklab 与 oklabDistance,不重写转换常数。

// file: core/src/sp03/roles.ts
// tier: P
// verify: node --test 'core/test/sp03_*.test.ts'
// 角色分工与明度梯度的 TS 侧校验器。复用 sp00/spec.ts 的 oklab / oklabDistance,
// 不重写任何转换常数。只允许可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { oklab, oklabDistance } from "../sp00/spec.ts";

/** OKLab L 放大到 1e4 取整:整数类,与 Python 的 round(L*10000) 对照。 */
export function oklabL10000(rgb: number[]): number {
  return Math.round(oklab(rgb)[0] * 10000);
}

/** 明度梯度:L10000 必须严格递增。 */
export function rampMonotonic(l10000: number[]): boolean {
  for (let i = 1; i < l10000.length; i += 1) {
    if (l10000[i] - l10000[i - 1] <= 0) return false;
  }
  return true;
}

/** 梯度最大步长 / 最小步长 ×100:整数地板除,逐位一致。 */
export function rampStepRatio100(l10000: number[]): number {
  const steps: number[] = [];
  for (let i = 1; i < l10000.length; i += 1) steps.push(l10000[i] - l10000[i - 1]);
  return Math.floor((Math.max(...steps) * 100) / Math.min(...steps));
}

/** 角色分工校验:每个 token 恰好被分配一次,且不含未知 token。 */
export function validateRoles(roles: Record<string, string[]>, tokenNames: string[]): string[] {
  const errs: string[] = [];
  const seen = new Map<string, number>();
  for (const [role, list] of Object.entries(roles)) {
    for (const t of list) {
      if (!tokenNames.includes(t)) errs.push(`未知 token: ${role}.${t}`);
      seen.set(t, (seen.get(t) ?? 0) + 1);
    }
  }
  for (const t of tokenNames) {
    const n = seen.get(t) ?? 0;
    if (n === 0) errs.push(`未分配角色: ${t}`);
    if (n > 1) errs.push(`重复分配: ${t}`);
  }
  return errs;
}

/** 角色分离度:两两 OKLab 距离是否 ≥ 声明下限;返回越界的 pair。 */
export function separationViolations(
  pairs: { a: string; b: string; minOklabDistance: number }[],
  tokens: Record<string, number[]>,
): string[] {
  const bad: string[] = [];
  for (const p of pairs) {
    if (oklabDistance(tokens[p.a], tokens[p.b]) < p.minOklabDistance) bad.push(`${p.a}/${p.b}`);
  }
  return bad;
}

配套测试读 Python 落盘的 out/sp03_roles.json 做跨语言对照(不是同一份源码):

// file: core/test/sp03_roles.test.ts
// tier: P
// verify: node --test 'core/test/sp03_*.test.ts'
// 跨语言对照:读 sim/sp03_roles.py 落盘的 out/sp03_roles.json(Python 真产物),
// 断言「整数类逐位一致、色彩派生量容差内一致」。
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 { oklabDistance } from "../src/sp00/spec.ts";
import {
  oklabL10000,
  rampMonotonic,
  rampStepRatio100,
  validateRoles,
  separationViolations,
} from "../src/sp03/roles.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const roles = JSON.parse(readFileSync(resolve(ROOT, "config/sp03_roles.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp03_roles.json"), "utf8"));
const tokens: Record<string, number[]> = spec.tokens;

test("明度梯度与步长比:整数类逐位一致", () => {
  const mine = py.neutralRamp.map((k: string) => oklabL10000(tokens[k]));
  assert.deepEqual(mine, py.rampL10000);
  assert.equal(rampMonotonic(mine), true);
  assert.equal(rampStepRatio100(py.rampL10000), py.rampStepRatio100);
});

test("角色分工:每个 token 恰好一次,无未知 token", () => {
  const flat: Record<string, string[]> = {};
  for (const [role, r] of Object.entries(roles.roles) as [string, { tokens: string[] }][]) {
    flat[role] = r.tokens;
  }
  assert.deepEqual(validateRoles(flat, Object.keys(tokens)), []);
});

test("分离度与色相无关性:容差内一致", () => {
  let maxDelta = 0;
  for (const s of py.roleSeparation) {
    const [a, b] = s.pair.split("/");
    maxDelta = Math.max(maxDelta, Math.abs(oklabDistance(tokens[a], tokens[b]) - s.d));
  }
  assert.ok(maxDelta <= spec.tolerance.okdist, `最大偏差 ${maxDelta}`);
  assert.deepEqual(separationViolations(roles.roleSeparations, tokens), py.roleSeparationFailing);
  assert.equal(py.hueIndependence.contrastSame, true);
});

test("负例:非单调梯度判 false,重复分配报错", () => {
  assert.equal(rampMonotonic([100, 200, 150]), false);
  assert.ok(validateRoles({ a: ["bg", "bg"] }, ["bg", "surface"]).length >= 1);
});
✔ 明度梯度与步长比:整数类逐位一致
✔ 角色分工:每个 token 恰好一次,无未知 token
✔ 分离度与色相无关性:容差内一致
✔ 负例:非单调梯度判 false,重复分配报错
ℹ tests 4 / pass 4 / fail 0

跨语言摘要:整数类(rampL10000、rampStepRatio100、对比度 ×10、失败集合)逐位一致;色彩派生量(OKLab 距离、相对亮度)在声明容差 1e-9 内一致,实测最大偏差 4.9e-13。把 L×10000 当整数断言是有前提的——脚本同时算了 minRoundingMargin,即所有 token 的 L×10000 距最近整数的最小距离,实测 0.1305,比跨语言偏差大十二个数量级,所以取整不会翻面。

一句话收束本节:主色、辅色、强调色是职责,明度梯度是层级,对比度管可读,OKLab 距离管可分辨。四者各管一段,谁也不能替谁。下一节把这张角色表变成可引用的变量。

阅读导航:上一节:2.3 参考板与视觉调研的结构化 · 下一节:3.2 色彩 token 化:从色卡到可引用变量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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