diff --git a/package-lock.json b/package-lock.json index 2664007e2d..d7df6c0a5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -695,6 +695,16 @@ "object-visit": "^1.0.0" } }, + "color": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/color/-/color-3.1.2.tgz", + "integrity": "sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg==", + "dev": true, + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -710,6 +720,16 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "dev": true, + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -816,6 +836,26 @@ "which": "^1.2.9" } }, + "css": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz", + "integrity": "sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "source-map": "^0.6.1", + "source-map-resolve": "^0.5.2", + "urix": "^0.1.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "cssom": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.6.tgz", @@ -841,6 +881,12 @@ "type": "^1.0.1" } }, + "d3-color": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.0.tgz", + "integrity": "sha512-TzNPeJy2+iEepfiL92LAAB7fvnp/dV2YwANPVHdDWmYMm23qIJBYww3qT8I8C1wXrmrg4UWs7BKc2tKIgyjzHg==", + "dev": true + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -4316,6 +4362,23 @@ } } }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "dev": true, + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true + } + } + }, "snapdragon": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", diff --git a/package.json b/package.json index f8bb2a9fcf..3ec0ba573e 100755 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "style": "themes/prism.css", "scripts": { "test:aliases": "mocha tests/aliases-test.js", + "test:contrast": "mocha tests/contrast-test.js", "test:core": "mocha tests/core/**/*.js", "test:dependencies": "mocha tests/dependencies-test.js", "test:examples": "mocha tests/examples-test.js", @@ -13,7 +14,7 @@ "test:patterns": "mocha tests/pattern-tests.js", "test:plugins": "mocha tests/plugins/**/*.js", "test:runner": "mocha tests/testrunner-tests.js", - "test": "npm run test:runner && npm run test:core && npm run test:dependencies && npm run test:languages && npm run test:plugins && npm run test:aliases && npm run test:patterns && npm run test:examples" + "test": "npm run test:runner && npm run test:core && npm run test:dependencies && npm run test:languages && npm run test:plugins && npm run test:aliases && npm run test:patterns && npm run test:examples && npm run test:contrast" }, "repository": { "type": "git", @@ -31,6 +32,8 @@ }, "devDependencies": { "chai": "^4.2.0", + "color": "^3.1.2", + "css": "^2.2.4", "gulp": "^4.0.2", "gulp-concat": "^2.3.4", "gulp-header": "^2.0.7", diff --git a/tests/contrast-test.js b/tests/contrast-test.js new file mode 100644 index 0000000000..a53e6e0d79 --- /dev/null +++ b/tests/contrast-test.js @@ -0,0 +1,160 @@ +const fs = require('fs'); +const css = require('css'); +const path = require('path'); +const Color = require('color'); +const { assert } = require('chai'); +const { themes } = require('../components'); + + +/** + * @typedef {import("color")} Color + */ + +/** + * Analyzes the given CSS source code to find low-contrast colors and returns an array the found colors, the line they + * occur in, and a suggested high-contrast color with the same hue. + * + * Use `@contrast-background: ` on a line in a comment to tell this checker the background color. The background + * color is defined per scope. + * + * Use `@contrast-ignore` on a line in a comment within a rule to ignore the next property. + * + * @param {string} cssCode + * @returns {string[]} + */ +function analyseContrast(cssCode) { + /** @type {string[]} */ + const errors = []; + + const ast = css.parse(cssCode); + analyseRules(ast.stylesheet.rules); + + /** + * @param {string} comment + */ + function parseBackground(comment) { + const colorStr = (/^\s*(?:\*\s*)?@contrast-background:(.*)/m.exec(comment) || [, ''])[1].trim(); + if (colorStr) { + return Color(colorStr); + } + } + /** + * @param {string} comment + */ + function parseIgnore(comment) { + return /^\s*(?:\*\s*)?@contrast-ignore\s*$/m.test(comment); + } + + /** + * @param {import("css").StyleRules["rules"]} rules + * @param {Color | undefined} [background] + */ + function analyseRules(rules, background) { + for (const element of rules) { + if ("comment" in element) { + background = parseBackground(element.comment) || background; + } else if ("rules" in element) { + analyseRules(element.rules, background); + } else if ("declarations" in element) { + analyseDeclarations(element.declarations, background); + } + } + } + /** + * @param {import("css").Rule["declarations"]} declarations + * @param {Color | undefined} background + */ + function analyseDeclarations(declarations, background) { + let ignore = false; + for (const decl of declarations) { + if ("comment" in decl) { + background = parseBackground(decl.comment) || background; + if (parseIgnore(decl.comment)) { + ignore = true; + } + } else if ("property" in decl) { + if (ignore) { + ignore = false; + continue; + } + if (decl.property !== "color") { + continue; + } + + const line = `Line ${decl.position.start.line}`; + + if (!background) { + errors.push(`${line}: There is no background defined for the color ${decl.value}.`); + continue; + } + + const color = Color(decl.value); + const contrast = color.contrast(background); + + if (contrast < 4.5 - 0.01 /* some epsilon for rounding errors */) { + const bg = `Background ${background.hex()}`; + const correctedColor = ensureContrast(color, background, 4.5); + const corrected = `${correctedColor.hex()} (${correctedColor.contrast(background).toFixed(2)})`; + errors.push(`${line}: ${bg}: The color ${decl.value} has a contrast of ${contrast.toFixed(2)} < 4.5. Use ${corrected} instead.`); + } + } + } + } + + return errors; +} + +/** + * @param {Color} color + * @param {Color} background + * @param {number} contrastGoal + * @returns {Color} + */ +function ensureContrast(color, background, contrastGoal) { + if (color.contrast(background) >= contrastGoal) { + return color.rgb(); + } + + color = color.lab(); + const makeDarker = color.luminosity() < background.luminosity(); + const ab = { a: color.object().a, b: color.object().b }; + + /** @type {number} */ + let near = color.lab().object().l; + /** @type {number} */ + let far = makeDarker ? 0 : 100; + while (Math.abs(near - far) > 0.00001) { + let middle = (near + far) / 2; + color = Color.lab({ l: middle, ...ab }); + + if (color.contrast(background) < contrastGoal) { + near = middle; + } else { + far = middle; + } + } + + return color.rgb(); +} + + +describe('Contrast', function () { + + for (const theme in themes) { + if (theme === 'meta') { + continue; + } + + const file = path.join(__dirname, `../themes/${theme}.css`); + + it(`- ./themes/${theme}.css`, () => { + const source = fs.readFileSync(file, 'utf8'); + + const errors = analyseContrast(source); + if (errors.length > 0) { + assert.fail(`There are ${errors.length} contrast issues:\n\n` + errors.join('\n')); + } + }); + } + +}); diff --git a/themes/prism-coy.css b/themes/prism-coy.css index 2d873dced9..736e36adc6 100644 --- a/themes/prism-coy.css +++ b/themes/prism-coy.css @@ -4,6 +4,8 @@ * @author Tim Shedor */ +/* @contrast-background: #fdfdfd */ + code[class*="language-"], pre[class*="language-"] { color: black; @@ -141,6 +143,7 @@ pre[class*="language-"]:after { .token.entity, .token.url, .token.variable { + /* @contrast-background: white */ color: #a67f59; background: rgba(255, 255, 255, 0.5); } @@ -159,6 +162,7 @@ pre[class*="language-"]:after { .language-css .token.string, .style .token.string { + /* @contrast-background: white */ color: #a67f59; background: rgba(255, 255, 255, 0.5); } @@ -195,6 +199,7 @@ pre[class*="language-"]:after { .token.tab:not(:empty):before, .token.cr:before, .token.lf:before { + /* @contrast-ignore */ color: #e0d7d1; } diff --git a/themes/prism-dark.css b/themes/prism-dark.css index ea98cd103e..dcd2dbd7da 100644 --- a/themes/prism-dark.css +++ b/themes/prism-dark.css @@ -4,6 +4,8 @@ * @author Lea Verou */ +/* @contrast-background: hsl(30, 20%, 25%) */ + code[class*="language-"], pre[class*="language-"] { color: white; diff --git a/themes/prism-funky.css b/themes/prism-funky.css index 21a89410fc..65c52368af 100644 --- a/themes/prism-funky.css +++ b/themes/prism-funky.css @@ -4,6 +4,8 @@ * @author Lea Verou */ +/* @contrast-background: black */ + code[class*="language-"], pre[class*="language-"] { font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; diff --git a/themes/prism-okaidia.css b/themes/prism-okaidia.css index fcb5fc4e4b..8847db79be 100644 --- a/themes/prism-okaidia.css +++ b/themes/prism-okaidia.css @@ -4,6 +4,8 @@ * @author ocodia */ +/* @contrast-background: #272822 */ + code[class*="language-"], pre[class*="language-"] { color: #f8f8f2; diff --git a/themes/prism-solarizedlight.css b/themes/prism-solarizedlight.css index 65e4a65860..e8775813f1 100644 --- a/themes/prism-solarizedlight.css +++ b/themes/prism-solarizedlight.css @@ -28,6 +28,8 @@ cyan #2aa198 green #859900 */ +/* @contrast-background: #fdf6e3 */ + code[class*="language-"], pre[class*="language-"] { color: #657b83; /* base00 */ diff --git a/themes/prism-tomorrow.css b/themes/prism-tomorrow.css index a0eeff0a38..5dcbbd6fe9 100644 --- a/themes/prism-tomorrow.css +++ b/themes/prism-tomorrow.css @@ -4,6 +4,8 @@ * @author Rose Pritchard */ +/* @contrast-background: #2d2d2d */ + code[class*="language-"], pre[class*="language-"] { color: #ccc; diff --git a/themes/prism-twilight.css b/themes/prism-twilight.css index 941d6d7f4d..5baa714922 100644 --- a/themes/prism-twilight.css +++ b/themes/prism-twilight.css @@ -3,6 +3,9 @@ * Based (more or less) on the Twilight theme originally of Textmate fame. * @author Remy Bach */ + +/* @contrast-background: black */ + code[class*="language-"], pre[class*="language-"] { color: white; diff --git a/themes/prism.css b/themes/prism.css index 618a4121e1..aeac273f64 100644 --- a/themes/prism.css +++ b/themes/prism.css @@ -4,6 +4,8 @@ * @author Lea Verou */ +/* @contrast-background: white */ + code[class*="language-"], pre[class*="language-"] { color: black; @@ -74,6 +76,7 @@ pre[class*="language-"] { } .token.punctuation { + /* @contrast-ignore */ color: #999; }