From cfa95307d15121570afe15775f09a67b496e700c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 15 Jan 2025 16:24:42 -0500 Subject: [PATCH] Quick proof of concept contrast tests --- package-lock.json | 12 ++++ package.json | 1 + src/styles/color/contrast.test.js | 107 ++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 src/styles/color/contrast.test.js diff --git a/package-lock.json b/package-lock.json index fb43e1f26..132429d25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "chalk": "^5.3.0", "change-case": "^4.1.2", "chokidar": "^3.5.3", + "colorjs.io": "^0.6.0-alpha.1", "command-line-args": "^5.2.1", "comment-parser": "^1.4.1", "cspell": "^6.18.1", @@ -5078,6 +5079,17 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, + "node_modules/colorjs.io": { + "version": "0.6.0-alpha.1", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.6.0-alpha.1.tgz", + "integrity": "sha512-c/h/8uAmPydQcriRdX8UTAFHj6SpSHFHBA8LvMikvYWAVApPTwg/pyOXNsGmaCBd6L/EeDlRHSNhTtnIFp/qsg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/color" + } + }, "node_modules/command-line-args": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", diff --git a/package.json b/package.json index 2ca2c0372..8d0591ef9 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "chalk": "^5.3.0", "change-case": "^4.1.2", "chokidar": "^3.5.3", + "colorjs.io": "^0.6.0-alpha.1", "command-line-args": "^5.2.1", "comment-parser": "^1.4.1", "cspell": "^6.18.1", diff --git a/src/styles/color/contrast.test.js b/src/styles/color/contrast.test.js new file mode 100644 index 000000000..2be37b56a --- /dev/null +++ b/src/styles/color/contrast.test.js @@ -0,0 +1,107 @@ +// Get a list of all CSS files in repo +import chalk from 'chalk'; +import Color from 'colorjs.io'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +const paletteFiles = fs.readdirSync(__dirname).filter(file => file.endsWith('.css')); + +function parse(contents) { + // Regex for each declaration + let regex = /^\s*--wa-color-(?[a-z]+)-(?[0-9]+):\s*(?[^;]+)\s*;$/gm; + let matches = [...contents.matchAll(regex)]; + + if (matches.length === 0) { + throw new Error('Cound not extract colors'); + } + + let ret = {}; + + for (let match of matches) { + let { hue, level, color } = match.groups; + ret[hue] ??= {}; + level = level.replace(/^0+/, ''); // Leading zeroes throw off sorting + ret[hue][level] = color; + } + + return ret; +} + +let targetContrasts = { + 40: 3, + 50: 4.5, + 60: 7, +}; + +let result = { pass: 0, fail: 0, invalid: 0 }; + +for (let file of paletteFiles) { + let css = fs.readFileSync(path.join(__dirname, file), 'utf8'); + let filePrefix = chalk.dim(`[${file}]`); + let tokens = parse(css); + + for (let hue in tokens) { + let tints = tokens[hue]; + + for (let tint = 10; tint <= 50; tint += 10) { + let color; + + try { + color = new Color(tints[tint]); + } catch (e) { + console.error(`[${file}] Invalid color ${hue}-${tint}: ${tints[tint]}`); + result.invalid++; + continue; + } + + for (let difference in targetContrasts) { + let targetContrast = targetContrasts[difference]; + let tint2 = tint + Number(difference); + if (tint2 > 90) { + continue; + } + + let color2; + try { + color2 = new Color(tints[tint2]); + } catch (e) { + if (tint2 > 50) { + // If 50, we'll look at it at some point as color1 + console.error(`${filePrefix} Invalid color ${hue}-${tint2}: ${tints[tint2]}`); + result.invalid++; + } + continue; + } + + let contrast = color.contrast(color2, 'WCAG21'); + let pass = contrast >= targetContrast; + if (pass) { + result.pass++; + } else { + result.fail++; + console.log( + chalk.red( + `${filePrefix} WCAG 2.1 contrast between ${hue}-${tint} and ${hue}-${tint2} is ${contrast.toLocaleString('en')} < ${targetContrast}`, + ), + ); + } + } + } + } +} + +let testCount = result.pass + result.fail; +console.info( + `Ran ${testCount} tests: ${chalk.green(`${chalk.bold(result.pass)} passed`)}` + + (result.fail ? `, ${chalk.red(`${chalk.bold(result.fail)} failed`)}` : '') + + (result.invalid ? `, ${chalk.red(`${chalk.bold(result.invalid)} invalid colors`)}` : ''), +); + +if (testCount === result.pass) { + process.exit(0); +} else { + process.exit(1); +}