当暗色不再是简单反转暗色模式设计系统的色彩映射算法与实操细节一、深度引言与场景痛点暗色模式不是把亮色页面的颜色值简单反转——把白色变黑色、把蓝色变深蓝色那得到的不是暗色模式而是噩梦模式。文字在纯黑背景上刺眼主色在深底上失去辨识度间距和阴影在暗色下变得不像回事。暗色模式的色彩设计需要一套全新的映射算法亮度反转但不简单取反饱和度适度降低以减少视觉疲劳对比度重新校准以确保可读性。这套映射算法不是艺术直觉而是数学公式——OKLCH 色彩空间中的亮度调整、sRGB 中的对比度计算、Display P3 中的色彩保真度维护。设计师在 Figma 中定义亮色 Token映射算法自动生成暗色 Token两者之间的关系不是颜色反转而是色彩空间变换。这篇文章解决的核心问题如何构建一套从亮色到暗色的色彩映射算法覆盖亮度调整、饱和度控制、对比度校准三个维度并把算法实现为可自动执行的 Token 转换管道。二、底层机制与原理深度剖析暗色模式色彩映射的三个核心步骤亮度映射、饱和度调整、对比度校准。每个步骤解决一类特定的暗色设计问题。flowchart TD A[亮色模式 Token] -- B[亮度映射层] B -- B1[OKLCH 色彩空间转换] B -- B2[亮度反转公式L_dark 1 - L_light × factor] B -- B3[保留色相和饱和度不变] B1 B2 B3 -- C[饱和度调整层] C -- C1[降低低亮度色彩的饱和度] C -- C2[保留高亮度主色的饱和度] C -- C3[避免暗色高饱和度导致的视觉刺痛] C1 C2 C3 -- D[对比度校准层] D -- D1[计算前景色与背景色的对比度比率] D -- D2[对比度低于 4.5:1 时自动调亮前景色] D -- D3[对比度高于 15:1 时适度降低以减少刺眼感] D1 D2 D3 -- E[暗色模式 Token 输出] E -- E1[生成 --color-*-dark 变量] E -- E2[写入 tokens-dark.css] E -- E3[WCAG AA 合规标记]亮度映射层是基础。简单反转rgb(255,255,255) → rgb(0,0,0)在 sRGB 空间中是灾难性的因为 sRGB 的亮度分布不均匀——中间亮度值在视觉感知上偏暗。OKLCH 色彩空间解决了这个问题它的 L 通道是感知均匀的L0.5 在视觉上真的是中间亮度。映射公式L_dark 1 - L_light × factor中factor 是亮度压缩系数——0.7 表示暗色最高亮度不超过 0.3避免纯白文字在暗底上刺眼。饱和度调整层处理暗色下的色彩感知问题。低亮度色彩如果保持高饱和度视觉上会产生刺痛感——暗红、暗蓝的高饱和度版本像一道激光射入眼睛。算法的规则是亮度低于 0.3 的色彩饱和度按比例降低C_dark C_light × (L_dark / 0.3)亮度高于 0.3 的色彩饱和度保持不变或略微降低维持品牌色辨识度。对比度校准层确保暗色模式下的可读性。WCAG AA 标准要求正常文字的对比度不低于 4.5:1。亮度映射后的前景色可能对比度不足需要自动调亮前景色直到满足 4.5:1。但对比度也不能过高——超过 15:1 的对比度纯白文字在纯黑背景上会产生刺眼感算法在 4.5:1 到 15:1 之间寻找平衡点。三、生产级代码实现与最佳实践OKLCH 色彩空间转换与映射算法// utils/color-mapping.ts // 暗色模式色彩映射算法核心实现 interface OKLCHColor { L: number; // 亮度 0-1感知均匀 C: number; // 饱和度 0-0.4 H: number; // 色相 0-360 } interface sRGBColor { r: number; // 0-1 g: number; b: number; } // sRGB → OKLCH 转换 function sRGBToOKLCH(rgb: sRGBColor): OKLCHColor { // 步骤 1: sRGB → 线性 RGB const linearize (v: number) v 0.04045 ? v / 12.92 : Math.pow((v 0.055) / 1.055, 2.4); const lr linearize(rgb.r); const lg linearize(rgb.g); const lb linearize(rgb.b); // 步骤 2: 线性 RGB → OKLab简化计算 // 使用近似的转换矩阵 const L_ 0.4122214708 * lr 0.5363325363 * lg 0.0514459929 * lb; const M_ 0.2119034982 * lr 0.6806995451 * lg 0.1075657908 * lb; const S_ 0.0883024619 * lr 0.2817188376 * lg 0.6302616586 * lb; const lc_ Math.cbrt(L_); const mc_ Math.cbrt(M_); const sc_ Math.cbrt(S_); const L 0.2104542553 * lc_ 0.7886176654 * mc_ - 0.0090723057 * sc_; const a 1.0 * lc_ - 0.8034542553 * mc_ 0.0833452553 * sc_; const b 0.0 * lc_ 0.3852652553 * mc_ - 0.3852652553 * sc_; // 步骤 3: OKLab → OKLCH const C Math.sqrt(a * a b * b); const H Math.atan2(b, a) * 180 / Math.PI; return { L, C, H: H 0 ? H 360 : H }; } // OKLCH → sRGB 转换 function oklchToSRGB(oklch: OKLCHColor): sRGBColor { // OKLCH → OKLab const a oklch.C * Math.cos(oklch.H * Math.PI / 180); const b oklch.C * Math.sin(oklch.H * Math.PI / 180); // OKLab → 线性 RGB逆矩阵 const lc_ L_ 0.3963377774 * a 0.2158037573 * b; // 简化 const mc_ 1.0 - 0.1055613458 * a - 0.0638541728 * b; const sc_ 0.0 0.0894841775 * a - 1.2914855480 * b; // 这些近似值仅用于示意实际实现应使用精确矩阵 const L_ lc_ * lc_ * lc_; const M_ mc_ * mc_ * mc_; const S_ sc_ * sc_ * sc_; const lr 1.7053 * L_ - 1.7813 * M_ 0.0760 * S_; const lg -0.6145 * L_ 1.8277 * M_ - 0.2132 * S_; const lb 0.0 * L_ - 0.0742 * M_ 1.0742 * S_; // 线性 RGB → sRGB const gamma (v: number) v 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; return { r: Math.max(0, Math.min(1, gamma(lr))), g: Math.max(0, Math.min(1, gamma(lg))), b: Math.max(0, Math.min(1, gamma(lb))) }; } // 暗色模式映射算法 function mapToDarkMode( lightColor: sRGBColor, backgroundDark: sRGBColor { r: 0.11, g: 0.11, b: 0.13 }, // 暗色背景默认值 factor: number 0.7 // 亮度压缩系数 ): sRGBColor { const oklch sRGBToOKLCH(lightColor); // 亮度映射反转并压缩 let L_dark 1 - oklch.L * factor; // 饱和度调整低亮度色彩降低饱和度 let C_dark oklch.C; if (L_dark 0.3) { // 低亮度时饱和度按比例降低避免暗色高饱和度的刺痛感 C_dark oklch.C * (L_dark / 0.3); } else if (L_dark 0.7) { // 高亮度时饱和度略微降低减少刺眼感 C_dark oklch.C * 0.85; } // 色相保持不变 const H_dark oklch.H; // 转换回 sRGB let darkColor oklchToSRGB({ L: L_dark, C: C_dark, H: H_dark }); // 对比度校准确保前景色与背景色的对比度在 4.5:1 到 15:1 之间 const bgOKLCH sRGBToOKLCH(backgroundDark); let contrastRatio calculateContrastRatio(darkColor, backgroundDark); // 对比度不足时逐步调亮前景色 while (contrastRatio 4.5 L_dark 1.0) { L_dark 0.02; darkColor oklchToSRGB({ L: L_dark, C: C_dark, H: H_dark }); contrastRatio calculateContrastRatio(darkColor, backgroundDark); } // 对比度过高时适度降低 while (contrastRatio 15 L_dark 0.3) { L_dark - 0.01; darkColor oklchToSRGB({ L: L_dark, C: C_dark, H: H_dark }); contrastRatio calculateContrastRatio(darkColor, backgroundDark); } return darkColor; } // 对比度比率计算WCAG 公式 function calculateContrastRatio(fg: sRGBColor, bg: sRGBColor): number { const fgLum relativeLuminance(fg); const bgLum relativeLuminance(bg); const lighter Math.max(fgLum, bgLum); const darker Math.min(fgLum, bgLum); return (lighter 0.05) / (darker 0.05); } // 相对亮度计算 function relativeLuminance(rgb: sRGBColor): number { const linearize (v: number) v 0.04045 ? v / 12.92 : Math.pow((v 0.055) / 1.055, 2.4); return 0.2126 * linearize(rgb.r) 0.7152 * linearize(rgb.g) 0.0722 * linearize(rgb.b); }Token 转换管道// scripts/dark-mode-token-generator.ts import fs from fs; interface LightToken { name: string; // CSS Variable 名称 value: string; // sRGB 颜色值如 #3B82F6 } function generateDarkTokens(lightTokensPath: string, outputPath: string): void { // 读取亮色模式 Token 文件 const lightTokens parseTokenFile(lightTokensPath); const darkTokens: LightToken[] []; for (const token of lightTokens) { // 将 hex 值转为 sRGB 对象 const rgb hexToSRGB(token.value); // 执行暗色映射算法 const darkRGB mapToDarkMode(rgb); // 将 sRGB 值转回 hex const darkHex sRGBToHex(darkRGB); // 暗色 Token 命名规则在原名称后添加语义标记 // 不使用 -dark 后缀而是使用语义化的命名 darkTokens.push({ name: token.name, // 同名覆盖在>