AI 生成色彩系统:从品牌主色到完整语义色板的自动化
一、一万种 CTA 蓝和一套活不过迭代的色彩规范
产品给了你一个主色#3B82F6(Tailwind 的 blue-500),然后说"后面你看着办"。接下来你要回答 15 个问题:hover 态是深 2% 还是 5%?disabled 态是降 opacity 还是混灰色?danger 色是纯红还是偏品红?warning 色和主色的对比度够不够 3:1?这些问题的答案散落在设计师的 Figma 堆里、Material Design 的文档里、还有你上次 Code Review 时被人指出的"这个灰色层级没用对"。
手工推导一个设计系统级别的色彩体系不需要多高的技术门槛,但它需要可怕的一致性。40 个 Color Token、15 种语义场景、亮暗双主题——任何一个 Token 的色相偏移 1 度都会在暗色模式下产生连锁反应,因为暗色模式的感知亮度映射是非线性的。
AI 在色彩生成上的优势恰恰在于"保持全局一致性"。色彩空间是一个三维连续域,人类每次只能盯着一种颜色手动调,而 AI 可以同时将所有 Token 约束在同一个色彩空间中,确保每个 Token 的色相、饱和度、明度变化遵循统一的数学模型。
二、色彩系统的 Token 层级与 AI 生成链路
设计系统的色彩 Token 可以按抽象层级分为四层:
- 原始色值(Primitive Tokens):HEX 或 HSL 值,如
#3B82F6,是最基础的原子。 - 色阶(Palette):从原始色值推导出的 10-13 级明度阶梯,如 blue-50 到 blue-950。
- 语义 Token(Semantic Tokens):将色阶映射到语义角色,如
color-bg-primary、color-text-danger。 - 组件 Token(Component Tokens):将语义 Token 绑定到具体组件,如
btn-primary-bg。
AI 介入的关键节点是第 2 层(色阶生成)和第 3 层(语义映射)。这两个步骤需要处理"可感知均匀"的明度阶梯——人眼对暗色的明度变化比对亮色敏感得多,所以 dark mode 下的色阶不能是线性插值。
flowchart TD A["输入:品牌主色 HEX"] --> B["色彩空间转换<br/>HEX → OKLCH"] B --> C["色阶生成<br/>L 通道等距采样"] C --> D["亮色模式色阶<br/>L: 0.98 → 0.30"] C --> E["暗色模式色阶<br/>L: 0.08 → 0.70"] D --> F["语义角色分配"] E --> F F --> G["WCAG 对比度校验"] G --> H{"对比度 ≥ AA?"} H -->|"✅"| I["输出完整 Token JSON"] H -->|"❌"| J["微调 L 值 & 重试"] J --> G这里选择 OKLCH 色彩空间而非 HSL 或 RGB,因为它有"均匀感知亮度"特性。在 HSL 空间中,hsl(240, 100%, 50%)和hsl(240, 100%, 30%)的"暗了 20%"感受并不等于hsl(60, 100%, 50%)到hsl(60, 100%, 30%)的"暗了 20%"。而在 OKLCH 中,L 通道的一致性可以保证每降低 0.05 的 L 值,人眼感受到的亮度变化是均匀的——这让 AI 的等距采样变得有意义。
三、色彩 Token 自动生成引擎
/** * 色彩系统自动生成引擎 * * 输入:品牌主色 HEX + 配置参数 * 输出:完整的 Semantic Token JSON(亮色 + 暗色两套主题) * * 核心算法: * 1. HEX → OKLCH 转换(使用 culori 库) * 2. 基于 OKLCH 的 L 通道做感知均匀的色阶插值 * 3. 自动分配语义角色并校验 WCAG 对比度 */ import { toOklch, formatHex } from 'culori'; import { wcagContrast } from 'culori'; // === 类型定义 === // 语义角色枚举:描述 Token 的使用场景(背景/文字/边框/强调) type SemanticRole = | 'bg-primary' | 'bg-secondary' | 'bg-tertiary' | 'text-primary' | 'text-secondary' | 'text-tertiary' | 'border-default' | 'border-strong' | 'accent-primary' | 'accent-hover' | 'accent-active' | 'danger' | 'warning' | 'success' | 'info'; // 色彩 Token 结构 interface ColorToken { name: string; value: string; // HEX 格式输出 role: SemanticRole; contrastRatio?: number; } // === 第一步:HEX → OKLCH 转换 === function hexToOklch(hex: string): { l: number; c: number; h: number } { const oklchObj = toOklch(hex); return { l: oklchObj.l, // 亮度 0-1(0=黑,1=白) c: oklchObj.c, // 色度(饱和度,0=灰,0.4=高饱和) h: oklchObj.h ?? 0, // 色相角度 0-360 }; } // === 第二步:基于 L 通道的均匀色阶生成 === // 亮色模式:L 从 0.98(接近白)递减到 0.30(深色但仍可见色相) // 暗色模式:L 从 0.08(接近黑)递增到 0.70(亮色) function generatePalette( baseOklch: { l: number; c: number; h: number }, mode: 'light' | 'dark' ): string[] { const steps = 13; // 生成 50 → 950 共 13 级色阶(Tailwind 标准) const palette: string[] = []; // 亮色模式:L 从高到低(白→深色) // 暗色模式:L 从低到高(黑→浅色) const lRange = mode === 'light' ? [0.98, 0.30] // 亮色模式范围 : [0.08, 0.70]; // 暗色模式范围 for (let i = 0; i < steps; i++) { const t = i / (steps - 1); // 线性插值 L 值(OKLCH 下这就是感知均匀的) const l = lRange[0] + (lRange[1] - lRange[0]) * t; // 饱和度衰减曲线:浅色阶(高 L)调低饱和度避免"荧光感" // 用 ease-out 曲线让饱和度平滑过渡 const chromaCurve = mode === 'light' ? Math.pow(t, 0.7) // light mode:深色步调饱和度更快下降 : Math.pow(1 - t, 0.5); // dark mode:浅色步调降低饱和度 const c = baseOklch.c * chromaCurve; const h = baseOklch.h; // OKLCH → HEX 输出 const hex = formatHex({ mode: 'oklch', l, c, h }); palette.push(hex); } return palette; } // === 第三步:语义角色分配 === // 自动将色阶映射到语义 Token,并校验 WCAG AA 对比度 function assignSemanticTokens( lightPalette: string[], darkPalette: string[], neutralLight: string[], // 中性灰阶(亮色模式) neutralDark: string[] // 中性灰阶(暗色模式) ): { light: ColorToken[]; dark: ColorToken[] } { const lightTokens: ColorToken[] = []; const darkTokens: ColorToken[] = []; // 亮色模式语义映射表 // 索引 0 = 最浅(50),索引 12 = 最深(950) const lightMapping: [string, SemanticRole, number][] = [ ['color-bg-primary', 'bg-primary', 0], // 极浅色背景 ≈ white ['color-bg-secondary', 'bg-secondary', 9], // 浅灰背景(用中性灰阶) ['color-bg-tertiary', 'bg-tertiary', 8], ['color-text-primary', 'text-primary', 11], // 最深色文字(保证 ≥ 7:1 对比度) ['color-text-secondary', 'text-secondary', 9], ['color-text-tertiary', 'text-tertiary', 7], ['color-border-default', 'border-default', 3], ['color-border-strong', 'border-strong', 5], ['color-accent-primary', 'accent-primary', 5], // 主色中等深度 ['color-accent-hover', 'accent-hover', 6], // hover 稍深 ['color-accent-active', 'accent-active', 7], // active 更深 ]; for (const [name, role, idx] of lightMapping) { const isNeutral = role.startsWith('bg') && idx >= 8; const palette = isNeutral ? neutralLight : lightPalette; const value = palette[Math.min(idx, palette.length - 1)]; // 背景和文字 Token 需验证对比度 if (role.startsWith('text')) { const bgValue = lightPalette[0]; // 假设文字在白背景上 const ratio = wcagContrast(value, bgValue); lightTokens.push({ name, value, role, contrastRatio: ratio }); } else { lightTokens.push({ name, value, role }); } } // 暗色模式语义映射表(对称逻辑) const darkMapping: [string, SemanticRole, number][] = [ ['color-bg-primary', 'bg-primary', 1], // 极暗背景 ['color-bg-secondary', 'bg-secondary', 8], // 稍浅背景(用中性暗灰阶) ['color-bg-tertiary', 'bg-tertiary', 7], ['color-text-primary', 'text-primary', 11], // 亮色调文字 ['color-text-secondary', 'text-secondary', 9], ['color-border-default', 'border-default', 4], ['color-accent-primary', 'accent-primary', 7], // 暗色下主色需提亮 ['color-accent-hover', 'accent-hover', 8], // hover 更亮 ]; for (const [name, role, idx] of darkMapping) { const isNeutral = role.startsWith('bg') && idx > 1; const palette = isNeutral ? neutralDark : darkPalette; const value = palette[Math.min(idx, palette.length - 1)]; darkTokens.push({ name, value, role }); } return { light: lightTokens, dark: darkTokens }; } // === 第四步:输出 Design Token JSON(CSS 变量格式) === function tokensToCSSVariables( tokens: ColorToken[], selector: string = ':root' ): string { const lines: string[] = [`${selector} {`]; for (const token of tokens) { lines.push(` --${token.name}: ${token.value};`); } lines.push('}'); return lines.join('\n'); } // === 使用示例 === // const baseOklch = hexToOklch('#7c3aed'); // const lightPalette = generatePalette(baseOklch, 'light'); // const darkPalette = generatePalette(baseOklch, 'dark'); // const tokens = assignSemanticTokens(lightPalette, darkPalette, neutralLight, neutralDark); // console.log(tokensToCSSVariables(tokens.light)); // console.log(tokensToCSSVariables(tokens.dark, '[data-theme="dark"]')); export { hexToOklch, generatePalette, assignSemanticTokens, tokensToCSSVariables };框架集成(搭配 Tailwind 的预设配置):
/** * Tailwind 预设:将生成 Token 注入 Tailwind 主题配置 * 这样开发者既可以使用工具生成的 Token, * 也可以继续用 Tailwind 的原生类名 */ import type { Config } from 'tailwindcss'; function createColorPreset(lightTokens: ColorToken[], darkTokens: ColorToken[]): Partial<Config> { const colors: Record<string, string> = {}; for (const t of lightTokens) { // 将 --color-bg-primary 映射为 tailwind 的 bg-primary 类名 colors[t.name] = `var(--${t.name})`; } return { theme: { extend: { colors, }, }, // dark mode 通过 CSS 变量层级覆盖,不需要 Tailwind 的 class 策略 darkMode: ['class', '[data-theme="dark"]'], }; } export { createColorPreset };四、AI 生成色彩系统的三大局限性
品牌色的"feel"无法量化。你给设计师#E53935(Material Red 600),设计师可能会说:这个红的基调偏橙,我希望它偏紫调用。AI 算法只能在给定主色的 LCH 空间中做插值,它不理解"偏紫"意味着色相旋转 -15 度。这类主观调整仍需要人工介入——色彩系统的生成是 AI + 人工协作,不是 AI 替代。
语义冲突的自动化处理能力为零。当一个色阶的color-text-primary在亮色模式下 WCAG AAA 达标(7:1),但在组件内部叠加在bg-secondary上时对比度骤降到 3.2:1——AI 不会主动发现这个问题。组件级 Token 的对比度往往是"背景 Token — 文字 Token"的配对约束问题,目前需要手动编写校验规则。
暗色模式的"感知亮度翻转"不是单纯的反转。在暗色模式下,人眼对高饱和度亮色的疲劳度远高于低饱和度暗色。Material Design 3 的暗色模式策略是:表面色的 L 值越低、饱和度也相应降低(tonal 调色板方案)。纯粹靠 L 值线性翻转生成的暗色 Token,会让人感觉"对比度够了但颜色很刺眼"。这是 AI 生成色彩系统中最大的体验陷阱——数学指标达标不代表视觉舒适度达标。
五、总结
- 色彩 Token 体系的四层抽象:原始色值 → 色阶 → 语义 Token → 组件 Token。
- OKLCH 的 L 通道保证了感知均匀的亮度变化,是色阶生成的理想色彩空间。
- 亮色模式的色阶 L 值范围 0.98→0.30,暗色模式 0.08→0.70,通过线性插值 + 饱和度衰减曲线生成。
- 语义角色分配的本质是"当前 Token 的对比度需求"与"色阶明度位置"的映射。
- WCAG 对比度校验属于"配对约束"问题,同一文字 Token 在不同背景 Token 上的对比度可能不达标。
- 暗色模式下高饱和度亮色会加速视觉疲劳,纯等距插值生成的暗色 Token 在体验上不够舒适。
- culori 库是目前 OKLCH 色彩空间运算的推荐选择(TypeScript 原生,tree-shakable)。
- AI 的色彩生成能力瓶颈在"主观审美"——品牌色微调仍需要设计师的色感决策。
- 输出 Token 时优先采用 CSS 变量(
var(--color-xxx)),保证运行时主题切换能力。 - 色彩系统生成的自动化程度可以到 80%,剩余的 20% 是语义映射微调和暗色模式手感调校。