「怎麼用 JS 偵測頁面有沒有被強制反色」是一個問錯的問題,但值得完整回答,因為理解為什麼偵測不可靠,才能理解正確解法為什麼是宣告而不是偵測。

為什麼 JS 偵測不可靠

UA 的演算法反色發生在繪製階段,不在樣式計算階段。getComputedStyle(el).backgroundColor 回傳的是樣式計算的結果,也就是你宣告的值,不是螢幕上真正的像素顏色。Android WebView 的 algorithmic darkening 與 Chrome 的 Auto Dark Theme 都是這個模式。

同時,DOM 沒有任何 API 能讀回自己被繪製後的像素——html2canvas 之類的函式庫是重新渲染一次,不是截圖,所以它讀到的也是宣告值。CanvasgetImageData 只能讀你自己畫進 canvas 的內容,讀不到 DOM 的合成結果。

因此,沒有可靠、標準的 JS 方法能判斷頁面是否被 UA 反色。 網路上流傳的幾種嘗試都有明確的失敗模式:

嘗試為什麼不可靠
getComputedStyle 比對預期值讀到的是宣告值,反色不影響它
用 canvas 畫已知色再 getImageDatacanvas 內容不受 DOM 反色影響,永遠讀到原色
檢查 matchMedia('(prefers-color-scheme: dark)')UA 反色路徑下這個值是 false,正好相反
UA 字串嗅探版本一改就失效,且無法得知使用者的 Chrome 設定
比對 Canvas/CanvasText 系統色這些在 forced-colors 才有意義,反色模式下不變

有一個間接訊號在實務上偶爾有用:matchMedia('(prefers-color-scheme: dark)')false,但你知道自己的頁面在暗色環境常被回報異常——這只是統計上的懷疑,不是判定。

正確解法:讓觸發條件不成立

Android 官方文件把 algorithmic darkening 的觸發條件寫得很清楚,第一條就是「網頁內容沒有使用 prefers-color-scheme」。Chrome 的 Auto Dark Theme 也是針對「亮色主題網站」。所以正解是:

支援雙主題 → 條件不成立 → 反色不會發生。 不需要偵測,也不需要對抗。

最小組合(重複一次,因為這是本篇的結論):

<meta name="color-scheme" content="light dark">
:root {
  color-scheme: light dark;
  --bg: light-dark(#ffffff, #0e1013);
  --fg: light-dark(#16191d, #e6e8eb);
}
html, body { background: var(--bg); color: var(--fg); }

只做亮色的頁面則用 only light 明確退出:

<meta name="color-scheme" content="only light">
:root { color-scheme: only light; }

only 這個關鍵字的存在目的就是禁止 UA 覆寫,Chrome 官方文件把它列為 Auto Dark Theme 的退出方式;Android WebView 的darkening 也遵守作者的明確關閉。

局部退出:顏色即資料的區塊

即使整頁支援雙主題,仍有些區塊的顏色不能被任何機制改動。color-scheme 可繼承,所以可以只圈住那個子樹:

.color-swatches,      /* 色票 */
.print-preview,       /* 列印預覽 */
.map-legend,          /* 地圖圖例 */
.medical-image {      /* 醫療影像 */
  color-scheme: only light;
  forced-color-adjust: none;   /* 連 Windows 高對比模式也不要動 */
}

forced-color-adjust: none 要謹慎——MDN 明確說它應該用於支援使用者的顏色與對比需求,不是保住品牌色。上面這幾類(顏色本身承載資訊)是它少數的正當用途。

完整測試矩陣

雙主題的驗證不能只在桌機 DevTools 切一下。以下是可以逐格打勾的矩陣,優先級由上而下:

#環境設定預期結果
1桌面 Chrome系統亮亮色,捲軸/表單為亮
2桌面 Chrome系統暗暗色,捲軸/表單為暗
3桌面 Chrome系統暗 + 手動切亮全亮,含原生控件
4桌面 Chrome系統亮 + 手動切暗全暗,含原生控件
5桌面 ChromeSlow 4G + 硬重整(四種組合)第一幀無白閃
6桌面 Chrome停用 JavaScript跟隨系統偏好正確渲染
7桌面 ChromeRendering → Emulate auto dark mode網站已支援暗色,不應被額外反色
8桌面 Chrome/EdgeRendering → forced-colors: active版面可用、焦點環可見、狀態可辨
9Safari macOS系統暗暗色 + theme-color 染色工具列
10iOS Safari系統暗暗色;捲到頂/底無白邊
11iOS Safari加到主畫面(standalone)狀態列樣式合理
12Android Chrome系統暗 + Auto Dark Theme 開啟顯示你的暗色主題,非演算法版本
13Android Chrome協助工具字級 200%版面不破
14Facebook 內建瀏覽器app 淺色 / 深色各一次兩種都正確;記錄診斷頁輸出
15Instagram 內建瀏覽器同上同上
16LINE 內建瀏覽器同上同上,並確認 localStorage 是否可用
17自家 app WebView若有與設計一致

第 5、6、7、8 這四格是最容易被跳過、也最容易藏 bug 的。第 6 格(停用 JS)的預期結果值得再強調:應該跟隨系統偏好正確渲染。如果停用 JS 後只剩亮色,代表系統偏好的判斷被錯誤地放進了 JS。

桌面模擬工具速查

  • Chrome DevToolsCmd/Ctrl+Shift+PShow RenderingEmulate CSS media feature prefers-color-schemeEmulate auto dark modeEmulate CSS media feature forced-colorsEmulate CSS media feature prefers-contrast
  • Safari Web Inspector:Elements 面板工具列有亮/暗切換鈕。
  • Firefox DevTools:Inspector 右上角有亮/暗與 prefers-contrast 的切換。
  • Android 實機的 Auto Dark Themechrome://flags 啟用 #darken-websites-checkbox-in-theme-setting,再到設定 → 主題勾選。

模擬永遠只是近似。Auto Dark Theme 與 in-app WebView 的最終驗證必須在實機上做,因為模擬器的演算法版本、app 的注入行為都無法完整重現。

把驗證自動化

視覺回歸測試可以擋住大部分退化。Playwright 支援直接指定色彩配置:

// playwright.config.js 用兩個 project 跑同一組測試
projects: [
  { name: 'light', use: { colorScheme: 'light' } },
  { name: 'dark',  use: { colorScheme: 'dark'  } },
]
test('首頁視覺', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot();   // 兩個 project 各產生一張基準圖
});

對比檢查可以用 axe 之類的工具在 CI 跑,同樣要跑兩次:

const { AxeBuilder } = require('@axe-core/playwright');
test('對比檢查', async ({ page }) => {
  await page.goto('/');
  const r = await new AxeBuilder({ page }).withTags(['wcag2aa']).analyze();
  expect(r.violations).toEqual([]);
});

兩套主題各跑一次是這裡的重點。只跑一次的 CI 會給人「已經測過無障礙」的錯覺,而暗色主題的對比問題完全不會被抓到。

至於 Auto Dark Theme 與 in-app WebView,目前沒有成熟的自動化方案,只能靠上一篇的診斷頁做人工檢查表。誠實記錄「這一格是人工測的、上次測是什麼時候」,比假裝它被自動化涵蓋要有用得多。

下一步:常見錯誤與排錯手冊