Cornhsu.XamlContrast
0.3.0
Prefix Reserved
dotnet tool install --global Cornhsu.XamlContrast --version 0.3.0
dotnet new tool-manifest
dotnet tool install --local Cornhsu.XamlContrast --version 0.3.0
#tool dotnet:?package=Cornhsu.XamlContrast&version=0.3.0
nuke :add-package Cornhsu.XamlContrast --version 0.3.0
<img src="icon.png" width="28" alt=""/> XamlContrast
Static WCAG contrast audit for XAML source. No app launch, whole-project scan, CI-ready exit codes.
Every existing desktop contrast checker is runtime + manual + one-element-at-a-time (Accessibility Insights hovers one element; CCA picks two pixels). XamlContrast flips that: it parses your XAML source, figures out what color every piece of text actually sits on, computes WCAG 2.x contrast for both your dark and light themes, and fails your CI when text falls below AA.
dotnet tool install -g Cornhsu.XamlContrast
xamlcontrast path/to/your/wpf/project
Requires the .NET 10 runtime (the analysis itself is pure XML — runs on Linux CI too). 0.x: pin exact versions (
--version 0.2.0). Interfaces freeze at 1.0.
What it looks like
Run against samples/demo (intentionally broken):
palette: auto-detected theme pair: Themes\DarkTheme.xaml + Themes\LightTheme.xaml (6 keys)
files 1 | text-on-background pairs 8
exempted 1 disabled-state pair(s) (IsEnabled=False; WCAG 1.4.3 ...)
suppressed 1 pair(s) via xamlcontrast-ignore comments
ok 3
fail 3
decorative 1
===== fail (3) =====
MainWindow.xaml:30 TextBlock fg=White bg={Surface} dark=16.67:1 light= 1:1 [light-fails] need 4.5 12px
MainWindow.xaml:26 TextBlock fg={DynamicResource DimText} bg={Bg} dark= 2.11:1 light= 1.6:1 [both-low] need 4.5 12px
MainWindow.xaml:6 Style[HoverBtn]/trigger fg=#9A9A9A bg={Surface} dark= 5.92:1 light=2.81:1 [light-fails] need 4.5
exit 1: 3 pair(s) below threshold x 2/3 (0 warn)
Why run it while developing
- Seconds instead of eyeballing. Without it, checking contrast means launching the app, flipping both themes, and judging every screen by eye — on your monitor, at your brightness. With it: one command before commit, whole project, every text-on-background pair.
- It guards the theme you're not looking at. You tune a color in dark mode and never notice light mode just dropped to 1.1:1. Every pair is computed for both themes; the symmetry column tells you which side broke.
- It checks states manual testing can't reach. Hover, pressed, trigger states,
what a
BasedOnchain actually resolves to — all 23 real issues found in one validation project were named-Style trigger states the authors never knew existed. - It pushes you toward a healthy palette. Hardcoded colors don't follow themes and light up in the report — one validation project went from 572 hardcoded colors to 0. The baseline's recorded ratios also catch palette drift (same key, darker value).
- It turns taste arguments into numbers. "Is this readable?" is endless; "2.54:1, needs 4.5" is a decision. In one real fix the intuitive direction (darken the scrim) was mathematically wrong — the numbers settled it.
- It moves the cost to the PR. The expensive path is ship → user report / compliance
audit → sweep the whole app. The cheap path is a red check with inline annotations
on the lines you just touched — and
--write-baselinelets legacy projects go green on day one, blocking only new debt.
In short: it makes contrast a first-class check, same rank as unit tests — run on every change, red when broken, pointing at the exact line.
Two independent dimensions per finding:
- grade (absolute contrast):
fail< threshold×2/3,warnin between,ok,decorative - symmetry (across themes):
both-low= the palette itself is too weak;dark-fails/light-fails= the design intent didn't survive the other theme
Symmetry is never used to hide a finding. "Both themes are equally bad" is not evidence of intent — it's just bad twice. Everything below AA gets reported; a human decides.
Why the hard part isn't the WCAG formula
The formula is 20 lines. The value is in resolving what color the text actually sits on — thirteen parsing rules, each one discovered by auditing real shipped products:
transparent passthrough · alpha compositing · opacity accumulation down the tree · ControlTemplate subtrees · Style setter pairing · trigger states · dead-setter filtering · named/inline Style resolution with BasedOn chains · per-state trigger merging · disabled-state exemption (WCAG 1.4.3) · translucent palette keys · template-root backgrounds · root-targeted trigger setters
Any implementation that just copies the formula misses all of them.
Zero-config palette detection
XamlContrast scans your project and figures out where the palette lives:
- Theme pair —
DarkTheme.xaml+LightTheme.xamlwith overlapping keys - C# source of truth —
("Key", "#dark", "#light")tuple arrays (beats a single-theme XAML copy: a source that provides both themes wins) - Single theme — one resource dictionary, both columns get the same value
- Nothing found — falls back to hardcoded colors only, and says so loudly
Wrong guess? Override with xamlcontrast.config.json —
schema (zh-Hant; the JSON example at the top is self-explanatory).
CI
xamlcontrast src/MyApp --json report.json # exit 1 on any fail
xamlcontrast src/MyApp --fail-on warn # strict: everything must meet AA
xamlcontrast src/MyApp --sarif audit.sarif # GitHub code scanning format
Exit codes: 1 on failure, 1 when zero pairs resolved (an empty scan is not a pass),
2 on usage errors, 0 otherwise.
Adopting on an existing project (baseline ratchet)
A gate that's red on day one gets turned off. Freeze the current debt once, then only block new or worsened failures — debt may only shrink:
xamlcontrast src/MyApp --write-baseline xamlcontrast-baseline.json # once; commit the file
xamlcontrast src/MyApp --baseline xamlcontrast-baseline.json # in CI
Baseline keys contain no line numbers (lines drift) but do record the worst ratio (so palette drift — same key, darker value — still gets caught).
Suppressing intentional low contrast
<TextBlock Opacity="0.4" Text="DRAFT" ... />
The reason is required — an ignore without one is invalid and warned about.
Suppressed pairs are counted in summary.suppressed; nothing disappears silently.
GitHub Action
- uses: HSU-YU-MING/cornhsu-xamlcontrast@v0.1.0 # 0.x: pin exact version
with:
root: src/MyApp
Failing pairs show up as inline annotations on the PR.
JSON output
--json report.json writes a two-layer report: findings (one entry per pair) plus a
summary block carrying every degradation counter — paletteSource, unresolved,
skipped, suppressed, parseErrors, disabledExempt. If the tool couldn't see
something, that fact is machine-readable. Consumers should check schemaVersion.
Validated against real products
Not "runs on a demo" — this tool found and drove fixes for 250+ real contrast issues across three shipped WPF apps (before/after audited by the tool itself):
| project | before | after |
|---|---|---|
| CelFlow | 39 fail / 57 warn | 0 (21 disabled-state exempted) |
| Kindling | 41 fail / 14 warn | 0 real (3 remaining, all verified false alarms of known classes) |
| QuillNest | 13 fail / 31 warn | 0 real (7 remaining, same) |
How that trust was earned — the highlights, with the full story in the development retrospective (zh-Hant):
- Two independent implementations cross-verified. The PowerShell prototype (the spec)
and this .NET port agree on every number across all four validation projects —
down to each failing pair's
file:lineand the exit code. Re-run it yourself:scripts/verify-baselines.ps1. - Every parsing rule came from a real false alarm or a real miss — none were designed at a whiteboard. The 12th (template-root backgrounds) was found while verifying the fixes the tool itself had driven.
- The tool produced eight "healthy-looking but wrong" reports during development. Each one is now a regression test. The project's core rule, written in blood: an audit tool's worst failure mode is not missing issues — it's false confidence. Degradations must shout; nothing gets silently excused — exemptions, exclusions, suppressions, and parse failures are all counted and reported.
- Remaining fails are named, not hidden: every residual finding above maps to a documented false-alarm class (see Known limitations) and is absorbed by the baseline ratchet on adoption.
Known limitations
Honest list — full blind-spot table with per-case evidence in the planning doc (zh-Hant):
TargetNamesetters aimed at inner template parts (root-targeted ones are resolved)- Cross-element correlated triggers (same condition flips fg on one element, bg on another) — false alarms
- Sibling-element backgrounds; text over images; implicit styles
Binding/TemplateBindingcolors are reported as unresolved, never guessed — guessing would trade honest uncertainty for false confidence
See also
Parity — sibling project, same author, same philosophy (numeric checks that gate CI). Parity answers "does the implementation match the design?" (Figma vs rendered values); XamlContrast answers "can people actually read it?" One guards fidelity, the other guards legibility — they meet in the same PR checks list.
License
MIT © 許彧銘 Hsu Yu-Ming
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.