1class ColorContrast { 2 // Default text color constant for consistency 3 static DEFAULT_TEXT_COLOR = "#333333"; 4 5 // WCAG 2.1 AA minimum ratio for normal-size text 6 static MINIMUM_CONTRAST_RATIO = 4.5; 7 8 constructor(elementColor, componentElementsToCheck, siteDesignElementsToCheck) { 9 this.elementColor = elementColor; 10 this.componentElementsToCheck = componentElementsToCheck; 11 this.siteDesignElementsToCheck = siteDesignElementsToCheck; 12 } 13 14 /** 15 * Converts hex or RGB color to RGB object 16 * @param {string} hex - Color in hex format (#RGB, #RRGGBB) or RGB format (rgb(r, g, b)) 17 * @returns {Object} RGB object with r, g, b properties 18 */ 19 hexToRgb(hex) { 20 // Validate input type 21 if (typeof hex !== 'string') { 22 // Return default color for invalid input 23 return { r: 51, g: 51, b: 51 }; // #333333 in RGB 24 } 25 26 // Handle RGB format: rgb(r, g, b) 27 const rgbMatch = hex.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i); 28 if (rgbMatch) { 29 return { 30 r: parseInt(rgbMatch[1], 10), 31 g: parseInt(rgbMatch[2], 10), 32 b: parseInt(rgbMatch[3], 10) 33 }; 34 } 35 36 // Handle hex format 37 hex = hex.replace(/^#/, ''); 38 39 // Handle 3-digit hex format 40 if (hex.length === 3) { 41 const r = parseInt(hex[0] + hex[0], 16); 42 const g = parseInt(hex[1] + hex[1], 16); 43 const b = parseInt(hex[2] + hex[2], 16); 44 45 // Match the 6-digit path below. Returning NaN here makes every downstream 46 // ratio NaN, which silently passes the contrast check rather than failing it. 47 if (isNaN(r) || isNaN(g) || isNaN(b)) { 48 return { r: 51, g: 51, b: 51 }; // #333333 in RGB 49 } 50 51 return { r, g, b }; 52 } 53 54 // Handle 6-digit hex format 55 const r = parseInt(hex.substring(0, 2), 16); 56 const g = parseInt(hex.substring(2, 4), 16); 57 const b = parseInt(hex.substring(4, 6), 16); 58 59 // Use consistent default color handling (matching #333333 used elsewhere) 60 if (isNaN(r) || isNaN(g) || isNaN(b)) { 61 // Return default color for invalid format 62 return { r: 51, g: 51, b: 51 }; // #333333 in RGB 63 } 64 65 return { r, g, b }; 66 } 67 68 hexToHsl(hex) { 69 // First convert to RGB 70 const rgb = this.hexToRgb(hex); 71 72 // Validate that we have a valid RGB object 73 if (!rgb || typeof rgb !== 'object' || rgb.r === undefined) { 74 // Return default HSL equivalent to #333333 75 return { h: 0, s: 0, l: 20 }; // Default HSL equivalent to #333333 76 } 77 78 // Then convert RGB to HSL 79 let r = rgb.r / 255; 80 let g = rgb.g / 255; 81 let b = rgb.b / 255; 82 83 const max = Math.max(r, g, b); 84 const min = Math.min(r, g, b); 85 let h, s, l = (max + min) / 2; 86 87 if (max === min) { 88 h = s = 0; // achromatic 89 } else { 90 const d = max - min; 91 s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 92 switch(max) { 93 case r: h = (g - b) / d + (g < b ? 6 : 0); break; 94 case g: h = (b - r) / d + 2; break; 95 case b: h = (r - g) / d + 4; break; 96 } 97 h /= 6; 98 } 99 100 s = s*100; 101 s = Math.round(s); 102 l = l*100; 103 l = Math.round(l); 104 h = Math.round(360*h); 105 106 return {h, s, l} 107 } 108 109 HslToHex(h, s, l) { 110 l /= 100; 111 const a = s * Math.min(l, 1 - l) / 100; 112 const f = n => { 113 const k = (n + h / 30) % 12; 114 const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); 115 return Math.round(255 * color).toString(16).padStart(2, '0'); // convert to Hex and prefix "0" if needed 116 }; 117 return `#${f(0)}${f(8)}${f(4)}`; 118 } 119 120 /** 121 * Finds the hex color with the least contrast that meets the specified ratio 122 * @param {string} mutableHex - The color to adjust 123 * @param {string} staticHex - The static color to contrast against 124 * @param {number} ratio - The target contrast ratio 125 * @returns {string} Adjusted hex color 126 */ 127 HexWithLeastContrastMet(mutableHex, staticHex, ratio) { 128 const {h, s, l} = this.hexToHsl(mutableHex) 129 const inc = -0.01; 130 let l2 = l 131 132 // Add safety limit to prevent infinite loops 133 let iterations = 0; 134 const MAX_ITERATIONS = 1000; 135 136 // Ensure l2 stays within valid range (0-100) 137 const validateL2 = (value) => { 138 if (value < 0) return 0; 139 if (value > 100) return 100; 140 return value; 141 }; 142 143 if (this.calculateContrastRatio(this.HslToHex(h,s,l2), staticHex) < ratio) { 144 while (this.calculateContrastRatio(this.HslToHex(h,s,l2), staticHex) < ratio && iterations < MAX_ITERATIONS) { 145 l2 += inc; 146 l2 = validateL2(l2); // Ensure value stays in valid range 147 iterations++; 148 149 // Break if we've reached the limit of possible adjustments 150 if (l2 <= 0 || l2 >= 100) break; 151 } 152 } else { 153 while (this.calculateContrastRatio(this.HslToHex(h,s,l2), staticHex) > ratio && iterations < MAX_ITERATIONS) { 154 l2 -= inc; 155 l2 = validateL2(l2); // Ensure value stays in valid range 156 iterations++; 157 158 // Break if we've reached the limit of possible adjustments 159 if (l2 <= 0 || l2 >= 100) break; 160 } 161 } 162 163 return this.HslToHex(h, s, l2); 164 } 165 166 calculateRelativeLuminance(rgb) { 167 const gammaCorrected = (c) => { 168 c = c / 255; 169 return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); 170 }; 171 172 const r = gammaCorrected(rgb.r); 173 const g = gammaCorrected(rgb.g); 174 const b = gammaCorrected(rgb.b); 175 176 return 0.2126 * r + 0.7152 * g + 0.0722 * b; 177 } 178 179 calculateContrastRatio(hexColor1, hexColor2) { 180 const rgb1 = this.hexToRgb(hexColor1); 181 const rgb2 = this.hexToRgb(hexColor2); 182 183 const luminance1 = this.calculateRelativeLuminance(rgb1); 184 const luminance2 = this.calculateRelativeLuminance(rgb2); 185 186 // Calculate contrast ratio directly without intermediate variables 187 // WCAG 2.0 formula: (L1 + 0.05) / (L2 + 0.05) where L1 is the lighter color 188 const contrastRatio = (Math.max(luminance1, luminance2) + 0.05) / 189 (Math.min(luminance1, luminance2) + 0.05); 190 191 return contrastRatio.toFixed(2); 192 } 193 194 /** 195 * Resolves the CSS color names stored by the admin forms to their hex equivalents 196 * @param {string} value - Hex value, or one of the supported CSS color names 197 * @returns {string} Hex color 198 */ 199 resolveColorValue(value) { 200 switch (value) { 201 case "black": 202 return "#000000"; 203 case "white": 204 case "transparent": 205 return "#ffffff"; 206 case "whitesmoke": 207 return "#f5f5f5"; 208 case "silver": 209 return "#C0C0C0"; 210 case "dimgray": 211 return "#696969"; 212 default: 213 return value; 214 } 215 } 216 217 /** 218 * Finds every comparison color that fails the minimum ratio against the given color. 219 * Callers that need to block an action read this; callers that only render read 220 * calculateContrast, which is built from the same result. 221 * @param {Array} color - Pair of [label, hex value] 222 * @param {Object} staticColors - Map of comparison labels to color values 223 * @returns {Array} One {label, hex, ratio} entry per failure, in comparison order 224 */ 225 findContrastFailures(color, staticColors) { 226 const failures = []; 227 228 for (const [label, value] of Object.entries(staticColors)) { 229 const hex = this.resolveColorValue(value); 230 const ratio = this.calculateContrastRatio(color[1], hex); 231 232 if (ratio < ColorContrast.MINIMUM_CONTRAST_RATIO) { 233 failures.push({ label, hex, ratio }); 234 } 235 } 236 237 return failures; 238 } 239 240 calculateContrast(color, staticColors) { 241 const failures = this.findContrastFailures(color, staticColors); 242 243 if (failures.length === 0) { 244 return `<div class="alert alert-success" style="text-align: left;"><b>
244${color[0]}</b> meets contrast requirements.</div>`; 245 } 246 247 const items = failures 248 .map((failure) => `<li><b>${color[0]}</b> has insufficient contrast with <b>${failure.label}</b></li>`) 249 .join(''); 250 251 return `<div class="alert alert-warning" style="text-align: left;"><ul>${items}</ul></div>`; 252 } 253} 254 255// A class declaration in a classic script is not a property of the global object. 256// The bundled guardrails modules and unit tests both need it by reference. 257globalThis.ColorContrast = ColorContrast;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.