{"ok":true,"data":{"slug":"design-tokens-dark-mode","title":"设计令牌与暗色模式 一套不闪烁的主题系统","summary":"暗色模式做得好不好，取决于首屏那 0.2 秒。这篇笔记把果语的色彩令牌、三态主题与无闪烁预设脚本完整记录下来。","date":"2026-10-10","category":"技术笔记","tags":["设计系统","暗色模式","CSS变量"],"cover":"/images/covers/cover-design.svg","featured":true,"draft":false,"body":"\n暗色模式做得好不好，取决于首屏那 0.2 秒。刷新页面时如果看到一瞬间的白屏闪烁，用户会立刻觉得「这个站不专业」。\n\n## 一、令牌先行：组件不写死颜色\n\n果语的全部颜色定义在 `:root` 和 `.dark` 两个作用域里，Tailwind 负责把它们映射成语义类名。\n\n```css\n:root {\n  --background: 75 30% 98%;\n  --foreground: 100 20% 12%;\n  --primary: 96 44% 27%;\n  --primary-foreground: 84 50% 96%;\n  --border: 84 18% 87%;\n}\n\n.dark {\n  --background: 100 14% 6.5%;\n  --foreground: 85 18% 92%;\n  --primary: 84 45% 55%;\n  --border: 90 10% 19%;\n}\n```\n\n```ts\n// tailwind.config.js\ncolors: {\n  background: \"hsl(var(--background))\",\n  foreground: \"hsl(var(--foreground))\",\n  primary: {\n    DEFAULT: \"hsl(var(--primary))\",\n    foreground: \"hsl(var(--primary-foreground))\",\n  },\n}\n```\n\n组件里只写 `bg-background`、`text-muted-foreground`、`border-border`。这样切换主题时**不需要给任何组件加 `.dark` 前缀**，变量一变，全站跟着变。\n\n### 为什么主色在两套主题里亮度不同\n\n浅色模式下主色是 `hsl(96 44% 27%)`（深果绿），深色模式提亮到 `hsl(84 45% 55%)`（亮果绿）。原因是同样的色相，亮度和背景的对比关系完全相反：\n\n| 模式 | 主色 | 背景 | 正文对比度 |\n|---|---|---|---|\n| 浅色 | 深果绿 27% | 暖纸白 | 6.6 : 1 |\n| 深色 | 亮果绿 55% | 深夜果园 | 9.0 : 1 |\n\n> 暗色不是「反色」，而是独立调校的第二套色彩。\n\n### 插画专用令牌\n\n除语义令牌外，还有一组 `--avocado-*` 专给牛油果插画用（果皮 / 果肉 / 果核 / 嫩叶），\n让线稿 logo 之外的彩色插画也有唯一的取色出处：\n\n```css\n:root {\n  --avocado-skin: 96 44% 27%;   /* 果皮 */\n  --avocado-flesh: 82 52% 82%;  /* 果肉 */\n  --avocado-pit: 28 42% 36%;    /* 果核 */\n  --avocado-leaf: 100 45% 38%;  /* 嫩叶 */\n}\n```\n\n## 二、三态主题：浅色 / 深色 / 跟随系统\n\n```ts\ntype Theme = \"light\" | \"dark\" | \"system\";\n```\n\n用户选择持久化在 `localStorage`；选 `system` 时监听 `prefers-color-scheme` 的变化事件，系统切换主题网站跟着切。\n\n## 三、首屏无闪烁：在 CSS 之前同步执行\n\n关键在于：这段脚本必须在样式表生效**之前**跑完，阻塞在 `<head>` 里。\n\n```html\n<script>\n  (function () {\n    try {\n      var stored = localStorage.getItem(\"guoyu-theme\");\n      var systemDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n      var dark = stored ? stored === \"dark\" : systemDark;\n      document.documentElement.classList.toggle(\"dark\", dark);\n      document.documentElement.style.colorScheme = dark ? \"dark\" : \"light\";\n    } catch (e) {}\n  })();\n</script>\n```\n\n有了它，React 挂载之前 `html` 上就已经有了正确的 class。用户完全看不到中间态。\n\n## 四、别忘了可访问性\n\n暗色模式最容易翻车的地方是**边框和次要文字**——它们在深色背景上如果直接取反，会变成一片噪点。我的处理方式：\n\n1. 正文对比度 ≥ 4.5:1，次要文字 ≥ 4.5:1，两套主题都实测；\n2. 深色模式弱化阴影，改用 `border + 亮度差` 分层；\n3. 所有动效在 `prefers-reduced-motion: reduce` 下统一降级：\n\n```css\n@media (prefers-reduced-motion: reduce) {\n  *,\n  *::before,\n  *::after {\n    animation-duration: 0.01ms !important;\n    transition-duration: 0.01ms !important;\n  }\n}\n```\n\n## 验收清单\n\n- [x] 刷新页面无主题闪烁\n- [x] 三态切换后刷新仍保持\n- [x] 系统切换主题时网站实时跟随\n- [x] 两套主题下正文对比度均 ≥ 4.5:1\n- [x] 全站颜色可追溯到令牌，无「临时起意」的第四种颜色\n"}}