Wednesday, September 20, 2017

True colors

Today's post is one that I'd generally qualify as 'quick and dirty', because it gets straight to the point and is mostly code with a little thinking thrown on top, like the cream on cup of hot chocolate (mmm, now I'm thinking about a nice cup of cocoa from Butler's and a stroll along High Street to the River Corrib and the Spanish Arch).

Group of colored pencils
One of the most troublesome accessibility issues is luminance contrast. Simply put, when setting the background and foreground color (background-color and color in CSS), you must maintain sufficient contrast. Most of the time, we have leave this task to designers and simply implement the vision they specify, including the various colors used; however, we as developers bear a responsibility for the interface as well, and that includes making certain that it is usable by as many people as possible.

To help us make certain a page is usable by as many people as possible, we have standards that include luminance contrast set out in the WCAG (by the way, if you're still following other standards, please join us in the 21st century). I won't bother to go into great detail about why they use the calculations they do to determine the luminance value of a color - other than the human eye perceives the different colors projected by a monitor (red, green, and blue) as having different luminance and that the luminance can be calculated. The discussion of the different equations that could be used to calculate luminance and how the human eye perceives luminance on a monitor is entirely tangential to this post, however. For our current discussion, I have put the equations used in the WCAG into JavaScript code that you can reuse.

Using the functions below you can build CSS preprocessor mixins that will automatically adjust colors based on their starting point, adjusting by 1 percent each iteration. Pay special attention to the limits set for large text and normal text (in the code as LIMIT_TEXT_LARGE and LIMIT_TEXT_NORMAL) because it make a significant difference in the colors available when moving from AA to AAA compliance. The function as defined below uses the values for AAA compliance rather than the less strict AA rules. For your convenience, the values for AA compliance are documented in the code if you wish to use those.

JavaScript
/**  * @private  * @description Normalizes a color value and returns an object with red, green, and blue values  * @returns {object}  * @param {string|object} value  */ function color(value) {   this.r = 0;   this.g = 0;   this.b = 0;   /**    * @description Returns a version darker by the specified percentage    * @returns {object}    * @param {number} amt    */   this.darken = function(amt) {     var delta = [this.r, this.g, this.b].map(function(c, i) {       var adjust = Math.min(Math.round((0 - c) * amt), -1);       return Math.max(adjust + c, 0);     });     this.r = delta[0];     this.g = delta[1];     this.b = delta[2];     return this;   };   /**    * @description Returns the hex value of the color    * @returns {string}    */   this.hex = function() {     return '#' +       ('0' + this.r.toString(16)).substr(-2) +       ('0' + this.g.toString(16)).substr(-2) +       ('0' + this.b.toString(16)).substr(-2);   };   /**    * @description Calculates luminance according to the WCAG. Lower values indicate darker color.    * @returns {number}    * @see https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests    */   this.luminance = function() {     var colors = [this.r, this.g, this.b].map(function(c) {       c /= 255;       return (c < 0.03929) ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);     }),     lum = (0.2126 * colors[0]) + (0.7152 * colors[1]) + (0.0722 * colors[2]);     return lum;   };   /**    * @description Returns a version lighter by the specified percentage    * @returns {object}    * @param {number} amt    */   this.lighten = function(amt) {     var delta = [this.r, this.g, this.b].map(function(c, i) {       var adjust = Math.max(Math.round((255 - c) * amt), 1);       return Math.min(adjust + c, 255);     });     this.r = delta[0];     this.g = delta[1];     this.b = delta[2];     return this;   };   /**    * @private    * @description constructs an object using the specified color    */   var h3 = /\b([0-9a-f])([0-9a-f])([0-9a-f])\b/i,     h6 = /\b([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})\b/i;   if (typeof value === 'string' && (h3.test(value) || h6.test(value))) {     h3 = h3.exec(value);     h6 = h6.exec(value);     this.r = parseInt(h6 ? h6[1] : h3[1]+h3[1], 16);     this.g = parseInt(h6 ? h6[2] : h3[2]+h3[2], 16);     this.b = parseInt(h6 ? h6[3] : h3[3]+h3[3], 16);   } else {     this.r = parseInt(value.r || '0', 16);     this.g = parseInt(value.g || '0', 16);     this.b = parseInt(value.b || '0', 16);   }   this.r = isNaN(this.r) ? 0 : this.r;   this.g = isNaN(this.g) ? 0 : this.g;   this.b = isNaN(this.b) ? 0 : this.b;   return this; } /**  * @description Calculates the luminance contrast and adjusts colors when applicable  * @returns {object}  * @param {string|object} fg - foreground in hexadecimal or an object with red, green, blue values  * @param {string|object} bg - background in hexadecimal or an object with red, green, blue values  * @param {string|number} fs - font size  * @param {boolean} bf - font is bold-face  */ function colorize(fg, bg, fs, bf) {   /**    * @private    * @description Compares two colors and returns true if the contrast is 7:1    * @returns {boolean}    * @param {object} color1    * @param {object} color2    * @param {boolean} large    */   function lowContrast(color1, color2, large) {     var LIMIT_TEXT_LARGE = 4.5, // for AAA, use 3.1 for AA       LIMIT_TEXT_NORMAL = 7; // for AAA, use 4.5 for AA     var lum1 = color1.luminance(),       lum2 = color2.luminance(),       diff = (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05);     if (isNaN(lum1) || isNaN(lum2)) {       throw new TypeError('Unable to calculate luminance for colors');     }     if (diff >= LIMIT_TEXT_LARGE) {       return (diff < LIMIT_TEXT_NORMAL && !large);     }     return true;   }   /* large print is defined as at least 14pt, which is approximately 1.2rem or 19 px */   var background = new color(bg),     foreground = new color(fg),     pt = /(\d{1,})pt/.exec(fs),     em = /(\d{1,})r?em/.exec(fs),     px = /(\d{1,})px/.exec(fs),     lp = ((px && ((parseFloat(px[1]) > 18 && bf) || parseFloat(px[1]) > 24)) ||       (pt && ((parseFloat(pt[1]) > 14 && bf) || parseFloat(pt[1]) > 18)) ||       (em && ((parseFloat(em[1]) > 1.1 && bf) || parseFloat(em[1]) > 1.5))) ||       false;   if (background.luminance() < foreground.luminance()) {     /* while the contrast is low, darken the darker color and lighten the lighter color */     while (lowContrast(background, foreground, lp)) {       background.darken(0.01);       foreground.lighten(0.01);     }   } else {     while (lowContrast(background, foreground, lp)) {       background.lighten(0.01);       foreground.darken(0.01);     }   }   return {     background: background,     foreground: foreground   }; }


Happy coding.

No comments:

Post a Comment