**逐处加 dark: 前缀的方案在这里必然失败**:约 700 处颜色散在 21 个组件里, 漏一处就是深色下的白底白字,而它不报错、不影响构建、只有肉眼能发现, 且往往只出现在某个不常开的页面。此后每加一个组件都要记得写两遍, 那种约定活不过三次改动。 改法是把颜色下沉到 CSS 变量,深色模式**反转灰阶**。这套代码的灰阶本身 就是语义色阶(white/gray-50 = 表面层次,gray-200/300 = 分隔线, gray-900→400 = 文字主次),反转之后 `bg-white text-gray-900` 自动变成 深色卡片 + 浅色文字。零组件改动,新组件照常写浅色类名也自动适配。 变量存 **RGB 三元组**而非 #hex:代码里有 bg-blue-50/70 这类透明度修饰符, Tailwind 生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS —— 那些半透明高亮会静默失效(不报错,只是不透明)。 --- 实测撞了三个必须分离的语义,每一个共用变量就坏: **1. text-white 不能跟 bg-white 走。** `white` 服务两种冲突用途:卡片表面(深色下要变暗)与彩色按钮上的文字 (深色下必须保持浅色)。共用时后者跟着变暗 —— 激活导航项的「收件」在 bg-chrome-700 上只剩 **1.34:1**,几乎消失。拆出 --c-on-accent。 **2. 侧栏与底部导航不能跟 gray 走。** 它们在浅色模式下**本来就是深色的**(深色侧栏配浅色内容区是原本设计)。 并入反转灰阶后深色模式下变成近白色(实测 rgb(243,245,248)),比内容区 (rgb(17,19,24))还亮,整个层次翻过来。独立成 chrome 色阶,深色下只微调、 保持「框架比内容更沉」。 **3. 强调色不能反转。** blue/red 跟着变会让主按钮在深色页面上失去「这是主操作」的视觉重量, 而且白字落在变暗的 blue-600 上对比度掉到 3:1 以下。改成固定值。 --- 顺带修的三处真实对比度不足(实测量出来的,不是猜的): - 待决策橙徽标:orange-500 上白字 2.80:1 → orange-700 5.18:1 (保留橙色语义,不能改成灰 —— 它与未读的红色是两种紧急) - 空状态文案:gray-400 2.43:1 → gray-500。这类文字是**页面上唯一的内容**, 不是次要装饰,读不动等于页面空白 - 列表头计数:同上 --- 主题是**三态**而非开关:system 不是 light 的别名 —— 只给开关的话, 白天设浅色之后晚上系统切深色应用不会跟着变。且只有 pref 为 system 时 才跟随系统,显式选了的人不该因为日落被切换。 index.html 加同步内联脚本消除首帧闪屏:bundle 有 430KB,从 HTML 解析完到 React 挂载之间页面是 body 默认色,深色用户每次刷新都被闪一下白屏。 外链或 defer 都晚于首次绘制。它与 themeStore 共用同一个 localStorage 键 (不一致会导致首帧按 A 键渲染、挂载后按 B 键重渲染,闪一下再变回去)。 body 显式设底色:移动端橡皮筋回弹露出的是 body 背景。 入口两处:侧栏单按钮快速翻转,「我的」页三选一设定偏好。单按钮不足以 表达三态,但只给单按钮的话用户一旦点过就永久脱离「跟随系统」—— 那是个回不去的单向门。 测试:test/theme.test.mjs 20 条结构性断言(已进 npm test), test/manual/theme-verify.mjs 真实渲染对比度验收(遍历可见文本节点算 WCAG 比值,往上找第一个不透明背景)。两种模式各 4 项全过。
140 lines
6.3 KiB
JavaScript
140 lines
6.3 KiB
JavaScript
/** @type {import('tailwindcss').Config} */
|
||
|
||
/**
|
||
* 颜色走 CSS 变量而不是写死的十六进制。
|
||
*
|
||
* # 为什么不逐处加 dark: 前缀
|
||
*
|
||
* 全站约 700 处颜色用法散在 21 个组件里。逐个写 `bg-white dark:bg-gray-900`
|
||
* 有两个致命问题:漏一处就是深色下的白底白字(而且只有肉眼能发现),
|
||
* 以及此后每加一个组件都要记得写两遍 —— 那种约定活不过三次改动。
|
||
*
|
||
* # 为什么改调色板就够了
|
||
*
|
||
* 这套代码里灰阶**本身就是语义色阶**:
|
||
* - `white` / `gray-50` / `gray-100` = 表面层次(卡片 / 页面底 / 悬停)
|
||
* - `gray-200` / `gray-300` = 分隔线
|
||
* - `gray-900` → `gray-400` = 文字主次
|
||
*
|
||
* 深色模式要做的正是把这条色阶**反转**:white 变近黑、gray-900 变近白。
|
||
* 于是零组件改动就能整体切换,新组件照常写 `bg-white text-gray-900`
|
||
* 也自动适配 —— 不需要任何人记得任何约定。
|
||
*
|
||
* # 为什么是 `rgb(var(--x) / <alpha-value>)` 而不是直接存颜色串
|
||
*
|
||
* 代码里有 `bg-blue-50/70`、`bg-gray-50/60` 这样的透明度修饰符。
|
||
* 变量若存 `#f9fafb`,Tailwind 生成的 `rgb(#f9fafb / 0.7)` 是无效 CSS,
|
||
* 那些半透明高亮会静默失效(不报错,只是不透明)。存 RGB 三元组才行。
|
||
*/
|
||
const withAlpha = (v) => `rgb(var(${v}) / <alpha-value>)`;
|
||
|
||
const grayScale = {
|
||
50: withAlpha('--c-gray-50'),
|
||
100: withAlpha('--c-gray-100'),
|
||
200: withAlpha('--c-gray-200'),
|
||
300: withAlpha('--c-gray-300'),
|
||
400: withAlpha('--c-gray-400'),
|
||
500: withAlpha('--c-gray-500'),
|
||
600: withAlpha('--c-gray-600'),
|
||
700: withAlpha('--c-gray-700'),
|
||
800: withAlpha('--c-gray-800'),
|
||
900: withAlpha('--c-gray-900'),
|
||
950: withAlpha('--c-gray-950')
|
||
};
|
||
|
||
/** 强调色只需要三档:浅底(chip/提示条)、主色(按钮)、深色(hover/文字)。 */
|
||
const accent = (name) => ({
|
||
50: withAlpha(`--c-${name}-50`),
|
||
100: withAlpha(`--c-${name}-100`),
|
||
200: withAlpha(`--c-${name}-200`),
|
||
300: withAlpha(`--c-${name}-300`),
|
||
400: withAlpha(`--c-${name}-400`),
|
||
500: withAlpha(`--c-${name}-500`),
|
||
600: withAlpha(`--c-${name}-600`),
|
||
700: withAlpha(`--c-${name}-700`),
|
||
800: withAlpha(`--c-${name}-800`),
|
||
900: withAlpha(`--c-${name}-900`)
|
||
});
|
||
|
||
export default {
|
||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||
// class 而不是 media:主题要能被人显式选择。跟系统走是**默认值**,
|
||
// 不是唯一选项 —— 白天开深色主题是常见偏好。
|
||
darkMode: 'class',
|
||
theme: {
|
||
extend: {
|
||
/**
|
||
* textColor 单独覆盖 white。
|
||
*
|
||
* `--c-white` 服务两种**互相冲突**的用途:
|
||
* - `bg-white` = 卡片表面 → 深色模式必须变暗
|
||
* - `text-white` = 彩色按钮上的文字 → 深色模式必须**保持浅色**
|
||
*
|
||
* 只有一个变量时后者跟着变暗,白字落在 `bg-chrome-700` 的激活导航项上
|
||
* 只剩 1.34:1 —— 几乎不可见(实测发现)。按钮底色在深色模式下依然是
|
||
* blue-600 那样的彩色,上面的文字本来就该是白的。
|
||
*
|
||
* Tailwind 的 textColor 默认继承 colors,这里只改 white 一项,
|
||
* 其余(gray/blue/...)仍走反转的色阶。
|
||
*/
|
||
textColor: {
|
||
white: withAlpha('--c-on-accent')
|
||
},
|
||
colors: {
|
||
white: withAlpha('--c-white'),
|
||
gray: grayScale,
|
||
// slate 在这套代码里只用于登录页与少数深色块,与 gray 同源即可 ——
|
||
// 保留两个名字是为了不改那些组件,但它们指向同一条色阶。
|
||
slate: grayScale,
|
||
/**
|
||
* chrome —— 应用框架(侧栏 / 底部导航)的专用色阶。
|
||
*
|
||
* 为什么不能跟 gray 走:这两块**在浅色模式下本来就是深色的**
|
||
* (深色侧栏配浅色内容区是这套 UI 的原本设计)。把它们并入反转的
|
||
* gray 之后,深色模式下 `bg-slate-900` 变成了近白色 —— 侧栏比内容区
|
||
* 还亮,整个层次翻了过来(实测 rgb(243,245,248),而内容区是 rgb(17,19,24))。
|
||
*
|
||
* 独立成一条色阶后:浅色模式下它是深色框架,深色模式下**微调即可**
|
||
* (比内容区略深一点,保持"框架比内容更沉"的关系),两种模式下
|
||
* 语义一致。
|
||
*/
|
||
chrome: {
|
||
100: withAlpha('--c-chrome-100'),
|
||
200: withAlpha('--c-chrome-200'),
|
||
400: withAlpha('--c-chrome-400'),
|
||
600: withAlpha('--c-chrome-600'),
|
||
700: withAlpha('--c-chrome-700'),
|
||
800: withAlpha('--c-chrome-800'),
|
||
900: withAlpha('--c-chrome-900')
|
||
},
|
||
/* accent 色走固定值,不随主题反转。深色模式下彩色按钮底色不变,
|
||
变的是卡片/表面的深浅,所以白色文字的对比度始终稳定。 */
|
||
blue: { 50:'#eff6ff', 100:'#dbeafe', 200:'#bfdbfe', 300:'#93c5fd',
|
||
400:'#60a5fa', 500:'#3b82f6', 600:'#2563eb', 700:'#1d4ed8',
|
||
800:'#1e40af', 900:'#1e3a8a' },
|
||
red: { 50:'#fef2f2', 100:'#fee2e2', 200:'#fecaca', 300:'#fca5a5',
|
||
400:'#f87171', 500:'#ef4444', 600:'#dc2626', 700:'#b91c1c',
|
||
800:'#991b1b', 900:'#7f1d1d' },
|
||
green: { 50:'#f0fdf4', 100:'#dcfce7', 200:'#bbf7d0', 300:'#86efac',
|
||
400:'#4ade80', 500:'#22c55e', 600:'#16a34a', 700:'#15803d',
|
||
800:'#166534', 900:'#14532d' },
|
||
amber: { 50:'#fffbeb', 100:'#fef3c7', 200:'#fde68a', 300:'#fcd34d',
|
||
400:'#fbbf24', 500:'#f59e0b', 600:'#d97706', 700:'#b45309',
|
||
800:'#92400e', 900:'#78350f' },
|
||
orange: { 50:'#fff7ed', 100:'#ffedd5', 200:'#fed7aa', 300:'#fdba74',
|
||
400:'#fb923c', 500:'#f97316', 600:'#ea580c', 700:'#c2410c',
|
||
800:'#9a3412', 900:'#7c2d12' },
|
||
yellow: { 50:'#fefce8', 100:'#fef9c3', 200:'#fef08a', 300:'#fde047',
|
||
400:'#facc15', 500:'#eab308', 600:'#ca8a04', 700:'#a16207',
|
||
800:'#854d0e', 900:'#713f12' },
|
||
red: accent('red'),
|
||
green: accent('green'),
|
||
amber: accent('amber'),
|
||
orange: accent('orange'),
|
||
yellow: accent('yellow')
|
||
}
|
||
}
|
||
},
|
||
plugins: []
|
||
};
|