UNPKG

@terrazzo/parser

Version:

Parser/validator for the Design Tokens Community Group (DTCG) standard.

92 lines (82 loc) 3.14 kB
import { tokenToCulori } from '@terrazzo/token-tools'; import { wcagContrast } from 'culori'; import type { LintRule } from '../../../types.js'; import { docsLink } from '../lib/docs.js'; export const A11Y_MIN_CONTRAST = 'a11y/min-contrast'; export interface RuleA11yMinContrastOptions { /** * Whether to adhere to AA (minimum) or AAA (enhanced) contrast levels. * @default "AA" */ level?: 'AA' | 'AAA'; /** Pairs of color tokens (and optionally typography) to test */ pairs: ContrastPair[]; } export interface ContrastPair { /** The foreground color token ID */ foreground: string; /** The background color token ID */ background: string; /** * Is this pair for large text? Large text allows a smaller contrast ratio. * * Note: while WCAG has _suggested_ sizes and weights, those are merely * suggestions. It’s always more reliable to determine what constitutes “large * text” for your designs yourself, based on your typographic stack. * @see https://www.w3.org/WAI/WCAG22/quickref/#contrast-minimum */ largeText?: boolean; } export const WCAG2_MIN_CONTRAST = { AA: { default: 4.5, large: 3 }, AAA: { default: 7, large: 4.5 }, }; export const ERROR_INSUFFICIENT_CONTRAST = 'INSUFFICIENT_CONTRAST'; const rule: LintRule<typeof ERROR_INSUFFICIENT_CONTRAST, RuleA11yMinContrastOptions> = { meta: { messages: { [ERROR_INSUFFICIENT_CONTRAST]: 'Pair {{ index }} failed; expected {{ expected }}, got {{ actual }} ({{ level }})', }, docs: { description: 'Enforce colors meet minimum contrast checks for WCAG 2.', url: docsLink(A11Y_MIN_CONTRAST), }, }, defaultOptions: { level: 'AA', pairs: [] }, create({ tokens, options, report }) { for (let i = 0; i < options.pairs.length; i++) { const { foreground, background, largeText } = options.pairs[i]!; if (!tokens[foreground]) { throw new Error(`Token ${foreground} does not exist`); } if (tokens[foreground].$type !== 'color') { throw new Error(`Token ${foreground} isn’t a color`); } if (!tokens[background]) { throw new Error(`Token ${background} does not exist`); } if (tokens[background].$type !== 'color') { throw new Error(`Token ${background} isn’t a color`); } // Note: if these culors were unparseable, they would have already thrown an error before the linter const a = tokenToCulori(tokens[foreground].$value)!; const b = tokenToCulori(tokens[background].$value)!; // Note: for the purposes of WCAG 2, foreground and background don’t // matter. But in other contrast algorithms, they do. const contrast = wcagContrast(a, b); const min = WCAG2_MIN_CONTRAST[options.level ?? 'AA'][largeText ? 'large' : 'default']; if (contrast < min) { report({ messageId: ERROR_INSUFFICIENT_CONTRAST, data: { index: i + 1, expected: min, actual: Math.round(contrast * 100) / 100, level: options.level, }, }); } } }, }; export default rule;