Skip to content

[test] Add a palette contrast contract for the accessibility reports - #49049

Open
michelengelen wants to merge 5 commits into
mui:masterfrom
michelengelen:a11y/contrast-contract
Open

[test] Add a palette contrast contract for the accessibility reports#49049
michelengelen wants to merge 5 commits into
mui:masterfrom
michelengelen:a11y/contrast-contract

Conversation

@michelengelen

Copy link
Copy Markdown
Member

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.js and 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), and requiredRatio(), 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), and measurePaletteContrast(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 getContrastRatio from @mui/system

Color parsing is reused (decomposeColor / hslToRgb). The math is not, for two reasons:

  1. getLuminance truncates at three digits. success on the filled input surface sits at 4.4966:1, where truncation can flip the classification. The truncation cannot change: getContrastText consumes it at runtime, so more precision would change which contrastText the theme auto-picks.
  2. getContrastRatio rates a translucent foreground as opaque. text.secondary on 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. main is the palette constant (createPalette.js: primary = blue[700], …, warning hand-picked next to orange[800]); the ratios are the WCAG formula over those hex values. Any row is reproducible with a contrast checker, for example WebAIM: #ed6c02 vs #fff → 3.11:1. The constants are pinned because they mirror what the reports and the committed *.a11y.json files publicly claim — the test forces that claim to be re-checked whenever the palette moves.

Follow-ups (separate PRs)

  • Component suites consume the contract instead of restating numbers (Button: contained = contrastText on main, text/outlined = main on background.paper, so its failing set is exactly FAILING_PALETTE_COLORS).
  • The scorecard tooling generates the reports' § 1.4.3 tables from PALETTE_CONTRAST, replacing the hand-written ones.

@code-infra-dashboard

code-infra-dashboard Bot commented Aug 28, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-49049--material-ui.netlify.app/
QR code for https://deploy-preview-49049--material-ui.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/material 0B(0.00%) 0B(0.00%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@JCQuintas JCQuintas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

michelengelen and others added 4 commits August 28, 2026 15:44
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 JCQuintas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread packages/mui-material/test/contrast.ts Outdated
} else {
px = parseFloat(style.fontSize);
}
const weight = Number(style.fontWeight ?? 400);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if style.fontWeight==='bold' this will NaN.

This would need proper text > number lookup

Comment thread packages/mui-material/test/contrast.ts Outdated
throw new Error(`blend() needs an opaque background, got "${background}". Resolve it first.`);
}
const effectiveAlpha = alpha * fg.a;
return toHex({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(...))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants