iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 11

Day 11 — 深色模式:設計 token 的回報

  • 分享至 

  • xImage
  •  

今天要解的問題

深色模式在很多專案裡是惡夢:色碼散落在幾百個規則裡,改一個地方就有三個地方對不上,最後變成「深色模式有 bug 但沒人想修」。

這個專案不會——因為 Day 2 定了一條紀律:元件樣式裡不准出現字面色碼,全站只有 :root 有 hex。 今天來收割。

三態,不是兩態

大部分教學只做「亮/暗切換」。實際上要三態:

狀態 行為
auto(預設) 跟隨系統 prefers-color-scheme
light 使用者明確選亮色
dark 使用者明確選暗色

為什麼要 auto:使用者的系統設定通常已經反映了他的偏好(晚上自動變暗)。如果只有兩態,第一次進站就得被迫做選擇,而且之後系統切換時網站不會跟。

實作:CSS 變數 + 兩個選擇器

:root {
  --bg: #f8fafc;
  --surface: #ffffff;
  --border: #e2e8f0;
  --text: #0f172a;
  --text-2: #475569;
  --text-3: #94a3b8;
  --accent: #4f46e5;
  --accent-soft: #eef2ff;
  /* … */
}

/* 1. 沒有明確選擇時,跟隨系統 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { /* 暗色 token */ }
}

/* 2. 使用者明確選暗色(覆蓋系統設定) */
:root[data-theme="dark"] { /* 同一組暗色 token */ }

暗色那組值:

  --bg: #0b1120;
  --surface: #131c2f;
  --border: #24304a;
  --text: #e6ecf5;
  --text-2: #a3b0c6;
  --text-3: #6b7a93;
  --accent: #818cf8;         /* 提亮:#4f46e5 在深底上太沉 */
  --accent-soft: #1e2544;
  --shadow: 0 1px 3px rgba(0,0,0,.4), 0 4px 16px rgba(0,0,0,.3);

為了避免同一組值寫兩次,用 CSS 巢狀選擇器合併:

@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0b1120; /* … */ } }
:root[data-theme="dark"] { --bg: #0b1120; /* … */ }

不想重複的話有兩條路:(a)把暗色 token 寫成一個 @mixin——但原生 CSS 沒有 mixin,要引入預處理器,違反 Day 1 的無建置原則;(b)用一個共用 class,JS 在 auto 且系統為暗時也加上 data-theme="dark"。我選 (b):

const Theme = {
  KEY: "learnpath-theme",
  read() {
    const v = localStorage.getItem(this.KEY);
    return (v === "light" || v === "dark") ? v : "auto";     // 淨化:只接受三個值
  },
  apply() {
    const pref = this.read();
    const sysDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
    const effective = pref === "auto" ? (sysDark ? "dark" : "light") : pref;
    document.documentElement.dataset.theme = effective;      // 只有 light / dark 兩種值進 DOM
    return { pref, effective };
  },
  set(pref) { localStorage.setItem(this.KEY, pref); this.apply(); },
  init() {
    this.apply();
    /* auto 模式下,系統切換要即時跟上 */
    window.matchMedia("(prefers-color-scheme: dark)")
      .addEventListener("change", () => { if (this.read() === "auto") this.apply(); });
  },
};

這樣 CSS 只需要處理 [data-theme="dark"] 一個選擇器,@media 查詢完全不用寫。JS 負責「解析偏好」,CSS 負責「套用顏色」,職責乾淨。

read() 裡的白名單檢查是 Day 9 的習慣延續:任何從 localStorage 讀出來、會寫進 DOM 的值都要淨化dataset.theme = 使用者可控字串 雖然不會執行 script,但可以用來注入奇怪的屬性選擇器行為,不值得冒險。

避免閃白:<head> 裡最早執行

深色模式最惱人的體驗是進站閃一下白色:HTML 已經畫出來了,主題 script 還沒跑。

解法是把主題判斷放在 <head>,在任何 CSS 生效前執行:

<head>
  <meta charset="UTF-8">
  <!-- script src="js/theme.js" · 必須在 stylesheet 之前 -->
  <link rel="stylesheet" href="css/style.css">
</head>
/* js/theme.js 尾端:立即執行,不等 DOMContentLoaded */
Theme.apply();
document.addEventListener("DOMContentLoaded", () => Theme.init());

Theme.apply() 直接執行(此時 document.documentElement 已存在,<body> 還沒有),所以 data-theme 在第一次繪製前就設好了。事件監聽等 DOM 好了再綁。

注意:這裡不能用行內 script(很多教學都這樣寫)——Day 10 的 CSP 禁止 unsafe-inline。外部檔案多一個請求,但同源、極小、又能被快取,實測沒有可感知的差異。

切換 UI

放在頂欄,三態循環:

/* NavView 加一顆按鈕 */
renderThemeToggle() {
  const btn = document.getElementById("theme-toggle");
  if (!btn) return;
  const ICONS = { auto: "🌗", light: "☀️", dark: "🌙" };
  const NEXT  = { auto: "light", light: "dark", dark: "auto" };
  const draw = () => {
    const { pref } = Theme.apply();
    btn.textContent = ICONS[pref];
    btn.title = `主題:${pref}(點擊切換)`;
    btn.setAttribute("aria-label", `主題:${pref},點擊切換`);
  };
  btn.addEventListener("click", () => { Theme.set(NEXT[Theme.read()]); draw(); });
  draw();
}

NEXT 是狀態機的轉移表,寫成資料而不是 if-else 鏈。三態變四態只要改這個物件。

真正的工作:檢查對比度

把顏色反轉不叫深色模式,那只是把亮色配色倒過來,通常會得到刺眼的純白文字配純黑底。要做對的是三件事:

1. 不要用純黑背景。 #000000 配白字對比度是 21:1,會產生光暈(halation),長文閱讀特別累。用 #0b1120(帶藍的深灰)柔和很多。

2. 提亮強調色。 #4f46e5 在白底上很好看,在深底上會沉下去。暗色模式改用 #818cf8。這不是主觀判斷,是對比度算出來的:

/* 相對亮度與對比度(WCAG 公式) */
const lum = hex => {
  const c = hex.match(/\w\w/g).map(h => {
    const v = parseInt(h, 16) / 255;
    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
};
const contrast = (a, b) => {
  const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
};

console.log(contrast("#4f46e5", "#0b1120").toFixed(2));   // 2.31 ← 不合格
console.log(contrast("#818cf8", "#0b1120").toFixed(2));   // 6.42 ← 通過 AA

標準:正文 ≥ 4.5:1(WCAG AA)、大字(18pt 以上或 14pt 粗體)≥ 3:1

3. 六個類別色全部要重新檢查。 這是這個專案的特殊工作——CAT_META 的顏色是資料層的,不是 CSS 變數,所以不會被 :root 覆蓋。

/* 每個類別給亮/暗兩個值 */
const CAT_META = {
  foundation: { label: "基礎",     color: "#4f46e5", dark: "#818cf8" },
  stats:      { label: "統計方法", color: "#a21caf", dark: "#e879f9" },
  ml:         { label: "機器學習", color: "#e11d48", dark: "#fb7185" },
  research:   { label: "研究主題", color: "#475569", dark: "#94a3b8" },
  eng:        { label: "資料工程", color: "#0d9488", dark: "#2dd4bf" },
  cloud:      { label: "雲端",     color: "#d97706", dark: "#fbbf24" },
};

/* View 統一透過這個函式取色 */
const catColor = cat =>
  document.documentElement.dataset.theme === "dark" ? CAT_META[cat].dark : CAT_META[cat].color;

所有 View 的 CAT_META[cat].color 改成 catColor(cat)。全站約六處,一次改完。

代價:主題切換後,SVG 地圖需要重繪(因為顏色是 JS 寫進 attribute 的,不是 CSS):

btn.addEventListener("click", () => {
  Theme.set(NEXT[Theme.read()]);
  draw();
  if (window.HomeController && document.getElementById("course-map")) HomeController.init();
});

這是「顏色下沉成資料」的帳單。Day 2 我說它讓 SVG 與 CSS 共用同一份色票,今天要付的代價是主題切換得重繪 SVG。整體還是划算——重繪整頁是毫秒級(Day 6 已經確立了「重繪不 diff」的策略)。

寫一支對比度驗證器

顏色是資料,就能自動驗:

/* scripts/check-contrast.js */
const PAIRS_LIGHT = [["#0f172a", "#f8fafc"], ["#475569", "#ffffff"], ["#4f46e5", "#ffffff"]];
const PAIRS_DARK  = [["#e6ecf5", "#0b1120"], ["#a3b0c6", "#131c2f"], ["#818cf8", "#0b1120"]];

let bad = 0;
for (const [name, pairs] of [["light", PAIRS_LIGHT], ["dark", PAIRS_DARK]])
  for (const [fg, bg] of pairs) {
    const r = contrast(fg, bg);
    if (r < 4.5) { console.log(`✗ ${name} ${fg} on ${bg}: ${r.toFixed(2)}`); bad++; }
  }

/* 類別色在各自主題底色上也要過 3:1(它們用在大字與描邊) */
for (const [k, m] of Object.entries(CAT_META)) {
  const rl = contrast(m.color, "#ffffff"), rd = contrast(m.dark, "#0b1120");
  if (rl < 3) { console.log(`✗ ${k} light: ${rl.toFixed(2)}`); bad++; }
  if (rd < 3) { console.log(`✗ ${k} dark: ${rd.toFixed(2)}`); bad++; }
}
process.exit(bad ? 1 : 0);

這支接進 CI(Day 22),以後改配色改壞就是紅燈,不用靠眼睛。

踩到的雷

MathJax 的公式顏色。 MathJax 3 的 CHTML 輸出預設用 currentColor,所以公式會自動跟著 --text 變色——這點運氣不錯。但 .formula 區塊的背景我原本寫死 #f8fafc,暗色下就變成一塊突兀的亮區。改用 var(--accent-soft) 之後兩個主題都正常。

寫死的陰影。 box-shadow: 0 1px 3px rgba(15,23,42,.06) 在深底上根本看不見(深色上的深陰影=沒有陰影)。暗色模式的「浮起」要靠邊框變亮,不是靠陰影變深:

:root[data-theme="dark"] {
  --shadow: 0 1px 3px rgba(0,0,0,.4), 0 4px 16px rgba(0,0,0,.3);
}
:root[data-theme="dark"] .chapter-card:hover { border-color: var(--text-3); }

進度條的底色。 .pbar 的軌道用 var(--border),暗色下與卡片背景幾乎同色,看起來像沒有進度條。改成一個專用 token --track,兩個主題各給合適的值。這種「原本共用某個 token,深色模式下需要分家」的情況會出現三四處,是深色模式最主要的實際工作量。

驗證

node scripts/check-contrast.js         # 全部通過才 exit 0
node scripts/verify.js                 # 確認沒動壞結構
python3 -m http.server 8901
python3 scripts/smoke-test.py          # 既有測試仍要 0 failures

手動檢查清單:

  • 三態循環:🌗 → ☀️ → 🌙 → 🌗,重整後保持。
  • 系統切換主題時,auto 狀態要即時跟上(macOS/Windows 的自動深色)。
  • 重整時不閃白(這是 <head> 早載入的驗收點)。
  • 地圖節點顏色在暗色下清楚可辨。
  • 課文的公式、.formula 背景、callout 三色、測驗答對/答錯的紅綠,逐一目視。
  • 測驗的紅綠在暗色下仍可分辨(紅綠色盲使用者靠的是 icon 與文字,不只顏色——這點 Day 19 會補)。

小結與明天預告

今天的核心:深色模式的難度與你的顏色集中程度成反比。 Day 2 花的紀律,今天讓 CSS 部分幾乎是免費的;真正的工作是對比度檢查與「共用 token 要分家」的那幾處。

另一個收穫是把對比度寫成驗證腳本。顏色是資料,資料就能驗——這是 Day 10 那條原則的延伸。

明天做全站搜尋。不裝 Lunr、不裝 Fuse,自己寫一個中文可用的倒排索引,並解釋為什麼中文搜尋不能直接照搬英文的分詞做法。


上一篇
Day 10 — 雙層驗證:結構驗證器 + 瀏覽器 smoke test
系列文
知識圖譜 : 技能樹式學習歷程11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言