├── .nojekyll ├── .gitignore ├── client ├── images │ ├── screenshot.png │ └── pattern.svg ├── bookmarklet-snippet.html └── index.html ├── parallel.sh ├── rollup.config.js ├── README.md ├── .eslintrc ├── LICENSE ├── package.json └── lib ├── color.js ├── chartist.css.js └── main.js /.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /client/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AdaRoseCannon/contrast-widget/HEAD/client/images/screenshot.png -------------------------------------------------------------------------------- /parallel.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for cmd in "$@"; do { 4 | echo "Process \"$cmd\" started"; 5 | $cmd & pid=$! 6 | PID_LIST+=" $pid"; 7 | } done 8 | 9 | trap "kill $PID_LIST" SIGINT 10 | 11 | echo "Parallel processes have started"; 12 | 13 | wait $PID_LIST 14 | 15 | echo 16 | echo "All processes have completed"; 17 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import nodeResolve from 'rollup-plugin-node-resolve'; 5 | import json from 'rollup-plugin-json'; 6 | 7 | export default { 8 | entry: './lib/main.js', 9 | intro: '(function () {\nvar define = false;\n', 10 | outro: '}());', 11 | plugins: [ 12 | nodeResolve({ 13 | jsnext: true 14 | }), 15 | commonjs({ 16 | include: 'node_modules/**' 17 | }), 18 | json() 19 | ], 20 | dest: './build/contrast-widget-bundle.es2015.js' 21 | }; 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a bookmarklet to run on any page to analyse the contrast of the text on a page and highlight elements which may have readability issues. 2 | 3 | Full Details: https://ada.is/blog/2016/02/12/contrast-bookmarklet/ 4 | 5 | ## Build instructions for the Contrast Widget 6 | 7 | ``` 8 | npm install 9 | npm run build 10 | ``` 11 | 12 | Open up `build/index.html` in Chrome to test the widget, it runs automatically on that page. 13 | 14 | ## Developing 15 | 16 | `npm run watch` to build automatically. 17 | 18 | ## Building 19 | 20 | Only update the version number if the bookmarklet has changed. The bookmarklet will compare against this number for prompting the user to update the widget. 21 | 22 | ## Todo 23 | 24 | ## Algorithmic improvements 25 | 26 | * Handle semitransparent backgrounds better 27 | * Ignore 0 area elements 28 | 29 | ## Usage improvements 30 | 31 | * Option to turn off update checking. 32 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "env": { 4 | "node": true, 5 | "es6": true, 6 | "browser": true 7 | }, 8 | "ecmaFeatures": { 9 | "modules": true 10 | }, 11 | "rules": { 12 | "no-unused-vars": 2, 13 | "no-undef": 2, 14 | "eqeqeq": 2, 15 | "no-underscore-dangle": 0, 16 | "guard-for-in": 2, 17 | "no-extend-native": 2, 18 | "wrap-iife": 2, 19 | "new-cap": 2, 20 | "no-caller": 2, 21 | "strict": [2, "global"], 22 | "quotes": [1, "single"], 23 | "no-loop-func": 2, 24 | "no-irregular-whitespace": 1, 25 | "no-multi-spaces": 2, 26 | "one-var": [2, "never"], 27 | "constructor-super": 2, 28 | "no-this-before-super": 2, 29 | "no-var": 2, 30 | "prefer-const": 1, 31 | "no-const-assign": 2, 32 | "space-after-keywords": 2, 33 | "indent": [2, "tab"], 34 | "no-trailing-spaces": 2 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ada Rose Cannon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/bookmarklet-snippet.html: -------------------------------------------------------------------------------- 1 | 45 | 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contrast-widget", 3 | "version": "1.0.4", 4 | "description": "Bookmarklet to analyse the contrast of test elements in a document", 5 | "scripts": { 6 | "build:js": "mkdir -p build && rollup -c && babel --presets es2015 ./build/contrast-widget-bundle.es2015.js | uglifyjs -m -r Chartist --screw-ie8 > ./build/contrast-widget-bundle.min.js && wc -c ./build/contrast-widget-bundle.min.js", 7 | "build:html": "node ./build-html.js", 8 | "build:other": "cp -r client/images .nojekyll build/", 9 | "build:json": "node ./build-json.js", 10 | "watch": "./parallel.sh \"nodemon --watch ./lib -e js --exec npm run build:js\" \"nodemon --watch ./client -e html --exec npm run build:html\"", 11 | "build": "npm run build:js && npm run build:html && npm run build:other && npm run build:json", 12 | "deploy": "npm run build && git-directory-deploy --directory ./build" 13 | }, 14 | "repository": "https://github.com/AdaRoseEdwards/contrast-widget", 15 | "author": "Ada Rose Edwards ", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "babel-cli": "^6.8.0", 19 | "babel-preset-es2015": "^6.5.0", 20 | "chartist": "^0.9.5", 21 | "git-directory-deploy": "^1.4.0", 22 | "nodemon": "^1.9.2", 23 | "preprocess": "^3.0.2", 24 | "rollup": "^0.26.3", 25 | "rollup-plugin-commonjs": "^2.2.1", 26 | "rollup-plugin-json": "^2.0.0", 27 | "rollup-plugin-node-resolve": "^1.5.0", 28 | "semver": "^5.1.0", 29 | "uglify-js": "^2.6.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/color.js: -------------------------------------------------------------------------------- 1 | // From lea Verous Color Contrast tool. 2 | // https://github.com/LeaVerou/contrast-ratio/blob/gh-pages/color.js 3 | // 2016-02-08 4 | 5 | var Color; 6 | 7 | // Extend Math.round to allow for precision 8 | Math.round = (function(){ 9 | var round = Math.round; 10 | 11 | return function (number, decimals) { 12 | decimals = +decimals || 0; 13 | 14 | var multiplier = Math.pow(10, decimals); 15 | 16 | return round(number * multiplier) / multiplier; 17 | }; 18 | })(); 19 | 20 | // Simple class for handling sRGB colors 21 | (function(){ 22 | 23 | var _ = Color = function(rgba) { 24 | if (rgba === 'transparent') { 25 | rgba = [0,0,0,0]; 26 | } 27 | else if (typeof rgba === 'string') { 28 | var rgbaString = rgba; 29 | rgba = rgbaString.match(/rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?\)/); 30 | 31 | if (rgba) { 32 | rgba.shift(); 33 | } 34 | else { 35 | throw new Error('Invalid string: ' + rgbaString); 36 | } 37 | } 38 | 39 | if (rgba[3] === undefined) { 40 | rgba[3] = 1; 41 | } 42 | 43 | rgba = rgba.map(function (a) { return Math.round(a, 3) }); 44 | 45 | this.rgba = rgba; 46 | } 47 | 48 | _.prototype = { 49 | get rgb () { 50 | return this.rgba.slice(0,3); 51 | }, 52 | 53 | get alpha () { 54 | return this.rgba[3]; 55 | }, 56 | 57 | set alpha (alpha) { 58 | this.rgba[3] = alpha; 59 | }, 60 | 61 | get luminance () { 62 | // Formula: http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef 63 | var rgba = this.rgba.slice(); 64 | 65 | for(var i=0; i<3; i++) { 66 | var rgb = rgba[i]; 67 | 68 | rgb /= 255; 69 | 70 | rgb = rgb < .03928 ? rgb / 12.92 : Math.pow((rgb + .055) / 1.055, 2.4); 71 | 72 | rgba[i] = rgb; 73 | } 74 | 75 | return .2126 * rgba[0] + .7152 * rgba[1] + 0.0722 * rgba[2]; 76 | }, 77 | 78 | get inverse () { 79 | return new _([ 80 | 255 - this.rgba[0], 81 | 255 - this.rgba[1], 82 | 255 - this.rgba[2], 83 | this.alpha 84 | ]); 85 | }, 86 | 87 | toString: function() { 88 | return 'rgb' + (this.alpha < 1? 'a' : '') + '(' + this.rgba.slice(0, this.alpha >= 1? 3 : 4).join(', ') + ')'; 89 | }, 90 | 91 | clone: function() { 92 | return new _(this.rgba); 93 | }, 94 | 95 | // Overlay a color over another 96 | overlayOn: function (color) { 97 | var overlaid = this.clone(); 98 | 99 | var alpha = this.alpha; 100 | 101 | if (alpha >= 1) { 102 | return overlaid; 103 | } 104 | 105 | for(var i=0; i<3; i++) { 106 | overlaid.rgba[i] = overlaid.rgba[i] * alpha + color.rgba[i] * color.rgba[3] * (1 - alpha); 107 | } 108 | 109 | overlaid.rgba[3] = alpha + color.rgba[3] * (1 - alpha) 110 | 111 | return overlaid; 112 | }, 113 | 114 | contrast: function (color) { 115 | // Formula: http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef 116 | var alpha = this.alpha; 117 | 118 | if (alpha >= 1) { 119 | if (color.alpha < 1) { 120 | color = color.overlayOn(this); 121 | } 122 | 123 | var l1 = this.luminance + .05, 124 | l2 = color.luminance + .05, 125 | ratio = l1/l2; 126 | 127 | if (l2 > l1) { 128 | ratio = 1 / ratio; 129 | } 130 | 131 | ratio = Math.round(ratio, 1); 132 | 133 | return { 134 | ratio: ratio, 135 | error: 0, 136 | min: ratio, 137 | max: ratio 138 | } 139 | } 140 | 141 | // If we’re here, it means we have a semi-transparent background 142 | // The text color may or may not be semi-transparent, but that doesn't matter 143 | 144 | var onBlack = this.overlayOn(_.BLACK).contrast(color).ratio, 145 | onWhite = this.overlayOn(_.WHITE).contrast(color).ratio; 146 | 147 | var max = Math.max(onBlack, onWhite); 148 | 149 | var closest = this.rgb.map(function(c, i) { 150 | return Math.min(Math.max(0, (color.rgb[i] - c * alpha)/(1-alpha)), 255); 151 | }); 152 | 153 | closest = new _(closest); 154 | 155 | var min = this.overlayOn(closest).contrast(color).ratio; 156 | 157 | return { 158 | ratio: Math.round((min + max) / 2, 2), 159 | error: Math.round((max - min) / 2, 2), 160 | min: min, 161 | max: max, 162 | closest: closest, 163 | farthest: onWhite == max? _.WHITE : _.BLACK 164 | }; 165 | } 166 | } 167 | 168 | _.BLACK = new _([0,0,0]); 169 | _.GRAY = new _([127.5, 127.5, 127.5]); 170 | _.WHITE = new _([255,255,255]); 171 | 172 | })(); 173 | 174 | export default Color; 175 | -------------------------------------------------------------------------------- /lib/chartist.css.js: -------------------------------------------------------------------------------- 1 | export default function css() { 2 | let css = `.ct-double-octave:after,.ct-major-eleventh:after,.ct-major-second:after,.ct-major-seventh:after,.ct-major-sixth:after,.ct-major-tenth:after,.ct-major-third:after,.ct-major-twelfth:after,.ct-minor-second:after,.ct-minor-seventh:after,.ct-minor-sixth:after,.ct-minor-third:after,.ct-octave:after,.ct-perfect-fifth:after,.ct-perfect-fourth:after,.ct-square:after{content:"";clear:both}.ct-label{fill:rgba(0,0,0,.4);color:rgba(0,0,0,.4);font-size:.75rem;line-height:1}.ct-chart-bar .ct-label,.ct-chart-line .ct-label{display:block;display:-webkit-box;display:-moz-box;display:-ms-flexbox;display:-webkit-flex;display:flex}.ct-label.ct-horizontal.ct-start{-webkit-box-align:flex-end;-webkit-align-items:flex-end;-ms-flex-align:flex-end;align-items:flex-end;-webkit-box-pack:flex-start;-webkit-justify-content:flex-start;-ms-flex-pack:flex-start;justify-content:flex-start;text-align:left;text-anchor:start}.ct-label.ct-horizontal.ct-end{-webkit-box-align:flex-start;-webkit-align-items:flex-start;-ms-flex-align:flex-start;align-items:flex-start;-webkit-box-pack:flex-start;-webkit-justify-content:flex-start;-ms-flex-pack:flex-start;justify-content:flex-start;text-align:left;text-anchor:start}.ct-label.ct-vertical.ct-start{-webkit-box-align:flex-end;-webkit-align-items:flex-end;-ms-flex-align:flex-end;align-items:flex-end;-webkit-box-pack:flex-end;-webkit-justify-content:flex-end;-ms-flex-pack:flex-end;justify-content:flex-end;text-align:right;text-anchor:end}.ct-label.ct-vertical.ct-end{-webkit-box-align:flex-end;-webkit-align-items:flex-end;-ms-flex-align:flex-end;align-items:flex-end;-webkit-box-pack:flex-start;-webkit-justify-content:flex-start;-ms-flex-pack:flex-start;justify-content:flex-start;text-align:left;text-anchor:start}.ct-chart-bar .ct-label.ct-horizontal.ct-start{-webkit-box-align:flex-end;-webkit-align-items:flex-end;-ms-flex-align:flex-end;align-items:flex-end;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center;text-anchor:start}.ct-chart-bar .ct-label.ct-horizontal.ct-end{-webkit-box-align:flex-start;-webkit-align-items:flex-start;-ms-flex-align:flex-start;align-items:flex-start;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center;text-anchor:start}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start{-webkit-box-align:flex-end;-webkit-align-items:flex-end;-ms-flex-align:flex-end;align-items:flex-end;-webkit-box-pack:flex-start;-webkit-justify-content:flex-start;-ms-flex-pack:flex-start;justify-content:flex-start;text-align:left;text-anchor:start}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end{-webkit-box-align:flex-start;-webkit-align-items:flex-start;-ms-flex-align:flex-start;align-items:flex-start;-webkit-box-pack:flex-start;-webkit-justify-content:flex-start;-ms-flex-pack:flex-start;justify-content:flex-start;text-align:left;text-anchor:start}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start{-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:flex-end;-webkit-justify-content:flex-end;-ms-flex-pack:flex-end;justify-content:flex-end;text-align:right;text-anchor:end}.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end{-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:flex-start;-webkit-justify-content:flex-start;-ms-flex-pack:flex-start;justify-content:flex-start;text-align:left;text-anchor:end}.ct-grid{stroke:rgba(0,0,0,.2);stroke-width:1px;stroke-dasharray:2px}.ct-point{stroke-width:10px;stroke-linecap:round}.ct-line{fill:none;stroke-width:4px}.ct-area{stroke:none;fill-opacity:.1}.ct-bar{fill:none;stroke-width:10px}.ct-slice-donut{fill:none;stroke-width:60px}.ct-series-a .ct-bar,.ct-series-a .ct-line,.ct-series-a .ct-point,.ct-series-a .ct-slice-donut{stroke:#d70206}.ct-series-a .ct-area,.ct-series-a .ct-slice-pie{fill:#d70206}.ct-series-b .ct-bar,.ct-series-b .ct-line,.ct-series-b .ct-point,.ct-series-b .ct-slice-donut{stroke:#f05b4f}.ct-series-b .ct-area,.ct-series-b .ct-slice-pie{fill:#f05b4f}.ct-series-c .ct-bar,.ct-series-c .ct-line,.ct-series-c .ct-point,.ct-series-c .ct-slice-donut{stroke:#f4c63d}.ct-series-c .ct-area,.ct-series-c .ct-slice-pie{fill:#f4c63d}.ct-series-d .ct-bar,.ct-series-d .ct-line,.ct-series-d .ct-point,.ct-series-d .ct-slice-donut{stroke:#d17905}.ct-series-d .ct-area,.ct-series-d .ct-slice-pie{fill:#d17905}.ct-series-e .ct-bar,.ct-series-e .ct-line,.ct-series-e .ct-point,.ct-series-e .ct-slice-donut{stroke:#453d3f}.ct-series-e .ct-area,.ct-series-e .ct-slice-pie{fill:#453d3f}.ct-series-f .ct-bar,.ct-series-f .ct-line,.ct-series-f .ct-point,.ct-series-f .ct-slice-donut{stroke:#59922b}.ct-series-f .ct-area,.ct-series-f .ct-slice-pie{fill:#59922b}.ct-series-g .ct-bar,.ct-series-g .ct-line,.ct-series-g .ct-point,.ct-series-g .ct-slice-donut{stroke:#0544d3}.ct-series-g .ct-area,.ct-series-g .ct-slice-pie{fill:#0544d3}.ct-series-h .ct-bar,.ct-series-h .ct-line,.ct-series-h .ct-point,.ct-series-h .ct-slice-donut{stroke:#6b0392}.ct-series-h .ct-area,.ct-series-h .ct-slice-pie{fill:#6b0392}.ct-series-i .ct-bar,.ct-series-i .ct-line,.ct-series-i .ct-point,.ct-series-i .ct-slice-donut{stroke:#f05b4f}.ct-series-i .ct-area,.ct-series-i .ct-slice-pie{fill:#f05b4f}.ct-series-j .ct-bar,.ct-series-j .ct-line,.ct-series-j .ct-point,.ct-series-j .ct-slice-donut{stroke:#dda458}.ct-series-j .ct-area,.ct-series-j .ct-slice-pie{fill:#dda458}.ct-series-k .ct-bar,.ct-series-k .ct-line,.ct-series-k .ct-point,.ct-series-k .ct-slice-donut{stroke:#eacf7d}.ct-series-k .ct-area,.ct-series-k .ct-slice-pie{fill:#eacf7d}.ct-series-l .ct-bar,.ct-series-l .ct-line,.ct-series-l .ct-point,.ct-series-l .ct-slice-donut{stroke:#86797d}.ct-series-l .ct-area,.ct-series-l .ct-slice-pie{fill:#86797d}.ct-series-m .ct-bar,.ct-series-m .ct-line,.ct-series-m .ct-point,.ct-series-m .ct-slice-donut{stroke:#b2c326}.ct-series-m .ct-area,.ct-series-m .ct-slice-pie{fill:#b2c326}.ct-series-n .ct-bar,.ct-series-n .ct-line,.ct-series-n .ct-point,.ct-series-n .ct-slice-donut{stroke:#6188e2}.ct-series-n .ct-area,.ct-series-n .ct-slice-pie{fill:#6188e2}.ct-series-o .ct-bar,.ct-series-o .ct-line,.ct-series-o .ct-point,.ct-series-o .ct-slice-donut{stroke:#a748ca}.ct-series-o .ct-area,.ct-series-o .ct-slice-pie{fill:#a748ca}.ct-square{display:block;position:relative;width:100%}.ct-square:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:100%}.ct-square:after{display:table}.ct-square>svg{display:block;position:absolute;top:0;left:0}.ct-minor-second{display:block;position:relative;width:100%}.ct-minor-second:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:93.75%}.ct-minor-second:after{display:table}.ct-minor-second>svg{display:block;position:absolute;top:0;left:0}.ct-major-second{display:block;position:relative;width:100%}.ct-major-second:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:88.8888888889%}.ct-major-second:after{display:table}.ct-major-second>svg{display:block;position:absolute;top:0;left:0}.ct-minor-third{display:block;position:relative;width:100%}.ct-minor-third:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:83.3333333333%}.ct-minor-third:after{display:table}.ct-minor-third>svg{display:block;position:absolute;top:0;left:0}.ct-major-third{display:block;position:relative;width:100%}.ct-major-third:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:80%}.ct-major-third:after{display:table}.ct-major-third>svg{display:block;position:absolute;top:0;left:0}.ct-perfect-fourth{display:block;position:relative;width:100%}.ct-perfect-fourth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:75%}.ct-perfect-fourth:after{display:table}.ct-perfect-fourth>svg{display:block;position:absolute;top:0;left:0}.ct-perfect-fifth{display:block;position:relative;width:100%}.ct-perfect-fifth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:66.6666666667%}.ct-perfect-fifth:after{display:table}.ct-perfect-fifth>svg{display:block;position:absolute;top:0;left:0}.ct-minor-sixth{display:block;position:relative;width:100%}.ct-minor-sixth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:62.5%}.ct-minor-sixth:after{display:table}.ct-minor-sixth>svg{display:block;position:absolute;top:0;left:0}.ct-golden-section{display:block;position:relative;width:100%}.ct-golden-section:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:61.804697157%}.ct-golden-section:after{content:"";display:table;clear:both}.ct-golden-section>svg{display:block;position:absolute;top:0;left:0}.ct-major-sixth{display:block;position:relative;width:100%}.ct-major-sixth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:60%}.ct-major-sixth:after{display:table}.ct-major-sixth>svg{display:block;position:absolute;top:0;left:0}.ct-minor-seventh{display:block;position:relative;width:100%}.ct-minor-seventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:56.25%}.ct-minor-seventh:after{display:table}.ct-minor-seventh>svg{display:block;position:absolute;top:0;left:0}.ct-major-seventh{display:block;position:relative;width:100%}.ct-major-seventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:53.3333333333%}.ct-major-seventh:after{display:table}.ct-major-seventh>svg{display:block;position:absolute;top:0;left:0}.ct-octave{display:block;position:relative;width:100%}.ct-octave:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:50%}.ct-octave:after{display:table}.ct-octave>svg{display:block;position:absolute;top:0;left:0}.ct-major-tenth{display:block;position:relative;width:100%}.ct-major-tenth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:40%}.ct-major-tenth:after{display:table}.ct-major-tenth>svg{display:block;position:absolute;top:0;left:0}.ct-major-eleventh{display:block;position:relative;width:100%}.ct-major-eleventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:37.5%}.ct-major-eleventh:after{display:table}.ct-major-eleventh>svg{display:block;position:absolute;top:0;left:0}.ct-major-twelfth{display:block;position:relative;width:100%}.ct-major-twelfth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:33.3333333333%}.ct-major-twelfth:after{display:table}.ct-major-twelfth>svg{display:block;position:absolute;top:0;left:0}.ct-double-octave{display:block;position:relative;width:100%}.ct-double-octave:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:25%}.ct-double-octave:after{display:table}.ct-double-octave>svg{display:block;position:absolute;top:0;left:0}`; 3 | css +=`.ct-label.ct-vertical.ct-start, .ct-label.ct-horizontal.ct-end {color: black;}`; 4 | const style = document.createElement('style'); 5 | style.textContent = css; 6 | document.head.appendChild(style); 7 | } 8 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Contraster 17 | 139 | 140 | 141 |
142 |

Contrast analysis widget.


143 |
144 | By Ada Rose Edwards, 12 February 2016 145 |

146 |
147 |
148 |
149 |
150 |

It is important that sites maintain legibility of all their text content for all of their users. Low contrast text can make it very difficult for some readers to read some text. 151 | This was inspired by Lea Verou's Contrast Ratio tool which works out the contrast between foreground and background colours and gives a numerical answer to whether it has enough contrast.

152 | 153 |

In order to locate potential problem areas on a website I put together this tool which shows the distribution of contrast ratio and can highlight elements on the page with poor contrast.

154 | 155 |

It's running automatically on this page and it is a bookmarklet so you can run it on almost any site provided the Content Security Policy allows.

156 | 157 |

This tool was inspired by Lea Verou's work on site contrast and uses some code from her Contrast Ratio utility.

158 | 159 |

Drag the Bookmarklet to your bookmark bar:  Analyse Contrast

160 | 161 |

Firefox: Fallback bookmarklet, works in firefox, doesn't work offline:  Analyse Contrast

162 | 163 |

Examples of bad contrast

164 |

Push the "Highlight Low Contrast Elements" button to see these highlighted.

165 |
166 |
167 | Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. 168 |
169 |
170 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 171 |
172 |
173 | 174 |

Scripts for automated testing

175 |

As well as a bookmarklet to enable it's use with automated tests I also compiled it as a script you can inject into the page once it has loaded. They can be configured to just show the graph or highlight the problem areas. The results are attached to the window so you can easily retrieve the results.

176 |

177 | Scripts you can inject with webdriver or put in your phantomjs script to perform analysis: 178 |

182 | These scripts will perform the analysis automatically and attach the results to the window (window.contrastWidgetData.) The data format is as follows: 183 |
184 | window.contrastWidgetData = {
185 | 	"badContrastNodes": [
186 | 		{
187 | 			"node": DOM Node,			 // Node which is below threshold
188 | 			"contrastRatio": 2.6		  // contrast ratio of the node
189 | 		}
190 | 	],
191 | 	"proportionBadContrast": 7.0495,	  // Proportion of characters below threshold
192 | 	"chartData": [ Number × 16 ]		  // Data to produce the chart from 0 to >15,
193 | 	"highlightBadEls": Function		   // function to highlight bad elements
194 | }
195 | 
196 |

197 | 198 |

Optional: To configure the script inject into the page a settings object BEFORE you inject the script.

199 |
200 | window.contrastWidgetOptions = {
201 | 	badNodeCutOff: 4.5,		 // The threshold for poor contrast.
202 | 	showModal: true,		  // whether to show the modal with the graph
203 | 	highlightBadEls: false	// whether to highlight the bad nodes
204 | }
205 | 				
206 |

Limitations

207 |
    208 |
  • Background Images and Gradients will be ignored, but it is good practise for these to have a similar 'background-color' regardless to fall back to in case of a lack of gradient support or the image fails to load.
  • 209 |
  • Text in 0px2 area DOM nodes which have background colour will still have that background colour used.
  • 210 |
  • The node highlighter will mis-highlight transformed elements (scale, translate etc..)
  • 211 |
  • The highlight of highlighted 'position: fixed' elements will differ in position after a scroll
  • 212 |
  • The widget will be unable to check for updates if used on pages in which the csp disallows, this will not effect the function of the bookmarklet.
  • 213 |
214 | 215 |

How it works

216 |

The bookmarklet iterates over all text nodes in the document and collects their parents in a Set.

217 |

We then iterate over the Set of parents and use getComputedStyle to measure the color of the type.

218 |

We have to be a bit smarter to measure the background color we traverse up the tree until we find a parent with a background color declared.

219 |

These values are then used with the color lib from Lea Verou's tool, to work out a value the contrast.

220 |

We then sum the number of characters contained in that DOM node and use that to produce the small chart.

221 | 222 |

Potential Additional Features

223 |
    224 |
  • Identify type which is too small.
  • 225 |
  • Make it much slower but increase accuracy for layered semi-transparent nodes by collecting the background color from the first opaque node above the leaf node down to the leaf node we are measuring. Layer these in a canvas, then measure the colour of the canvas to work out the aggregate color.
  • 226 |
227 | 228 |

Contribute

229 |

Github Page with build instructions.

230 |
231 |
232 | 237 | 238 | 239 | 240 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import Color from './color'; 4 | import Chartist from 'chartist'; 5 | import ctCSS from './chartist.css.js'; 6 | import {version} from '../package.json'; 7 | import {lt} from 'semver'; 8 | 9 | const home = 'https://gh.ada.is/contrast-widget/'; 10 | const widgetOptions = window.contrastWidgetOptions || {}; 11 | const noColorCalculatedStyle = (function () { 12 | 'use strict'; 13 | const temp = document.createElement('div'); 14 | document.body.appendChild(temp); 15 | const styleAttr = window.getComputedStyle(temp).backgroundColor; 16 | document.body.removeChild(temp); 17 | return styleAttr; 18 | }()); 19 | 20 | /* eslint browser: true*/ 21 | function nodesWithTextNodesUnder (el) { 22 | 'use strict'; 23 | 24 | const elementsWithTextMap = new Map(); 25 | const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); 26 | let textNode; 27 | while(textNode = walk.nextNode()) { 28 | 29 | if (textNode.parentNode === undefined) { 30 | continue; 31 | } 32 | 33 | // ignore just whitespace nodes 34 | if (textNode.data.trim().length > 0) { 35 | if (elementsWithTextMap.has(textNode.parentNode)) { 36 | elementsWithTextMap.get(textNode.parentNode).push(textNode); 37 | } else { 38 | elementsWithTextMap.set(textNode.parentNode, [textNode]); 39 | } 40 | } 41 | } 42 | return Array.from(elementsWithTextMap); 43 | } 44 | 45 | function getBackgroundColorForEl (el) { 46 | 'use strict'; 47 | 48 | const bgc = window.getComputedStyle(el).backgroundColor; 49 | if (bgc !== noColorCalculatedStyle && bgc != "") { 50 | return bgc; 51 | } else if (el.parentNode) { 52 | if (el.parentNode !== document) { 53 | return getBackgroundColorForEl(el.parentNode); 54 | } 55 | } 56 | return null; 57 | } 58 | 59 | function getContrastForEl (el) { 60 | 'use strict'; 61 | 62 | const style = window.getComputedStyle(el); 63 | const color = style.color; 64 | const backgroundColor = getBackgroundColorForEl(el) || 'rgba(255, 255, 255, 1)'; 65 | const bColor = new Color(backgroundColor); 66 | const fColor = new Color(color); 67 | return bColor.contrast(fColor); 68 | } 69 | 70 | function generateContrastData () { 71 | 'use strict'; 72 | 73 | const badNodeCutoff = widgetOptions.badNodeCutOff || 4.5; 74 | const textNodes = nodesWithTextNodesUnder(document.body); 75 | const badNodes = []; 76 | let goodChars = 0; 77 | let badChars = 0; 78 | let chartData = [ 79 | 0, 0, 0, 0, 0, 80 | 0, 0, 0, 0, 0, 81 | 0, 0, 0, 0, 0, 82 | 0 83 | ]; // buckets representing 0-(15+) 84 | textNodes.forEach(inNode => { 85 | const n = inNode[0]; 86 | const ratio = getContrastForEl(n).ratio; 87 | const noCharacters = inNode[1].map(t => t.length).reduce((a,b) => a + b, 0); 88 | const bucket = Math.min(15, Math.round(ratio)); 89 | chartData[bucket] += noCharacters; 90 | if (ratio < badNodeCutoff) { 91 | badNodes.push({ 92 | node: n, 93 | contrastRatio: ratio 94 | }); 95 | badChars += noCharacters; 96 | } else { 97 | goodChars += noCharacters; 98 | } 99 | }); 100 | 101 | return { 102 | badContrastNodes: badNodes, 103 | proportionBadContrast: badChars / (badChars + goodChars), 104 | chartData: chartData.map(i => (i/(badChars + goodChars))) // average the data to keep numbers small 105 | } 106 | } 107 | 108 | function checkForUpdates() { 109 | if (!window.fetch) return Promise.resolve(false); 110 | return fetch(home + '__about.json') 111 | .then(response => response.ok ? response.json() : Promise.reject('Contrast Widget failed to check for updates, Bad Response')) 112 | .then(about => lt(version, about.version) && about.version); 113 | } 114 | 115 | function addScript(url) { 116 | return new Promise(function (resolve, reject) { 117 | var script = document.createElement('script'); 118 | script.setAttribute('src', url); 119 | document.head.appendChild(script); 120 | script.onload = resolve; 121 | script.onerror = reject; 122 | }); 123 | } 124 | 125 | function main() { 126 | 'use strict'; 127 | 128 | function css(el, props) { 129 | function units(prop, i) { 130 | if (typeof i === "number") { 131 | if (prop.match(/width|height|top|left|right|bottom/)) { 132 | return i + "px"; 133 | } 134 | } 135 | return i; 136 | } 137 | for (let n in props) { 138 | el.style[n] = units(n, props[n]); 139 | } 140 | return el; 141 | }; 142 | 143 | console.log('Adding Widget, version ' + version); 144 | 145 | function calcData() { 146 | const contrastData = generateContrastData(); 147 | contrastData.version = version; 148 | window.contrastWidgetData = contrastData; 149 | contrastData.highlightBadEls = highlightBadEls; 150 | return contrastData; 151 | } 152 | 153 | function removeHighlight() { 154 | [].slice.call(document.querySelectorAll('body > .widget-bad-el-marker')).forEach(node => document.body.removeChild(node)); 155 | } 156 | 157 | function highlightBadEls() { 158 | const contrastData = calcData(); 159 | removeHighlight(); 160 | const toAdd = contrastData.badContrastNodes.map(function ({node, contrastRatio}) { 161 | 162 | // use get bounding rect to draw rectangle around the bad node. 163 | const br = node.getBoundingClientRect(); 164 | 165 | if (br.width * br.height === 0) { 166 | return null; 167 | } 168 | 169 | const marker = document.createElement('div'); 170 | css(marker, { 171 | unset: 'all', 172 | position: 'absolute', 173 | backgroundColor: `hsla(${10 + (contrastRatio * 8)}, 100%, 60%, 0.4)`, 174 | left: br.left + window.scrollX, 175 | right: br.right + window.scrollX, 176 | top: br.top + window.scrollY, 177 | bottom: br.bottom + window.scrollY, 178 | width: br.width, 179 | height: br.height, 180 | textAlign: 'center', 181 | fontSize: br.height * 0.8 + 'px', 182 | lineHeight: br.height + 'px', 183 | overflow: 'hidden' 184 | }); 185 | marker.innerHTML = contrastRatio; 186 | marker.classList.add('widget-bad-el-marker'); 187 | return marker; 188 | }); 189 | 190 | toAdd.forEach(node => node && document.body.appendChild(node)); 191 | } 192 | 193 | const chartData = (() => calcData().chartData)(); 194 | 195 | if (widgetOptions.highlightBadEls === true) highlightBadEls(); 196 | if (widgetOptions.showModal === false) return; 197 | 198 | const chartWrapper = document.querySelector('#contrastBookmarklet_ChartWrapper') || document.createElement('div'); 199 | chartWrapper.id = 'contrastBookmarklet_ChartWrapper'; 200 | chartWrapper.innerHTML = ''; 201 | 202 | const menuBar = document.createElement('div'); 203 | chartWrapper.appendChild(menuBar); 204 | 205 | const buttonStyle = { 206 | all: 'unset', 207 | fontSize: '0.9em', 208 | border: '2px outset #A2A0A0', 209 | background: 'hsla(340,63%,92%,1)', 210 | fontFamily: `'Open Sans',Sans-serif`, 211 | borderRadius: '1em', 212 | color: 'black', 213 | padding: '0.2em 0.4em', 214 | cursor: 'pointer', 215 | margin: '0 0.5em 0.5em 0' 216 | }; 217 | 218 | const highlightButton = document.createElement('button'); 219 | css(highlightButton, buttonStyle); 220 | highlightButton.textContent = 'Highlight Low Contrast Elements'; 221 | menuBar.appendChild(highlightButton); 222 | highlightButton.addEventListener('click', highlightBadEls); 223 | 224 | const xButton = document.createElement('span'); 225 | xButton.innerHTML = 'ⓧ'; 226 | css(xButton, { 227 | display: 'inline-block', 228 | float: 'right', 229 | fontWeight: 'bold', 230 | fontSize: '1.6em', 231 | cursor: 'pointer', 232 | textDecoration: 'none', 233 | color: 'black', 234 | width: '1em', 235 | lineHeight: '1em', 236 | padding: '0.1em' 237 | }); 238 | menuBar.appendChild(xButton); 239 | xButton.addEventListener('click', function () { 240 | chartWrapper.parentNode.removeChild(chartWrapper); 241 | removeHighlight(); 242 | }); 243 | 244 | const iButton = document.createElement('a'); 245 | iButton.innerHTML = 'ⓘ'; 246 | css(iButton, { 247 | display: 'inline-block', 248 | float: 'right', 249 | fontWeight: 'bold', 250 | fontSize: '1.6em', 251 | cursor: 'pointer', 252 | textDecoration: 'none', 253 | color: 'black', 254 | width: '1em', 255 | lineHeight: '1em', 256 | padding: '0.1em' 257 | }); 258 | iButton.href = home; 259 | menuBar.appendChild(iButton); 260 | 261 | checkForUpdates().then(function (newVersion) { 262 | if (newVersion) { 263 | const updateMessage = document.createElement('div'); 264 | css(updateMessage, { 265 | padding: '0.5em', 266 | margin: '0.25em', 267 | border: '1px solid grey', 268 | borderRadius: '0.5em' 269 | }); 270 | updateMessage.innerHTML = `Update Available, from ${version} to ${newVersion}
`; 271 | const updateButton = document.createElement('button'); 272 | updateButton.textContent = 'Fetch Updated Bookmarklet'; 273 | css(updateButton, buttonStyle); 274 | css(updateButton, { 275 | margin: '0.25em 0.5em 0 0em' 276 | }); 277 | updateMessage.appendChild(updateButton); 278 | chartWrapper.appendChild(updateMessage); 279 | updateButton.addEventListener('click', function () { 280 | updateButton.textContent = 'Fetching...'; 281 | fetch(home + '/bookmarklet-snippet.html') 282 | .then(response => response.ok ? response.text() : Promise.reject('Snippet Response not ok')) 283 | .then(text => { 284 | const content = document.createRange().createContextualFragment(text); 285 | const style = content.getElementById('contrast-widget-update-style'); 286 | updateMessage.innerHTML = ''; 287 | document.head.appendChild(style); 288 | updateMessage.appendChild(content); 289 | }) 290 | .catch(e => { 291 | console.log(e); 292 | updateMessage.innerHTML = `Fetching new bookmarklet failed,
to update plase go to ${home}.`; 293 | }); 294 | }); 295 | } 296 | }, function (e) { 297 | console.log(e); 298 | }); 299 | 300 | css(chartWrapper, { 301 | position: 'fixed', 302 | bottom: 0, 303 | right: 0, 304 | padding: '1em', 305 | margin: '1em', 306 | background: '#eee', 307 | boxShadow: '0 0 1em 0 black', 308 | boxSizing: 'border-box', 309 | fontSize: '16px', 310 | fontFamily: `'Open Sans', 'Helvetica Neue', Helvetica, sans-serif`, 311 | zIndex: 1000 312 | }); 313 | 314 | document.body.appendChild(chartWrapper); 315 | 316 | ctCSS(); 317 | 318 | const yLabel = document.createElement('div'); 319 | yLabel.textContent = 'Proportion of Characters'; 320 | css(yLabel, { 321 | display: 'block', 322 | textAlign: 'left', 323 | fontWeight: 'bold' 324 | }); 325 | 326 | const xLabel = document.createElement('div'); 327 | xLabel.textContent = 'Contrast'; 328 | css(xLabel, { 329 | display: 'block', 330 | textAlign: 'center', 331 | fontWeight: 'bold' 332 | }); 333 | 334 | 335 | const chartContainer = document.createElement('div'); 336 | css(chartContainer, { 337 | marginBottom: '-1em' 338 | }); 339 | chartWrapper.appendChild(yLabel); 340 | chartWrapper.appendChild(chartContainer); 341 | chartWrapper.appendChild(xLabel); 342 | 343 | const line = new Chartist.Line(chartContainer, { 344 | labels: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, '15+'].map((a, i) => i % 3 ? '' : String(a)), 345 | series: [ 346 | chartData 347 | ] 348 | }, { 349 | low: 0.0, 350 | high: 1.0, 351 | showArea: true, 352 | areaBase: 0, 353 | showPoint: false, 354 | width: '300px', 355 | height: '200px', 356 | lineSmooth: Chartist.Interpolation.simple({ 357 | divisor: 2 358 | }) 359 | }); 360 | 361 | line.on('created', function(ctx) { 362 | var defs = ctx.svg.elem('defs'); 363 | defs.elem('linearGradient', { 364 | id: 'gradient', 365 | x1: 0, 366 | y1: 0, 367 | x2: 1, 368 | y2: 0 369 | }).elem('stop', { 370 | offset: 0, 371 | 'stop-color': 'hsla(10, 60%, 60%, 1)' 372 | }).parent().elem('stop', { 373 | offset: 0.5, 374 | 'stop-color': 'hsla(55, 90%, 60%, 1)' 375 | }).parent().elem('stop', { 376 | offset: 1, 377 | 'stop-color': 'hsla(100, 60%, 60%, 1)' 378 | }); 379 | 380 | const node = ctx.svg.querySelector('.ct-area')._node; 381 | node.style.fill = 'url(#gradient)'; 382 | node.style.fillOpacity = '1'; 383 | }); 384 | }; 385 | 386 | if (document.readyState === "complete") { 387 | main(); 388 | } else { 389 | document.onreadystatechange = function () { 390 | if (document.readyState == "complete") { 391 | main(); 392 | } 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /client/images/pattern.svg: -------------------------------------------------------------------------------- 1 | 2 | --------------------------------------------------------------------------------