From df51149d0a45238ee253467cfc56d53c16e1fe74 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 16 Jan 2025 12:47:06 -0500 Subject: [PATCH] Show contrast ratios in contrast pair tables --- docs/_data/palettes.js | 1 + docs/_includes/contrast-table.njk | 27 ++++++++----- docs/_layouts/palette.njk | 28 ++++++++++++++ docs/_utils/filters.js | 17 +++++++++ package.json | 1 + src/styles/color/contrast.test.js | 58 ++++++---------------------- src/styles/color/palettes.js | 63 +++++++++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 56 deletions(-) create mode 100644 docs/_data/palettes.js create mode 100644 src/styles/color/palettes.js diff --git a/docs/_data/palettes.js b/docs/_data/palettes.js new file mode 100644 index 000000000..fb231f0e9 --- /dev/null +++ b/docs/_data/palettes.js @@ -0,0 +1 @@ +export { default as default } from '../../src/styles/color/palettes.js'; diff --git a/docs/_includes/contrast-table.njk b/docs/_includes/contrast-table.njk index 19b4bec7f..46e6ff701 100644 --- a/docs/_includes/contrast-table.njk +++ b/docs/_includes/contrast-table.njk @@ -15,15 +15,24 @@ {{ hue | capitalize }} {% for tint_bg in tints -%} - {% for tint_fg in tints | reverse -%} - - {% if (tint_fg - tint_bg) | abs == difference %} - -
- {{ tint_fg }} on {{ tint_bg }} -
- - {% endif %} + {% set color_bg = palettes[paletteId][hue][tint_bg] %} + {% for tint_fg in tints | reverse -%} + {% set color_fg = palettes[paletteId][hue][tint_fg] %} + {% if (tint_fg - tint_bg) | abs == difference %} + +
+ {% set contrast_wcag = '' %} + {% if color_fg and color_bg %} + {% set contrast_wcag = color_bg.contrast(color_fg, 'WCAG21') %} + {% endif %} + {% if contrast_wcag %} + {{ contrast_wcag | number({maximumSignificantDigits: 2}) }} + {% else %} + {{ tint_fg }} on {{ tint_bg }} + {% endif %} +
+ + {% endif %} {%- endfor -%} {%- endfor -%} diff --git a/docs/_layouts/palette.njk b/docs/_layouts/palette.njk index 03fa26cd5..a0e19a13b 100644 --- a/docs/_layouts/palette.njk +++ b/docs/_layouts/palette.njk @@ -61,6 +61,16 @@ A difference of `40` ensures a minimum **3:1** contrast ratio, suitable for larg {% include "contrast-table.njk" %} {% markdown %} + +This also goes for a difference of `45`: + +{% endmarkdown %} + +{% set difference = 45 %} +{% include "contrast-table.njk" %} + +{% markdown %} + ### Level 2 A difference of `50` ensures a minimum **4.5:1** contrast ratio, suitable for normal text (AA) and large text (AAA) @@ -69,6 +79,15 @@ A difference of `50` ensures a minimum **4.5:1** contrast ratio, suitable for no {% set difference = 50 %} {% include "contrast-table.njk" %} +{% markdown %} + +This also goes for a difference of `55`: + +{% endmarkdown %} + +{% set difference = 55 %} +{% include "contrast-table.njk" %} + {% markdown %} ### Level 3 @@ -78,6 +97,15 @@ A difference of `60` ensures a minimum **7:1** contrast ratio, suitable for all {% set difference = 60 %} {% include "contrast-table.njk" %} +{% markdown %} + +This also goes for a difference of `65`: + +{% endmarkdown %} + +{% set difference = 65 %} +{% include "contrast-table.njk" %} + {% markdown %} ## How to use this palette diff --git a/docs/_utils/filters.js b/docs/_utils/filters.js index 5f672881a..912b5dbb1 100644 --- a/docs/_utils/filters.js +++ b/docs/_utils/filters.js @@ -105,6 +105,23 @@ export function deepValue(obj, key) { return key.reduce((subObj, property) => subObj?.[property], obj); } +export function number(value, options) { + if (typeof value !== 'number' && isNaN(value)) { + return value; + } + + let lang = options?.lang ?? 'en'; + if (options?.lang) { + delete options.lang; + } + + if (!options || Object.keys(options).length === 0) { + options = { maximumSignificantDigits: 3 }; + } + + return Number(value).toLocaleString(lang, options); +} + export function isNumeric(value) { return typeof value === 'number' || (typeof value === 'string' && !isNaN(value)); } diff --git a/package.json b/package.json index 8d0591ef9..e1c0d10bd 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "create": "plop --plopfile scripts/plop/plopfile.js", "test": "web-test-runner --group default", "test:component": "web-test-runner -- --watch --group", + "test:contrast": "cd src/styles/color && node contrast.test.js", "test:watch": "web-test-runner --watch --group default", "prettier": "prettier --check --log-level=warn .", "prettier:fix": "prettier --write --log-level=warn .", diff --git a/src/styles/color/contrast.test.js b/src/styles/color/contrast.test.js index 2be37b56a..1882f8353 100644 --- a/src/styles/color/contrast.test.js +++ b/src/styles/color/contrast.test.js @@ -1,34 +1,7 @@ // 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; -} +import palettes from './palettes.js'; let targetContrasts = { 40: 3, @@ -38,21 +11,17 @@ let targetContrasts = { 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 paletteId in palettes) { + const tokens = palettes[paletteId]; + let prefix = chalk.dim(`[${paletteId}]`); for (let hue in tokens) { let tints = tokens[hue]; for (let tint = 10; tint <= 50; tint += 10) { - let color; + let color = tints[tint]; - try { - color = new Color(tints[tint]); - } catch (e) { - console.error(`[${file}] Invalid color ${hue}-${tint}: ${tints[tint]}`); + if (!(color instanceof Color)) { result.invalid++; continue; } @@ -64,15 +33,10 @@ for (let file of paletteFiles) { 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++; - } + let color2 = tints[tint2]; + + if (!(color2 instanceof Color)) { + result.invalid++; continue; } @@ -84,7 +48,7 @@ for (let file of paletteFiles) { result.fail++; console.log( chalk.red( - `${filePrefix} WCAG 2.1 contrast between ${hue}-${tint} and ${hue}-${tint2} is ${contrast.toLocaleString('en')} < ${targetContrast}`, + `${prefix} WCAG 2.1 contrast between ${hue}-${tint} and ${hue}-${tint2} is ${contrast.toLocaleString('en')} < ${targetContrast}`, ), ); } diff --git a/src/styles/color/palettes.js b/src/styles/color/palettes.js new file mode 100644 index 000000000..130c3e02a --- /dev/null +++ b/src/styles/color/palettes.js @@ -0,0 +1,63 @@ +/** + * Export data on all color tokens from all palettes + */ + +// Get a list of all CSS files in repo +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)); + +export const paletteFiles = fs.readdirSync(__dirname).filter(file => file.endsWith('.css')); +export const declarationRegex = /^\s*--wa-color-(?[a-z]+)-(?[0-9]+):\s*(?[^;]+)\s*;$/gm; + +function parse(contents, file) { + // Regex for each declaration + const matches = [...contents.matchAll(declarationRegex)]; + + if (matches.length === 0) { + throw new Error('Cound not extract colors'); + } + + const ret = {}; + + for (let match of matches) { + let { hue, level, color } = match.groups; + ret[hue] ??= {}; + + // Attempt to convert color to Color object, fall back to string if this fails + // This will happen for e.g. colors defined via color-mix() + try { + color = new Color(color); + } catch (e) { + console.warn(`[${file}] Unparseable color ${hue}-${level}: ${color}`); + } + + if (level.startsWith('0')) { + // Leading zeroes throw off sorting, add both properties + // NOTE: Ideally one of the two would be added as non-enumerable, but then we cannot access it via 11ty data + ret[hue][level] = color; + + // Drop leading zeroes + level = level.replace(/^0+/, ''); + } + + ret[hue][level] = color; + } + + return ret; +} + +const palettes = {}; + +for (let file of paletteFiles) { + let css = fs.readFileSync(path.join(__dirname, file), 'utf8'); + let tokens = parse(css, file); + let paletteId = file.replace(/\.css$/, ''); + + palettes[paletteId] = tokens; +} + +export default palettes;