# Django 站点暗色模式改造：零回归策略、CSS 变量架构与验证方法

> 作者: Lazybones | 日期: 2026-09-05

> 给一个已经在运行、有大量既有页面的 Django 站点加暗色模式，最大的挑战往往不是「怎么做出暗色」，而是「怎么保证明亮态一个像素都不变」。本文给出一套零回归策略、CSS 变量架构，以及浏览器验证阶段常见的三个缺陷及其修复。

## 需求：三态主题

需求很明确，明亮 / 黑暗 / 跟随系统三态可切换：

- **明亮**：强制浅色。
- **黑暗**：强制深色。
- **跟随系统**：读操作系统的 `prefers-color-scheme`，并且系统主题变化时**实时**跟随，不用刷新页面。

选择要持久化到 `localStorage`，下次访问自动恢复。

## 站点现状

假设站点用 **Tailwind CSS（CDN 版）+ Alpine.js**。既有的颜色体系是 `base.html` 里定义的 7 组语义颜色 token（静态 hex 值），没有任何暗色逻辑，`darkMode` 也没配。更麻烦的是模板里颜色用法很杂：有语义 token、有原生 Tailwind 色类（`bg-white`、`text-gray-700`）、还有硬编码 hex。这决定了不能靠单一手段搞定，得用**混合策略**。

## 策略一：Tailwind class 模式 + `<html>` 开关

Tailwind 配 `darkMode: 'class'`，通过在 `<html>` 上加/去 `.dark` 类来切换。所有 `dark:` 变体都以祖先是否有 `.dark` 为条件：

```javascript
// base.html 的 Tailwind 配置
tailwind.config = {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        // 关键：token 写成 rgb(var(--x-rgb) / <alpha-value>) 保留透明度能力
        'page-bg': 'rgb(var(--color-bg-rgb) / <alpha-value>)',
        'text-primary': 'rgb(var(--color-text-primary-rgb) / <alpha-value>)',
        // ... 其余 5 组
      }
    }
  }
}
```

## 策略二：CSS 变量作为单一真相源

核心思路是让 `main.css` 里的 CSS 变量成为**唯一的颜色真相源**，明亮和暗色只是同一批变量的两套取值。为了让 Tailwind 的 `<alpha-value>` 透明度机制仍然可用，变量存的是 **RGB 三元组**（而不是完整颜色值），token 再拼成 `rgb(var(--color-*-rgb) / <alpha-value>)`：

```css
/* main.css */
:root {
  --color-bg-rgb: 255 255 255;
  --color-text-primary-rgb: 17 24 39;
  --color-accent-rgb: 180 40 40;
  /* ... 共 7 组 */
}

html.dark {
  --color-bg-rgb: 17 20 24;
  --color-text-primary-rgb: 229 231 235;
  --color-accent-rgb: 220 120 100;
  /* ... 对应 7 组暗色取值 */
}
```

这样一来，**语义 token 会随 `.dark` 自动翻转**——凡是用了 `text-text-primary`、`bg-page-bg` 这类 token 的地方，一行代码都不用改就自动适配暗色。

## 策略三：FOUC 预防

用 CDN 版 Tailwind 有个经典问题：如果等 Tailwind/Alpine 加载完再决定主题，页面会先闪一下白再变黑（FOUC，Flash of Unstyled Content）。解法是在 **Tailwind CDN `<script>` 之前**注入一段**同步执行**的 pre-paint 脚本，在首次绘制前就把 `.dark` 类打到 `<html>` 上：

```html
<!-- 必须放在 Tailwind CDN 之前，同步执行 -->
<script>
  (function () {
    try {
      var stored = localStorage.getItem('theme');
      var sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      var dark = stored === 'dark' || (!stored || stored === 'system') && sysDark;
      if (dark) document.documentElement.classList.add('dark');
    } catch (e) {}
  })();
</script>
```

## 策略四：Alpine store 管理三态

主题状态用 `Alpine.store('theme')` 集中管理，负责三态切换、`localStorage` 持久化，以及监听 `matchMedia` 实现「跟随系统」时的实时切换：

```javascript
Alpine.store('theme', {
  mode: 'system', // 'light' | 'dark' | 'system'
  init() {
    this.mode = localStorage.getItem('theme') || 'system';
    this.apply();
    // 系统主题变化时，若处于 system 模式则实时跟随
    window.matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', () => { if (this.mode === 'system') this.apply(); });
  },
  set(m) { this.mode = m; localStorage.setItem('theme', m); this.apply(); },
  apply() {
    const dark = this.mode === 'dark' ||
      (this.mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
    document.documentElement.classList.toggle('dark', dark);
  }
});
```

## 策略五：混合适配

因为站点颜色用法混杂，采用两条腿走路：

- **语义 token**：靠 CSS 变量自动翻转，零改动。
- **原生 Tailwind 色孤岛**：逐元素补 `dark:` 变体。这里有一条铁律——**零回归**：只追加 `dark:` 变体，绝不新增/修改明亮态的类。典型映射：

```text
bg-white        → 追加 dark:bg-gray-800
text-gray-700   → 追加 dark:text-gray-200
border-gray-200 → 追加 dark:border-gray-700
```

**这里容易踩一个「好心办坏事」的坑**：给输入框加暗色时，顺手补一个 `bg-white` 想让明亮态「更明确」。但原文件的输入框根本没有显式背景类（靠浏览器默认白底），加 `bg-white` 就凭空多了一个原本不存在的明亮态类——**违反了零回归原则**。正确做法是只加 `dark:bg-gray-800`，它会覆盖浏览器默认白底，明亮态保持原样不动。

> **零回归的可验证判据**：把改后文件里所有 `dark:` 变体剥离掉，剩下的内容应与原文件 `diff` 完全一致。这是保证「明亮态一个像素没变」的硬指标。

## 水墨风格页：「夜宣纸·淡墨夜读」暗色重设计

有些工具页会采用水墨风格设计，它用了一套**独立的** `--ink-*` CSS 变量，同时控制文字颜色和背景渐变/形状。这页不能简单套用「翻转变量」——简单翻转会破坏视觉意图：

- `.ink-icon` 用 `--ink-deep` 作渐变停止点，翻成浅色后变成「亮光环 + 暗字看不见」，而不是想要的「暗石 + 浅字」。
- `.ink-enso`（圆形印章）用深色 conic 渐变，在暗背景上直接消失。

这类页面的暗色需要专门重设计（以「夜宣纸·淡墨夜读」为题的一种思路）：给这些类加**自定义 `html.dark` 覆盖**，用新值重建视觉意图，而不是机械翻转——Ensō 改成月白银钩、图标改成墨石质感、朱砂红作点睛。

> 通用教训：设计敏感的页面（艺术风格、渐变/形状/mask 多的），别指望简单翻转变量。要针对受影响的类加自定义暗色覆盖，重建原本的视觉意图。

## 三个典型缺陷与修复

结构性改完后，**真实浏览器 + `getComputedStyle` + WCAG 对比度取证**的验证阶段，往往会揪出三个典型缺陷。它们都很典型：光看代码看不出来，必须渲染后量。

### 缺陷一：属性选择器特异性——暗底暗字不可见

**现象**：水墨风格页的主题切换 pill，选中态在暗色下「暗底暗字」几乎看不见。

**根因**：选中 pill 的样式用了属性选择器 `.ink-pill[aria-pressed="true"]`，而 `html.dark` 的覆盖规则漏掉了 `color` 属性。属性选择器带有一定的特异性，暗色下背景翻了但文字颜色没翻，两者都偏暗。实测对比度只有 **cr 1.15**，完全不可读。

**修复**：补全暗色覆盖，把 `background` / `color` / `border-color` 一起显式声明：

```css
html.dark .ink-pill[aria-pressed="true"] {
  background: /* 暗色选中背景 */;
  color:      /* 暗色选中文字，与背景拉开对比 */;
  border-color: /* ... */;
}
```

修复后对比度升到 **cr 13.86**，清晰可读。

### 缺陷二：实心 bg-accent 按钮白字，暗色对比崩坏

**现象**：实心强调色按钮（`bg-accent` + `text-white`）在暗色下对比度崩了，实测 **cr 2.57**，不达 WCAG。

**根因**：暗色下 `--color-accent-rgb` 被调亮（为了在暗背景上更醒目），但按钮文字写死 `text-white`。亮 accent 底 + 白字，对比自然不够。

**修复**：把 `text-white` 换成 `text-page-bg`（即 `rgb(var(--color-bg-rgb))`）。妙处在于：

- **明亮态**：`--color-bg-rgb` = `255 255 255`，`text-page-bg` 逐字节等于原来的白色，**零回归**。
- **暗色态**：`--color-bg-rgb` 是深色，深字配亮 accent 底，对比度升到 **cr 6.83**，达标。

一个改动同时满足两态，且明亮态纹丝不动。

### 缺陷三：@media print 被 Tailwind 类压制

**现象**：打印时页面没有回到白底黑字，暗色被打印了出来。

**根因**：原来在 `@media print` 里想覆盖颜色，但**媒体查询本身不增加选择器特异性**。`body` 上的 Tailwind 工具类（如 `bg-...`）特异性压过了 `@media print` 里的普通规则，导致打印覆盖失效。

**修复**：不在 `body` 层面较劲，而是回到 CSS 变量这个真相源——在 `@media print` 里对 `:root, html.dark` 两级把 7 组 RGB 三元组统一覆盖回明亮值：

```css
@media print {
  :root, html.dark {
    --color-bg-rgb: 255 255 255;
    --color-text-primary-rgb: 17 24 39;
    /* ... 7 组全部覆盖为明亮值 */
  }
}
```

因为所有 token 都从这批变量派生，覆盖变量就等于把整页颜色拉回明亮，绕开了特异性战争。修复后打印为纯白底黑字。

## 验证方法论

三个缺陷都印证了同一套方法论：

1. **必须真实浏览器验证**：`getComputedStyle` 读实际生效的颜色，而不是看源码猜。CSS 特异性、媒体查询、级联顺序，只有渲染后才知道真相（这和「命令行返回 200 不等于浏览器可用」是同一个道理——验证手段必须匹配故障发生的层面）。
2. **WCAG 对比度取证**：用对比度公式量化 cr 值，达标线明确（正文 4.5、大字 3.0），不靠肉眼「感觉还行」。三个缺陷分别对应 cr 1.15→13.86、2.57→6.83、打印白/黑。
3. **明亮态零回归用 diff 保证**：剥离 `dark:` 变体后与原文件逐字节 diff 一致，这是「没弄坏明亮态」的客观证据，而不是主观判断。

## 小结

给既有站点加暗色模式，工程上的关键是：**用 CSS 变量做单一真相源**（语义 token 自动翻转）、**混合策略**（token 翻转 + 原生色孤岛补 `dark:`）、**pre-paint 脚本防 FOUC**、**Alpine store 管三态 + 实时跟随**，最后靠**真实浏览器 + 对比度取证 + diff 零回归**三件套验收。设计敏感页单独重做，别指望一刀切。

把这一类问题汇总成一份「问题 → 根因 → 解法 → 可复用经验」的清单，是把经验固化成清单的好办法。
