diff --git a/packages/mui-material/src/styles/paletteContrast.test.ts b/packages/mui-material/src/styles/paletteContrast.test.ts new file mode 100644 index 00000000000000..7df91fa48ee100 --- /dev/null +++ b/packages/mui-material/src/styles/paletteContrast.test.ts @@ -0,0 +1,101 @@ +import { describe, it, assert } from 'vitest'; +import { createTheme } from '@mui/material/styles'; +import { + blend, + roundRatio, + roundedContrastRatio, + requiredRatio, + WCAG_MINIMUM_RATIO, +} from '../../test/contrast'; +import { + FAILING_PALETTE_COLORS, + PALETTE_CONTRAST, + failsWcag, + measurePaletteContrast, +} from '../../test/contrastContract'; + +/** + * Enforces the palette contrast contract (`test/contrastContract.ts`): the + * pinned 1.4.3 facts every component conformance suite consumes. A palette + * change fails here, in the PR that makes it, with the new values in the + * failure message. + */ +describe('palette contrast contract', () => { + const theme = createTheme(); + + it('pins the contrast ratios of every palette color', () => { + // The claim is stated at two decimals — the precision the reports and + // public checkers display — so round the exact measurement to compare. + const measured = measurePaletteContrast(theme).map((entry) => ({ + ...entry, + contrastTextOnMain: roundRatio(entry.contrastTextOnMain), + mainOnPaper: roundRatio(entry.mainOnPaper), + })); + + assert.deepEqual( + measured, + [...PALETTE_CONTRAST], + 'The default palette contrast ratios changed. Current values: ' + + `${measured.map(({ color, main, contrastTextOnMain, mainOnPaper }) => `${color} (${main}) ${contrastTextOnMain}:1 contrastText on main, ${mainOnPaper}:1 main on paper`).join('; ')}. ` + + 'Update PALETTE_CONTRAST in test/contrastContract.ts, the 1.4.3 sections ' + + 'of the affected conformance reports ' + + '(packages/mui-material/src//accessibility.md), and expect the ' + + 'recorded axe results (*.a11y.json) to change with the next regression run.', + ); + }); + + it('only info and warning fall short of WCAG 4.5:1, in both directions', () => { + // Classify on the exact ratios: rounding first can flip a color at the + // boundary (a true 4.4992:1 fails 1.4.3 but rounds to 4.5). + const failing = measurePaletteContrast(theme) + .filter((entry) => failsWcag(entry, WCAG_MINIMUM_RATIO.normalText)) + .map(({ color }) => color); + + assert.deepEqual( + failing, + [...FAILING_PALETTE_COLORS], + 'The set of default palette colors failing WCAG 4.5:1 changed. Update ' + + 'FAILING_PALETTE_COLORS in test/contrastContract.ts and re-check every ' + + 'conformance report that rates 1.4.3: the Known gaps entries name info ' + + 'and warning as the failing colors.', + ); + }); + + it('derives the 1.4.3 threshold from the typography a component renders with', () => { + // Button labels: 14px at weight 500 — not WCAG large text. + assert.equal(requiredRatio(theme.typography.button, theme.typography.htmlFontSize), 4.5); + // Body text: 16px regular — not large text either. + assert.equal(requiredRatio(theme.typography.body1, theme.typography.htmlFontSize), 4.5); + // h4: 34px regular — large text, the relaxed 3:1 threshold applies. + assert.equal(requiredRatio(theme.typography.h4, theme.typography.htmlFontSize), 3); + // 14pt bold (18.66px at weight 700) is the other large-text doorway. + assert.equal(requiredRatio({ fontSize: '18.66px', fontWeight: 700 }), 3); + assert.equal(requiredRatio({ fontSize: '18.66px', fontWeight: 500 }), 4.5); + // The CSS keyword is the same doorway as the number it stands for. + assert.equal(requiredRatio({ fontSize: '18.66px', fontWeight: 'bold' }), 3); + assert.equal(requiredRatio({ fontSize: '18.66px', fontWeight: 'normal' }), 4.5); + }); + + it('refuses a size or weight it cannot resolve instead of guessing', () => { + // `em` depends on the parent, so a theme value alone cannot size it. The + // old silent parseFloat read this as 1.5px and rated it as normal text. + assert.throws(() => requiredRatio({ fontSize: '1.5em' }), /cannot size/); + assert.throws(() => requiredRatio({ fontSize: '2rex' }), /cannot size/); + // `bolder` is relative to the parent for the same reason. The old + // `Number('bolder')` gave NaN, which silently failed the >= 700 test. + assert.throws( + () => requiredRatio({ fontSize: '18.66px', fontWeight: 'bolder' }), + /cannot weigh/, + ); + }); + + it('composites translucent surfaces the way the browser paints them', () => { + // The filled input surface: 6% black over white. + assert.equal(blend('#000', 0.06, '#fff'), '#f0f0f0'); + // A selected-state tint: warning.main at 8% over white (ToggleButton). + assert.equal(blend(theme.palette.warning.main, 0.08, '#fff'), '#fef3eb'); + // A translucent text color resolves against its background before rating: + // text.secondary (60% black) on white measures 5.74:1. + assert.equal(roundedContrastRatio('rgba(0, 0, 0, 0.6)', '#fff'), 5.74); + }); +}); diff --git a/packages/mui-material/test/contrast.ts b/packages/mui-material/test/contrast.ts new file mode 100644 index 00000000000000..ca0ba769a7f42c --- /dev/null +++ b/packages/mui-material/test/contrast.ts @@ -0,0 +1,196 @@ +/** + * Exact WCAG 2.x contrast math for the accessibility conformance guards. + * + * The conformance reports (`src//accessibility.md`) document which + * theme color pairs fail 1.4.3 Contrast (Minimum). These helpers let tests + * recompute those facts from `createTheme()` so a palette change cannot leave + * a report stale. + * + * Deliberately not `getContrastRatio` from `@mui/system`: `getLuminance` + * truncates luminance at three digits, which can flip a classification right + * at the 4.5:1 boundary (`success` on the filled surface sits at 4.4992:1). + */ +import { decomposeColor, hslToRgb } from '@mui/system/colorManipulator'; + +interface Rgba { + r: number; + g: number; + b: number; + a: number; +} + +function parseColor(color: string): Rgba { + let parsed = decomposeColor(color); + if (parsed.type === 'hsl' || parsed.type === 'hsla') { + parsed = decomposeColor(hslToRgb(color)); + } + if (parsed.type !== 'rgb' && parsed.type !== 'rgba') { + throw new Error( + `Unsupported color format: "${color}". The exact WCAG math covers sRGB colors only.`, + ); + } + const [r, g, b, a = 1] = parsed.values; + return { r, g, b, a }; +} + +function toHex({ r, g, b }: Rgba): string { + return `#${[r, g, b].map((channel) => Math.round(channel).toString(16).padStart(2, '0')).join('')}`; +} + +/** Parses a color that must be opaque, quoting the input when it is not. */ +function parseOpaqueColor(color: string, requirement: string): Rgba { + const parsed = parseColor(color); + if (parsed.a !== 1) { + throw new Error(`${requirement}, got "${color}". Resolve it against a surface first.`); + } + return parsed; +} + +/** + * Composites `foreground` at `alpha` (multiplied with the color's own alpha) + * over an opaque `background`. Channels stay fractional: nothing is quantized + * until a value leaves this module as hex. + */ +function compose(foreground: Rgba, alpha: number, background: Rgba): Rgba { + const effectiveAlpha = alpha * foreground.a; + return { + r: foreground.r * effectiveAlpha + background.r * (1 - effectiveAlpha), + g: foreground.g * effectiveAlpha + background.g * (1 - effectiveAlpha), + b: foreground.b * effectiveAlpha + background.b * (1 - effectiveAlpha), + a: 1, + }; +} + +/** WCAG 2.x relative luminance of an opaque color. */ +function luminance({ r, g, b }: Rgba): number { + const [lr, lg, lb] = [r, g, b] + .map((channel) => channel / 255) + .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)); + return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb; +} + +/** + * Composites `foreground` at `alpha` over an opaque `background`, as hex. Use + * it for surfaces the browser composes at paint time: a placeholder rendered + * at `opacity`, a selected-state tint of `alpha(main, selectedOpacity)`, the + * filled input surface. The hex is 8-bit, the precision a stylesheet holds. + * Rating that surface stays exact anyway: `contrastRatio` composites its own + * foreground internally rather than round-tripping through this. + */ +export function blend(foreground: string, alpha: number, background: string): string { + const resolvedBackground = parseOpaqueColor(background, 'blend() needs an opaque background'); + return toHex(compose(parseColor(foreground), alpha, resolvedBackground)); +} + +/** WCAG 2.x relative luminance of an opaque color. */ +export function wcagLuminance(color: string): number { + return luminance(parseOpaqueColor(color, 'wcagLuminance() needs an opaque color')); +} + +/** + * WCAG 2.x contrast ratio. A translucent foreground (an rgba text color, for + * example `text.secondary`) is composited onto the background first, matching + * what the browser paints. The composite keeps fractional channels, so the + * ratio stays exact for threshold classification. + */ +export function contrastRatio(foreground: string, background: string): number { + const resolvedBackground = parseOpaqueColor( + background, + 'contrastRatio() needs an opaque background', + ); + const [hi, lo] = [ + luminance(compose(parseColor(foreground), 1, resolvedBackground)), + luminance(resolvedBackground), + ].sort((a, b) => b - a); + return (hi + 0.05) / (lo + 0.05); +} + +/** + * Rounds a ratio to two decimals — the precision the reports and public + * checkers display. Display only: WCAG classification must compare the exact + * ratio, because rounding can flip a value at a threshold (a true 4.4992:1 + * fails 1.4.3 but rounds to 4.5). + */ +export function roundRatio(ratio: number): number { + return Math.round(ratio * 100) / 100; +} + +/** `contrastRatio` rounded to two decimals, the precision the reports use. */ +export function roundedContrastRatio(foreground: string, background: string): number { + return roundRatio(contrastRatio(foreground, background)); +} + +export interface TypographyStyleLike { + fontSize?: string | number; + fontWeight?: string | number; +} + +/** + * The two 1.4.3 thresholds. Named so a test states which one it means instead + * of repeating a bare number that reads as a magic constant. + */ +export const WCAG_MINIMUM_RATIO = { + /** Text below the WCAG "large text" cutoff. */ + normalText: 4.5, + /** At least 24px, or at least 18.66px at weight 700+. */ + largeText: 3, +} as const; + +/** CSS keywords `fontWeight` accepts that map to a fixed numeric weight. */ +const ABSOLUTE_FONT_WEIGHTS: Record = { normal: 400, bold: 700 }; + +function toPx(fontSize: string | number, htmlFontSize: number): number { + if (typeof fontSize === 'number') { + return fontSize; + } + const match = /^(\d*\.?\d+)(px|rem)$/.exec(fontSize.trim()); + if (!match) { + throw new Error( + `requiredRatio() cannot size "${fontSize}". Pass px, rem, or a number: any other ` + + 'unit depends on the render tree, which a theme value cannot tell us.', + ); + } + const value = parseFloat(match[1]); + return match[2] === 'rem' ? value * htmlFontSize : value; +} + +function toWeight(fontWeight: string | number | undefined): number { + if (fontWeight == null) { + return 400; + } + if (typeof fontWeight === 'number') { + return fontWeight; + } + const keyword = ABSOLUTE_FONT_WEIGHTS[fontWeight.trim()]; + if (keyword !== undefined) { + return keyword; + } + const numeric = Number(fontWeight); + if (Number.isNaN(numeric)) { + throw new Error( + `requiredRatio() cannot weigh "${fontWeight}". Pass a number, "normal", or "bold": ` + + '"bolder" and "lighter" are relative to the parent, which a theme value cannot tell us.', + ); + } + return numeric; +} + +/** + * The 1.4.3 threshold a text style must meet: 3:1 for WCAG "large text" + * (at least 24px, or at least 18.66px at weight 700+), 4.5:1 otherwise. + * Pass a theme typography variant (`theme.typography.button`) so the + * threshold derives from the same tokens the component renders with. + * + * Throws on a size or weight it cannot resolve rather than guessing. A silent + * fallback here misclassifies the threshold, which is the one number the whole + * conformance guard rests on. + */ +export function requiredRatio(style: TypographyStyleLike, htmlFontSize = 16): 4.5 | 3 { + if (style.fontSize == null) { + throw new Error('requiredRatio() needs a style with a fontSize to derive the threshold.'); + } + const px = toPx(style.fontSize, htmlFontSize); + const weight = toWeight(style.fontWeight); + const isLargeText = px >= 24 || (px >= 18.66 && weight >= 700); + return isLargeText ? WCAG_MINIMUM_RATIO.largeText : WCAG_MINIMUM_RATIO.normalText; +} diff --git a/packages/mui-material/test/contrastContract.ts b/packages/mui-material/test/contrastContract.ts new file mode 100644 index 00000000000000..c3b06f21583213 --- /dev/null +++ b/packages/mui-material/test/contrastContract.ts @@ -0,0 +1,107 @@ +import type { Theme } from '@mui/material/styles'; +import { contrastRatio, WCAG_MINIMUM_RATIO } from './contrast'; + +/** + * The palette contrast contract: the pinned WCAG 1.4.3 facts of the default + * light theme, shared by every component's accessibility conformance suite. + * + * These are the documented public claim, not a convenience snapshot: the + * per-component `accessibility.md` reports and the committed axe results + * (`*.a11y.json`) state these values, so a palette change must fail the + * enforcement test (`src/styles/paletteContrast.test.ts`) instead of + * silently re-blessing itself. + * + * Nothing is hand-invented. `main` is the palette constant + * (`createPalette.js`: primary = blue[700], secondary = purple[500], + * error = red[700], info = lightBlue[700], success = green[800], warning + * hand-picked next to orange[800]); the ratios are the WCAG 2.x formula over + * those hex values. Verify any row with a contrast checker, for example + * https://webaim.org/resources/contrastchecker/ — `contrastText` and + * `background.paper` are both `#fff`, which is why the directions agree. + * + * The pinned ratios use two decimals on purpose. That is the precision the + * reports and public checkers display, so a human can verify each row against + * an independent implementation. More digits would remove that property and + * add no drift protection: the exact tripwire is the pinned `main` hex. + * Exactness matters only when a ratio meets a WCAG threshold — for that, + * `measurePaletteContrast` returns exact ratios. + * + * Component suites consume this contract instead of restating numbers: + * + * ```ts + * import { FAILING_PALETTE_COLORS, PALETTE_CONTRAST } from '../../test/contrastContract'; + * // Button: contained = contrastText on main, text/outlined = main on paper, + * // so its failing color set is exactly FAILING_PALETTE_COLORS. + * ``` + */ +export const PALETTE_CONTRAST_COLORS = [ + 'primary', + 'secondary', + 'error', + 'info', + 'success', + 'warning', +] as const; + +export type PaletteContrastColor = (typeof PALETTE_CONTRAST_COLORS)[number]; + +export interface PaletteContrastEntry { + color: PaletteContrastColor; + /** The palette constant behind the ratios, pinned so hex drift is loud. */ + main: string; + /** Filled surfaces: a contained Button's label on its background. */ + contrastTextOnMain: number; + /** Colored text: a text/outlined Button's label on `background.paper`. */ + mainOnPaper: number; +} + +/** + * The pinned claim: the same shape as a measurement, but the ratios are + * rounded to the two decimals the reports publish. Never classify against + * these. A rounded ratio can sit on the wrong side of a threshold, which is + * exactly the bug `measurePaletteContrast` returning exact ratios avoids. + * Use `FAILING_PALETTE_COLORS`, or `failsWcag` over a fresh measurement. + */ +export type PinnedPaletteContrastEntry = PaletteContrastEntry; + +export const PALETTE_CONTRAST: readonly PinnedPaletteContrastEntry[] = [ + { color: 'primary', main: '#1976d2', contrastTextOnMain: 4.6, mainOnPaper: 4.6 }, + { color: 'secondary', main: '#9c27b0', contrastTextOnMain: 6.3, mainOnPaper: 6.3 }, + { color: 'error', main: '#d32f2f', contrastTextOnMain: 4.98, mainOnPaper: 4.98 }, + { color: 'info', main: '#0288d1', contrastTextOnMain: 3.86, mainOnPaper: 3.86 }, + { color: 'success', main: '#2e7d32', contrastTextOnMain: 5.13, mainOnPaper: 5.13 }, + { color: 'warning', main: '#ed6c02', contrastTextOnMain: 3.11, mainOnPaper: 3.11 }, +]; + +/** + * The colors below WCAG 4.5:1 in at least one direction — the set every + * report's Known gaps entry names. + */ +export const FAILING_PALETTE_COLORS: readonly PaletteContrastColor[] = ['info', 'warning']; + +/** + * Recomputes the contract's facts from a theme, in `PALETTE_CONTRAST` shape. + * The ratios are exact: classify against WCAG thresholds directly on them. + * Round with `roundRatio` from `./contrast` only to compare against the + * pinned two-decimal claim or to display a value. + */ +export function measurePaletteContrast(theme: Theme): PaletteContrastEntry[] { + return PALETTE_CONTRAST_COLORS.map((color) => ({ + color, + main: theme.palette[color].main, + contrastTextOnMain: contrastRatio(theme.palette[color].contrastText, theme.palette[color].main), + mainOnPaper: contrastRatio(theme.palette[color].main, theme.palette.background.paper), + })); +} + +/** + * Whether an entry falls short of a 1.4.3 threshold in either direction. + * Feed it a `measurePaletteContrast` entry, never a `PALETTE_CONTRAST` one: + * classification needs the exact ratio. + */ +export function failsWcag( + entry: PaletteContrastEntry, + threshold: number = WCAG_MINIMUM_RATIO.normalText, +): boolean { + return entry.contrastTextOnMain < threshold || entry.mainOnPaper < threshold; +}