AI 产品配色系统设计

色彩理论、语义化配色、暗黑模式、WCAG 无障碍与 Design Token 架构


一、为什么配色系统是 AI 产品的基础设施

颜色不是装饰,是信息。在 AI 产品中,颜色承载着状态、层级、操作和情感:

  • 状态表达:成功(绿)、错误(红)、警告(黄)、加载中(蓝)
  • 层级区分:主操作 vs 次要操作、已读 vs 未读、激活 vs 禁用
  • 品牌识别:用户在 0.1 秒内通过颜色辨认产品
  • 情感基调:冷静专业(蓝灰)、温暖友好(橙黄)、科技感(深紫+青)

配色系统不是"选几个好看的颜色",而是建立一套从语义到像素的映射规则,确保产品在任何场景下都能一致、可用、可访问。

配色系统的层级模型

┌───────────────────────────────────────────────┐
│           L1: Semantic Layer                   │
│    (success, error, warning, info, ...)        │
├───────────────────────────────────────────────┤
│           L2: Palette Layer                    │
│    (blue-50 ~ blue-900, gray-50 ~ gray-900)   │
├───────────────────────────────────────────────┤
│           L3: Primitive Layer                  │
│    (#1a365d, #2b6cb0, #3182ce, ...)            │
└───────────────────────────────────────────────┘

使用方向:代码引用 L1 -> L1 映射到 L2 -> L2 定义为 L3
          button.primary -> palette.blue.600 -> #2563eb

二、色彩理论基础

2.1 色彩模型选择

模型 用途 优势
HSL CSS 开发 直觉化(色相/饱和度/亮度)
OKLCH 现代 CSS 感知均匀,适合色板生成
HEX/RGB 存储和传输 通用、兼容性最好
HSB 设计工具 Figma/Sketch 原生支持

2.2 科学化色板生成

传统的色板生成依赖设计师直觉。现代方法使用感知均匀色彩空间(OKLCH),确保同一色相的不同明度变体看起来"等距"。

// color-palette-generator.ts

interface PaletteStep {
  name: string;    // e.g., '50', '100', ..., '900'
  hex: string;
  hsl: { h: number; s: number; l: number };
  contrastOnWhite: number;
  contrastOnBlack: number;
}

function generatePalette(
  baseColor: string,
  steps: number = 10,
): PaletteStep[] {
  /**
   * Generate a 10-step palette from a base color using
   * perceptually uniform lightness interpolation.
   *
   * Steps: 50 (lightest) -> 950 (darkest)
   * Base color typically maps to step 500-600.
   */
  const base = parseHSL(baseColor);

  // Define lightness curve (perceptually uniform)
  const lightnessSteps = [
    { name: '50',  l: 97 },
    { name: '100', l: 93 },
    { name: '200', l: 85 },
    { name: '300', l: 75 },
    { name: '400', l: 63 },
    { name: '500', l: 50 },
    { name: '600', l: 40 },
    { name: '700', l: 32 },
    { name: '800', l: 24 },
    { name: '900', l: 15 },
  ];

  return lightnessSteps.map(step => {
    // Adjust saturation: slightly reduce at extremes
    const satAdjust = step.l > 90 || step.l < 20
      ? 0.7
      : step.l > 80 || step.l < 30
        ? 0.85
        : 1.0;

    const hsl = {
      h: base.h,
      s: Math.round(base.s * satAdjust),
      l: step.l,
    };

    const hex = hslToHex(hsl);

    return {
      name: step.name,
      hex,
      hsl,
      contrastOnWhite: calculateContrastRatio(hex, '#ffffff'),
      contrastOnBlack: calculateContrastRatio(hex, '#000000'),
    };
  });
}

function hslToHex(hsl: { h: number; s: number; l: number }): string {
  const { h, s, l } = hsl;
  const a = s * Math.min(l, 100 - l) / 100;
  const f = (n: number) => {
    const k = (n + h / 30) % 12;
    const color = l / 100 - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
    return Math.round(255 * color)
      .toString(16)
      .padStart(2, '0');
  };
  return `#${f(0)}${f(8)}${f(4)}`;
}

三、语义化配色系统

3.1 从色板到语义

代码中不应该直接使用色板值(如 blue-600),而应该使用语义化名称(如 primary)。这使得主题切换成为一行配置修改。

// semantic-colors.ts

interface SemanticColors {
  // Brand
  primary: string;            // Main brand action
  primaryHover: string;       // Hover state
  primaryActive: string;      // Active/pressed state
  primarySubtle: string;      // Soft background

  // Feedback
  success: string;
  successSubtle: string;
  warning: string;
  warningSubtle: string;
  error: string;
  errorSubtle: string;
  info: string;
  infoSubtle: string;

  // Surface
  background: string;         // Page background
  surface: string;            // Card/modal background
  surfaceHover: string;       // Hover state for interactive surfaces
  overlay: string;            // Modal backdrop

  // Text
  textPrimary: string;        // Main body text
  textSecondary: string;      // Supporting text
  textDisabled: string;       // Disabled state
  textInverse: string;        // Text on dark backgrounds
  textLink: string;           // Links
  textLinkHover: string;

  // Border
  border: string;             // Default borders
  borderFocus: string;        // Focus ring
  borderError: string;        // Error state borders

  // AI-specific
  aiThinking: string;         // Loading/processing indicator
  aiResponse: string;         // AI-generated content background
  aiHighlight: string;        // AI suggestions/highlights
  streaming: string;          // Streaming text cursor color
}

function createLightTheme(palette: {
  blue: PaletteStep[];
  gray: PaletteStep[];
  green: PaletteStep[];
  red: PaletteStep[];
  yellow: PaletteStep[];
}): SemanticColors {
  return {
    primary:        palette.blue[5].hex,   // blue-500
    primaryHover:   palette.blue[6].hex,   // blue-600
    primaryActive:  palette.blue[7].hex,   // blue-700
    primarySubtle:  palette.blue[0].hex,   // blue-50

    success:        palette.green[5].hex,
    successSubtle:  palette.green[0].hex,
    warning:        palette.yellow[4].hex,
    warningSubtle:  palette.yellow[0].hex,
    error:          palette.red[5].hex,
    errorSubtle:    palette.red[0].hex,
    info:           palette.blue[4].hex,
    infoSubtle:     palette.blue[0].hex,

    background:     '#ffffff',
    surface:        palette.gray[0].hex,
    surfaceHover:   palette.gray[1].hex,
    overlay:        'rgba(0, 0, 0, 0.5)',

    textPrimary:    palette.gray[8].hex,   // gray-800
    textSecondary:  palette.gray[5].hex,   // gray-500
    textDisabled:   palette.gray[3].hex,   // gray-300
    textInverse:    '#ffffff',
    textLink:       palette.blue[5].hex,
    textLinkHover:  palette.blue[6].hex,

    border:         palette.gray[2].hex,
    borderFocus:    palette.blue[4].hex,
    borderError:    palette.red[4].hex,

    aiThinking:     palette.blue[4].hex,
    aiResponse:     palette.blue[0].hex,
    aiHighlight:    palette.yellow[1].hex,
    streaming:      palette.blue[5].hex,
  };
}

四、暗黑模式设计

4.1 暗黑模式不是"反转颜色"

暗黑模式的常见错误是简单地将黑白互换。正确的做法是重新映射整个语义层。

属性 浅色模式 暗黑模式 说明
背景 #ffffff #0f172a 不用纯黑,用深蓝灰
表面 #f8fafc #1e293b 卡片/弹窗略浅于背景
主文本 #1e293b #e2e8f0 不用纯白,减少眩光
次文本 #64748b #94a3b8 保持对比度比例
主色调 #2563eb #60a5fa 暗黑下用更亮的主色
错误 #dc2626 #f87171 暗背景上用更亮的红

4.2 双主题 Token 系统

// theme-tokens.ts

type ThemeMode = 'light' | 'dark';

interface ThemeTokens {
  mode: ThemeMode;
  colors: SemanticColors;
}

function createDarkTheme(palette: typeof lightPalette): SemanticColors {
  return {
    primary:        palette.blue[3].hex,   // blue-300 (brighter)
    primaryHover:   palette.blue[2].hex,   // blue-200
    primaryActive:  palette.blue[4].hex,   // blue-400
    primarySubtle:  '#1e3a5f',             // Custom dark blue

    success:        palette.green[3].hex,
    successSubtle:  '#1a3a2a',
    warning:        palette.yellow[3].hex,
    warningSubtle:  '#3a3520',
    error:          palette.red[3].hex,
    errorSubtle:    '#3a1a1a',
    info:           palette.blue[3].hex,
    infoSubtle:     '#1a2a3a',

    background:     '#0f172a',             // slate-900
    surface:        '#1e293b',             // slate-800
    surfaceHover:   '#334155',             // slate-700
    overlay:        'rgba(0, 0, 0, 0.7)',

    textPrimary:    '#e2e8f0',             // slate-200
    textSecondary:  '#94a3b8',             // slate-400
    textDisabled:   '#475569',             // slate-600
    textInverse:    '#0f172a',
    textLink:       palette.blue[3].hex,
    textLinkHover:  palette.blue[2].hex,

    border:         '#334155',             // slate-700
    borderFocus:    palette.blue[3].hex,
    borderError:    palette.red[3].hex,

    aiThinking:     palette.blue[3].hex,
    aiResponse:     '#1e3a5f',
    aiHighlight:    '#3a3520',
    streaming:      palette.blue[3].hex,
  };
}

// CSS Custom Properties export
function exportAsCSSVariables(tokens: SemanticColors): string {
  const entries = Object.entries(tokens).map(([key, value]) => {
    const cssName = key.replace(/([A-Z])/g, '-$1').toLowerCase();
    return `  --color-${cssName}: ${value};`;
  });
  return `:root {\n${entries.join('\n')}\n}`;
}

function exportAsDarkCSS(tokens: SemanticColors): string {
  const entries = Object.entries(tokens).map(([key, value]) => {
    const cssName = key.replace(/([A-Z])/g, '-$1').toLowerCase();
    return `  --color-${cssName}: ${value};`;
  });
  return `@media (prefers-color-scheme: dark) {\n  :root {\n${entries.join('\n')}\n  }\n}`;
}

五、WCAG 无障碍设计

5.1 对比度要求

级别 正文 (>= 18px bold / 24px) 小文本 (< 18px bold) 非文本元素
AA 3:1 4.5:1 3:1
AAA 4.5:1 7:1 -

5.2 自动对比度校验

// wcag-validator.ts

function relativeLuminance(hex: string): number {
  const rgb = hexToRGB(hex);
  const [r, g, b] = [rgb.r, rgb.g, rgb.b].map(c => {
    const sRGB = c / 255;
    return sRGB <= 0.03928
      ? sRGB / 12.92
      : Math.pow((sRGB + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function contrastRatio(color1: string, color2: string): number {
  const l1 = relativeLuminance(color1);
  const l2 = relativeLuminance(color2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

interface ContrastReport {
  pair: [string, string];
  ratio: number;
  aa: { normalText: boolean; largeText: boolean };
  aaa: { normalText: boolean; largeText: boolean };
}

function auditThemeContrast(
  theme: SemanticColors,
): ContrastReport[] {
  /**
   * Audit all critical color pairings in the theme.
   * Returns list of pairings that fail WCAG criteria.
   */
  const criticalPairs: [string, string, string, string][] = [
    // [name1, color1, name2, color2]
    ['textPrimary', theme.textPrimary, 'background', theme.background],
    ['textSecondary', theme.textSecondary, 'background', theme.background],
    ['textPrimary', theme.textPrimary, 'surface', theme.surface],
    ['primary', theme.primary, 'background', theme.background],
    ['error', theme.error, 'background', theme.background],
    ['success', theme.success, 'background', theme.background],
    ['textInverse', theme.textInverse, 'primary', theme.primary],
  ];

  return criticalPairs.map(([name1, color1, name2, color2]) => {
    const ratio = contrastRatio(color1, color2);
    return {
      pair: [color1, color2],
      ratio,
      aa: {
        normalText: ratio >= 4.5,
        largeText: ratio >= 3,
      },
      aaa: {
        normalText: ratio >= 7,
        largeText: ratio >= 4.5,
      },
    };
  });
}

function fixContrast(
  foreground: string,
  background: string,
  targetRatio: number = 4.5,
): string {
  /**
   * Adjust foreground color to meet target contrast ratio.
   * Preserves hue, adjusts lightness.
   */
  let hsl = parseHSL(foreground);
  const bgLuminance = relativeLuminance(background);

  // Direction: darken on light background, lighten on dark
  const step = bgLuminance > 0.5 ? -2 : 2;

  for (let i = 0; i < 50; i++) {
    const hex = hslToHex(hsl);
    const ratio = contrastRatio(hex, background);
    if (ratio >= targetRatio) return hex;
    hsl = { ...hsl, l: Math.max(0, Math.min(100, hsl.l + step)) };
  }

  // Fallback: return black or white
  return bgLuminance > 0.5 ? '#000000' : '#ffffff';
}

六、Design Token 架构

6.1 Token 分发流程

Figma (设计师)
  |
  v
[Figma Tokens Plugin] -> tokens.json (原始 Token)
  |
  v
[Style Dictionary] -> 转换为多平台格式
  |
  ├── CSS Variables     (Web)
  ├── Tailwind Config   (Tailwind CSS)
  ├── Swift UIColor     (iOS)
  ├── Android XML       (Android)
  └── JSON              (服务端/PPT 生成)

6.2 Token 结构示例

{
  "color": {
    "primitive": {
      "blue": {
        "50":  { "value": "#eff6ff" },
        "100": { "value": "#dbeafe" },
        "200": { "value": "#bfdbfe" },
        "500": { "value": "#3b82f6" },
        "700": { "value": "#1d4ed8" },
        "900": { "value": "#1e3a8a" }
      }
    },
    "semantic": {
      "primary": {
        "value": "{color.primitive.blue.500}",
        "description": "Primary brand color, used for main CTAs"
      },
      "primary-hover": {
        "value": "{color.primitive.blue.600}"
      },
      "text-primary": {
        "value": "{color.primitive.gray.800}",
        "darkValue": "{color.primitive.gray.200}"
      }
    }
  },
  "spacing": {
    "xs": { "value": "4px" },
    "sm": { "value": "8px" },
    "md": { "value": "16px" },
    "lg": { "value": "24px" },
    "xl": { "value": "32px" }
  },
  "typography": {
    "heading": {
      "fontFamily": { "value": "Inter, system-ui, sans-serif" },
      "fontWeight": { "value": "700" },
      "lineHeight": { "value": "1.2" }
    }
  }
}

6.3 Tailwind 集成

// tailwind.config.ts
import type { Config } from 'tailwindcss';
import tokens from './tokens.json';

const config: Config = {
  theme: {
    extend: {
      colors: {
        primary: {
          DEFAULT: tokens.color.semantic.primary.value,
          hover: tokens.color.semantic['primary-hover'].value,
          subtle: tokens.color.primitive.blue['50'].value,
        },
        surface: tokens.color.semantic.surface.value,
        // ... map all semantic tokens
      },
      spacing: {
        xs: tokens.spacing.xs.value,
        sm: tokens.spacing.sm.value,
        md: tokens.spacing.md.value,
        lg: tokens.spacing.lg.value,
        xl: tokens.spacing.xl.value,
      },
    },
  },
};

export default config;

七、AI 产品的特殊配色需求

AI 交互状态的颜色语义

状态 推荐颜色 动态效果 说明
等待输入 中性灰 不引导、不催促
AI 思考中 品牌蓝 脉冲/渐变动画 表示"正在工作"
AI 回复中 浅蓝背景 流式文字光标 区分于用户消息
AI 完成 标准背景 过渡回正常状态
AI 错误 红色提示 但要提供重试按钮
AI 建议 黄色高亮 淡入 引起注意但不打扰

代码示例:AI 消息气泡

/* AI message bubble - distinct from user messages */
.message-ai {
  background: var(--color-ai-response);       /* 浅蓝 */
  border-left: 3px solid var(--color-primary); /* 品牌色标识 */
  color: var(--color-text-primary);
}

.message-user {
  background: var(--color-surface);            /* 中性灰 */
  color: var(--color-text-primary);
}

/* AI thinking indicator */
.ai-thinking {
  display: inline-flex;
  gap: 4px;
}

.ai-thinking .dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--color-ai-thinking);
  animation: pulse 1.4s ease-in-out infinite;
}

八、总结

配色系统的最终目标是让颜色"隐形"——用户不会注意到颜色,但会因为颜色而更高效地使用产品。好的配色系统应该:

  1. 语义化:开发者用 primary 而非 #2563eb
  2. 可主题化:一行配置切换明暗主题
  3. 可验证:自动化工具检测对比度和一致性
  4. 可扩展:新增颜色不破坏现有体系
  5. 可感知:色板在感知上均匀分布

Maurice | maurice_wen@proton.me