Cornhsu.XamlContrast
桌面端的對比度工具全是「執行期+手動+一次一個元素」——滑鼠停在元素上、點兩個像素,等於開 App、切深淺主題、一頁一頁用眼睛看。XamlContrast 反過來:不開 App,直接解析 XAML 原始碼,算出每段文字實際疊在什麼顏色上,深淺主題各算一次 WCAG 比值,低於 AA 就讓 CI 紅燈。
$ dotnet tool install -g Cornhsu.XamlContrast $ xamlcontrast src/MyApp # 全專案掃描 → 報告 + exit code(CI 把關) palette: auto-detected theme pair: DarkTheme.xaml + LightTheme.xaml (6 keys) files 30 | text-on-background pairs 706 exempted 21 disabled-state pair(s) # WCAG 1.4.3 豁免,但一樣計數回報 ok 589 fail 3 warn 0 decorative 115 ===== fail (3) ===== MainWindow.xaml:30 TextBlock fg=White bg={Surface} dark=16.72:1 light= 1.00:1 [light-fails] MainWindow.xaml:26 TextBlock fg={DimText} bg={Bg} dark= 2.11:1 light= 1.60:1 [both-low] Theme.xaml:6 Style[HoverBtn]/trigger fg=#9A9A9A bg={Surface} dark= 5.92:1 light= 2.81:1 [light-fails] exit 1: 3 pair(s) below threshold × 2/3 (0 warn)
別人幫你「看見」對比度不足,XamlContrast 幫你「擋住」它
桌面端的無障礙對比度檢查,工具其實不少——Accessibility Insights for Windows、Colour Contrast Analyser 都很成熟。但它們有一個共同形狀:執行期、手動、一次一個元素。要先把程式跑起來,把滑鼠停在那個元素上,或是用滴管去點兩個像素;要檢查深淺兩套主題,就把整套流程再走一遍。實務上的結果是——沒有人真的每次改動都做這件事。
XamlContrast 換一個切入點:對比度的答案,其實在原始碼裡就算得出來。解析 XAML 樹,沿著父節點往上找出每段文字實際生效的背景(Transparent 要穿透、半透明要合成、Opacity 沿樹累乘、ControlTemplate 自成子樹、Style 與觸發器要逐狀態解析),再套 WCAG 2.x 公式,深淺兩套主題各算一次。整個過程是純 XML 處理(詳見下方技術亮點)——不開 App、不需要 Windows,Linux CI 上也跑得動,一個指令掃完整個專案,低於 AA 就 exit 1,並在 PR 的那一行留下註記。
這句話是整個專案的憲法,也決定了它每一個設計:豁免、排除、壓抑、解析失敗全部計數並喊出來;解析不到的顏色標成 unresolved 而不是用猜的;連「一個配對都沒解析到」也是 exit 1——因為綠燈的意思必須是「我看過了而且沒問題」,不能是「我什麼都沒看到」。
它守的是「你沒在看的那個主題」
你在深色模式下調了一個顏色,看起來很好。同一個色票在淺色主題底下,可能已經掉到 1:1——白字白底,完全看不見,而你不會發現,因為你今天沒切到淺色模式。每個配對都在兩套主題各算一次,對稱欄位會直接告訴你是哪一邊壞了。
MainWindow.xaml:30 · [light-fails] · 深色主題下的設計意圖沒有跨到另一套主題
v0.3.0,四個真實產品驗證過
| 發佈 | NuGet dotnet tool(v0.1.0 → v0.3.0),推 tag 即 OIDC Trusted Publishing 自動上架(repo 零長效金鑰);分析本身是純 XML,Linux CI 也能跑,不需要 Windows 或 WPF 執行期 |
| 真實驗證 | 四個已出貨的 WPF 產品、250+ 處真實對比問題修畢——工具驅動修正,前後成績由工具自己稽核 |
| 雙實作互證 | PowerShell 原型(1,080 行,當規格)與 .NET 移植在四個真實專案上數字完全一致——含每筆 fail 的 file:line 與退出碼,verify-baselines.ps1 隨時可重跑 |
| 測試 | 61 條,含 WCAG 標準值、13 條解析規則、色盤偵測四形狀、baseline/ignore,以及八個「看起來健康但錯」報告的回歸形狀 |
| 解析規則 | 13 條,每一條都來自真實產品的實查(誤報或漏報),沒有一條是在白板上想出來的 |
| 零設定 | 色盤偵測 4 種形狀:主題配對/C# 真相源/單一主題/無色盤退路(大聲警告)——四個受測專案全部零設定跑得起來 |
| CI 整合 | GitHub Action(PR 行內註記)、SARIF → Security 分頁、baseline ratchet;自家 CI 用故意壞掉的 demo 專案預期 exit 1——跑出 0 就是守門員自己壞了 |
拿自己已經出貨的三個產品當白老鼠
不是「跑得動 demo」就宣稱有用。三個已經在用的 WPF 產品交給它掃,掃出來的問題一路修到底,前後成績由工具自己稽核。其中一個專案的 23 處真問題全部是具名 Style 的觸發態——hover、pressed 這些手動測試很難逐一走到、作者自己也不知道存在的狀態。
| 專案 | 導入前 | 導入後 | 備註 |
|---|---|---|---|
| CelFlow | 破 39 / 偏低 57 | 0 | 21 組停用態依 WCAG 1.4.3 豁免(計數回報,不評分) |
| Kindling | 破 41 / 偏低 14 | 真問題 0 | 殘餘 3 筆全數對得上文件化的假警報類型 |
| QuillNest | 破 13 / 偏低 31 | 真問題 0 | 殘餘 7 筆同上;一併被 baseline 吸收 |
順帶的收穫有兩個。其一,某個專案的寫死色碼從 572 個降到 0——寫死的顏色不跟主題走,在報告裡會整片亮起來,等於順手把色盤治理做完了。其二,一次真實修正中,直覺的方向(把紗罩調深)在數學上是錯的:淺色主題的前景本來就是深灰,紗罩要更淡才對。「這樣好讀嗎」可以吵一整天,「2.54:1,需要 4.5」是一個決定——數字讓推理可以被驗證。
六個值得一提的設計
真正的難題不是 WCAG 公式
對比度公式只有 20 行,網路上到處都是;價值全部在「這段文字實際疊在什麼顏色上」——Transparent 要往上穿透、半透明 ARGB 要合成、Opacity 沿樹累乘(Border 0.5 裡的 TextBlock 0.5 = 0.25)、ControlTemplate 自成子樹、Style 的 Background/Foreground 兩個 Setter 要配對、觸發器逐狀態檢、具名 Style 跨檔索引 + BasedOn 鏈揉平 + 同條件觸發器合併、模板根背景、TargetName 指向模板根的 Setter(依 WPF 優先序蓋過 TemplateBinding 進來的宿主本地值)。抄公式的實作全都會漏掉這些。
「對稱」是獨立維度,不能當成「刻意」的證據
原型犯過最嚴重的錯,是把「深淺兩邊都低而且差不多」判成設計意圖並藏起來。實測後果:一個專案 52 組低於 AA 被吃掉;另一個單一主題專案(深淺同值,差距恆為 0)96 組全被吃掉,含 9pt 小字 1.75:1。改成兩個維度各答各的:等級(絕對對比:fail/warn/ok/裝飾)× 對稱(兩邊皆低=色票本身不夠、換主題救不了;深色壞/淺色壞=設計意圖沒跨主題保住)。兩邊一樣糟不是意圖,只是糟兩次。
零設定的色盤偵測
工具得自己找到色盤在哪:主題配對(DarkTheme.xaml + LightTheme.xaml 鍵重疊)→ C# 真相源(("Key","#dark","#light") 元組陣列;關鍵規則是「提供兩套主題的來源,優先於只有一套的」)→ 單一主題 → 找不到就退回只算寫死色碼並大聲警告。四個受測專案剛好四種形狀,全部零設定。猜錯可用 xamlcontrast.config.json 覆寫,設定寫錯是 exit 2 加欄位級訊息——被靜默忽略的設定,等於你以為改了稽核標準但其實沒改。
退出碼堵住「空掃綠燈」
有 fail → exit 1;配對數為 0 也 → exit 1(一次重構讓所有配對變成無法解析,綠燈放行就是謊報健康);用法錯誤 → exit 2。--fail-on warn 給法遵場景(AA 全過才算過)、--strict-palette 讓「色盤沒偵測到」也能是紅燈。
導入模式:baseline ratchet
真實專案第一次跑就是破 39~55 組,第一天全紅的 gate 會被關掉,關掉的 gate 價值是 0。第一次跑凍結既有債並進 repo,之後只擋新增與惡化——債只准減不准增。鍵不含行號(行號會漂移)但記錄最差比值:色票值被調暗時鍵不變,少了比值欄位就會靜默放行。xamlcontrast-ignore 註解理由必填、壓掉的項目計入 summary.suppressed——ignore 不准變成新開的靜默通道。
退化必須機器可讀
JSON 分兩層:findings 之外一定有 summary,帶齊每一個退化計數器(paletteSource/unresolved/skipped/suppressed/parseErrors/disabledExempt)。CI 只消費 findings 的話,「工具有東西沒看到」這件事等於不存在。另有 --sarif 直接進 GitHub code scanning。解析不到的顏色(Binding/TemplateBinding)一律標 unresolved,絕不推測——拿誠實的不確定去換好看的確定性,是這個專案唯一不能犯的錯。
規畫書 → 先修工具 → 每條規則都來自實查 → 雙實作互證
先修工具,再修專案
逐行審查原型,抓到四個報告層 bug(Style 配對被 $null -le 0 靜默全判成裝飾、「低於 AA」把裝飾算進去…)。修完重出基準線的那一刻,「已經修好」的專案裡浮出一整批被吃掉的真問題——這是整個專案的定調事件:先讓工具值得相信,再拿它去下判斷。
每條解析規則都指得出實查來源
規則從 8 條長到 13 條,來源全部有名有姓:QuillNest CheckBox 的死 setter 誤報、CelFlow 觸發態與停用態、Kindling 模板根背景……第 12 條是在驗收工具自己驅動的修正時發現的。沒有白老鼠的平台支援只是沒驗證過的猜測——所以 WinUI 3/Avalonia 的擴充也明文卡在「要有真實專案」這個前置條件上。
兩套獨立實作互證
移植時刻意不逐位元組複刻即將丟棄的原型輸出,而是用 C# 直接實作最終合約(英文+JSON),驗收改用語意比對。結果是這個工具最強的正確性證據:兩個不同語言的獨立實作,在四個真實專案上數字完全一致,連每筆 fail 的 file:line 與退出碼都對得上,而且隨時可重跑。
每次「看起來健康但錯」都變成測試
開發期間前後八次產出健康但錯誤的報告,每一次都固化成回歸測試。這類 bug 的可怕之處在於它不會讓你發現——報告漂漂亮亮,問題還在產品裡。
「不做」也明文記錄
npm 通路暫緩(受眾是 XAML/.NET 開發者,人人都有 dotnet,dotnet tool install 不構成採用門檻——受眾論證不成立就不付維護成本)、TargetName 指向模板內部元素不做、自動修不做(換哪個色票是設計決定)、執行期取樣不做(那是 Accessibility Insights 的地盤)、APCA/WCAG 3 不追草案。每條都有寫下來的理由。
取了什麼、捨了什麼、為什麼
| 決定 | 取 | 捨 | 理由 |
|---|---|---|---|
| 靜態解析原始碼,非執行期取樣 | 不開 App、掃全專案、進得了 CI、Linux 也能跑 | 圖片上的文字、執行期才決定的顏色 | 執行期取樣是 Accessibility Insights 的地盤,重做一次沒有價值 |
| 對稱不得用來隱藏 finding | 報告可信 | 報告數字好看 | 「兩邊一樣糟」不是意圖的證據,只是糟兩次 |
| 解析不到就標 unresolved | 誠實的不確定 | 覆蓋率數字 | 猜錯的顏色會產生假警報,信任一旦掉了就回不來 |
| 0 配對 = exit 1 | 堵住空掃綠燈 | 「至少不會誤擋」的直覺 | 綠燈的意思必須是「我看過了而且沒問題」 |
| baseline ratchet | 舊專案今天就能導入 | 「零 fail 才給過」的純粹 | 第一天全紅的 gate 會被關掉,關掉的 gate 價值是 0 |
| ignore 理由必填+計數 | 壓抑行為留下痕跡 | 一行註解就靜音的方便 | 否則 ignore 就是自己新開的一條靜默退化通道 |
從引擎到上架的具體組成
| 套件 | Cornhsu.XamlContrast(dotnet tool,指令 xamlcontrast) · MIT · .NET 10 |
| 架構 | Core/CLI 分離:解析引擎(XAML 樹走訪 · Style 跨檔索引 · 色盤偵測 · WCAG 計算 · 分級)+薄 CLI 外殼;純 XML 處理,不依賴 WPF 執行期 |
| 解析規則 | 13 條:透明穿透 · alpha 合成 · Opacity 累乘 · ControlTemplate 子樹 · Style setter 配對 · 觸發器狀態 · 死 setter 過濾 · BasedOn 鏈揉平 · 逐狀態觸發器合併 · 停用態豁免 · 半透明色票 · 模板根背景 · 模板根 TargetName |
| 色盤偵測 | 主題配對 · C# 真相源 · 單一主題 · 無色盤退路(皆可用 xamlcontrast.config.json 覆寫) |
| 輸出 | Console · JSON(兩層,帶 schemaVersion 與全部退化計數器)· SARIF 2.1.0(GitHub code scanning)· 退出碼合約 |
| 品質 | 61 條測試 · 四個真實產品驗證 · 兩套獨立實作數字互證 · CI 用故意壞掉的 demo 專案守門(預期 exit 1) |
| 發佈 | OIDC Trusted Publishing(無長效金鑰) · 推 tag 即自動測試、打包、上架 NuGet · GitHub Action(PR 行內註記) |
MIT 授權開源。裝完直接 xamlcontrast path/to/your/wpf/project,零設定就能掃;舊專案先 --write-baseline 凍結既有債,CI 只擋新增與惡化。README 含快速開始、色盤偵測四形狀、baseline/ignore、GitHub Action 教學,以及一份誠實的已知盲區清單。姊妹專案 Parity 守的是「實作有沒有照設計做」,這一支守的是「人到底看不看得清楚」——在同一份 PR 檢查清單裡會合。