[test] Add a palette contrast contract for the accessibility reports - #49049
[test] Add a palette contrast contract for the accessibility reports#49049michelengelen wants to merge 5 commits into
Conversation
Deploy previewBundle size
Check out the code infra dashboard for more information about this PR. |
JCQuintas
left a comment
There was a problem hiding this comment.
Two things are a bit misleading I think.
There are a lot of Math.round which makes the testing not really exact, which I suppose can hide/dismiss actual contrast failures.
On the same note, it seems we convert from hex to rgba and back quite often. We should probably try to use a single format internally (RGBA), and convert to hex only when necessary for output/display.
measurePaletteContrast returned two-decimal values. The failing-set test compared them against the 4.5:1 threshold. Rounding before a threshold check can flip a color: a true 4.4966:1 fails 1.4.3 but rounds to 4.5. The helper now returns exact ratios. The pin test rounds them to compare against the two-decimal claim. The pinned contract keeps two decimals: that precision is verifiable with public checkers, and the main hex already pins drift exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HgD6GEW3UxNRvC2abbTy9
onMain and onPaper named only the background of each pair. The new names state foreground on background: contrastTextOnMain for filled surfaces, mainOnPaper for colored text on paper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HgD6GEW3UxNRvC2abbTy9
`contrastRatio` round-tripped the composited foreground through `toHex`, losing sub-8-bit precision. Opaque foregrounds were unaffected, but translucent ones (the rgba text colors this module exists to rate) shifted in the second decimal: `action.active` on the 4% surface read 4.4768 instead of 4.4963. Split private `compose` and `luminance` helpers that operate on `Rgba`, keep `toHex` at the string boundary, and route `blend` and `contrastRatio` through them. `blend` still returns 8-bit hex, the precision a stylesheet holds, so its output is unchanged. Also corrects the cited boundary ratio: `success` on the filled input surface is 4.4992:1, not 4.4966:1.
`requiredRatio` guessed on input it could not read. `Number('bold')` is NaN,
so `NaN >= 700` was false and 14pt bold got the 4.5:1 threshold instead of
3:1. A non-px, non-rem size fell through to a bare `parseFloat`, so '1.5em'
sized as 1.5px. Both failures were silent, in the one number the conformance
guard rests on.
Sizes now accept px, rem, or a number, and weights accept a number or the
absolute keywords 'normal' and 'bold'. Anything else throws, naming the
input: 'em' and 'bolder' depend on the render tree, which a theme value
cannot resolve.
Also names the two thresholds as WCAG_MINIMUM_RATIO so the enforcement test
states which one it means, and adds `failsWcag` so consumers classify
through a helper that takes an exact measurement rather than reaching into
the rounded PALETTE_CONTRAST rows themselves.
JCQuintas
left a comment
There was a problem hiding this comment.
I've applied fixes to my two comments already, which I think would be easier than explaining it in full (on the case of the color parsing)
| } else { | ||
| px = parseFloat(style.fontSize); | ||
| } | ||
| const weight = Number(style.fontWeight ?? 400); |
There was a problem hiding this comment.
if style.fontWeight==='bold' this will NaN.
This would need proper text > number lookup
| throw new Error(`blend() needs an opaque background, got "${background}". Resolve it first.`); | ||
| } | ||
| const effectiveAlpha = alpha * fg.a; | ||
| return toHex({ |
There was a problem hiding this comment.
Probably a good idea to return rgba from obj from here instead of hex. Only format to hex at the places that really need it.
Like, in the contrastRatio we go from hex > blend() > rgba > hex > wcagLuminance > rgba.
probably a good idea to have blend return RGBA and wcagLuminance accept RGBA.
Then if we need to get the result of blend in hex is simply toHex(blend(...))
Part of the accessibility conformance program (#48916 and the component report PRs). The component reports rate WCAG 1.4.3 Contrast (Minimum) against the default palette. The first guard for these facts lived in
Button.test.jsand parsed the report's markdown table. That was brittle, and the palette facts are not Button-specific — every component that paints palette text inherits them (review thread-adjacent discussion). This PR moves the guard to the theme level.What this adds
Three files with distinct roles:
packages/mui-material/test/contrast.ts— exact WCAG 2.x contrast math: luminance, ratio, alpha compositing (blend), andrequiredRatio(), which derives the 4.5:1 / 3:1 threshold from a typography variant instead of hard-coding it.packages/mui-material/test/contrastContract.ts— the consumable contract:PALETTE_CONTRAST(pinned hex + ratio per color, both directions),FAILING_PALETTE_COLORS(info,warning), andmeasurePaletteContrast(theme)to recompute the same shape from any theme.packages/mui-material/src/styles/paletteContrast.test.ts— the enforcement: a palette change fails this test in the PR that makes it. The failure message prints the new values and names the conformance reports to update.Why not
getContrastRatiofrom@mui/systemColor parsing is reused (
decomposeColor/hslToRgb). The math is not, for two reasons:getLuminancetruncates at three digits.successon the filled input surface sits at 4.4966:1, where truncation can flip the classification. The truncation cannot change:getContrastTextconsumes it at runtime, so more precision would change whichcontrastTextthe theme auto-picks.getContrastRatiorates a translucent foreground as opaque.text.secondaryon white measures 5.74:1 in the browser; the util reports 21:1. The engine composites first.Where the pinned values come from
Nothing is hand-invented.
mainis the palette constant (createPalette.js: primary =blue[700], …, warning hand-picked next toorange[800]); the ratios are the WCAG formula over those hex values. Any row is reproducible with a contrast checker, for example WebAIM:#ed6c02vs#fff→ 3.11:1. The constants are pinned because they mirror what the reports and the committed*.a11y.jsonfiles publicly claim — the test forces that claim to be re-checked whenever the palette moves.Follow-ups (separate PRs)
contrastTextonmain, text/outlined =mainonbackground.paper, so its failing set is exactlyFAILING_PALETTE_COLORS).PALETTE_CONTRAST, replacing the hand-written ones.