PageSourceSearch

https://mcisd.us/js/classes/ColorContrast.js?v=20260928201145

js mcisd.us collected 2026-09-29 15:15:45 UTC 9,217 bytes, 257 lines download raw bytes

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.