wcag-scanner 1.2.65 → 1.2.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.scanBrowserPage = scanBrowserPage;
7
+ exports.getNthChildSelector = getNthChildSelector;
7
8
  exports.getElementPath = getElementPath;
9
+ exports.findElement = findElement;
10
+ exports.findBySnippet = findBySnippet;
8
11
  const images_1 = __importDefault(require("../rules/images"));
12
+ const backgroundImages_1 = __importDefault(require("../rules/backgroundImages"));
9
13
  const contrast_1 = __importDefault(require("../rules/contrast"));
10
14
  const forms_1 = __importDefault(require("../rules/forms"));
11
15
  const aria_1 = __importDefault(require("../rules/aria"));
12
16
  const structure_1 = __importDefault(require("../rules/structure"));
13
17
  const keyboard_1 = __importDefault(require("../rules/keyboard"));
18
+ const presets_1 = require("../rules/presets");
14
19
  const RULES = {
15
20
  images: images_1.default,
21
+ backgroundImages: backgroundImages_1.default,
16
22
  contrast: contrast_1.default,
17
23
  forms: forms_1.default,
18
24
  aria: aria_1.default,
@@ -20,40 +26,117 @@ const RULES = {
20
26
  keyboard: keyboard_1.default,
21
27
  };
22
28
  async function scanBrowserPage(options = {}) {
29
+ var _a, _b;
23
30
  const start = performance.now();
24
- const ruleNames = options.rules || Object.keys(RULES);
31
+ const ruleNames = (0, presets_1.resolveRuleNames)(options, presets_1.FAST_RULES);
25
32
  const violations = [];
26
33
  const warnings = [];
27
34
  const passes = [];
28
- for (const name of ruleNames) {
29
- const rule = RULES[name];
30
- if (!rule)
31
- continue;
32
- try {
33
- const res = await rule.check(document, window, options);
34
- violations.push(...(res.violations || []));
35
- warnings.push(...(res.warnings || []));
36
- passes.push(...(res.passes || []));
35
+ const overlayRoot = document.querySelector('[data-wcag-overlay-root="true"]');
36
+ const overlayParent = (_a = overlayRoot === null || overlayRoot === void 0 ? void 0 : overlayRoot.parentNode) !== null && _a !== void 0 ? _a : null;
37
+ const overlayNextSibling = (_b = overlayRoot === null || overlayRoot === void 0 ? void 0 : overlayRoot.nextSibling) !== null && _b !== void 0 ? _b : null;
38
+ try {
39
+ // Detach the dev overlay while scanning so the inspector never scans itself.
40
+ if (overlayRoot && overlayParent) {
41
+ overlayParent.removeChild(overlayRoot);
37
42
  }
38
- catch (_a) {
39
- // rule failed in browser context — skip silently
43
+ for (const name of ruleNames) {
44
+ const rule = RULES[name];
45
+ if (!rule)
46
+ continue;
47
+ try {
48
+ const res = await rule.check(document, window, options);
49
+ violations.push(...(res.violations || []));
50
+ warnings.push(...(res.warnings || []));
51
+ passes.push(...(res.passes || []));
52
+ }
53
+ catch (_c) {
54
+ // rule failed in browser context — skip silently
55
+ }
40
56
  }
41
57
  }
42
- // Exclude any elements that live inside the WCAG overlay itself
43
- const overlayRoot = document.querySelector('[data-wcag-overlay-root]');
44
- const isInOverlay = (el) => el != null && overlayRoot != null && overlayRoot.contains(el);
58
+ finally {
59
+ if (overlayRoot && overlayParent) {
60
+ overlayParent.insertBefore(overlayRoot, overlayNextSibling);
61
+ }
62
+ }
63
+ // Exclude elements that live inside the WCAG overlay itself
64
+ const overlaySurface = document.querySelector('[data-wcag-overlay="true"]');
65
+ const isInOverlay = (el) => el != null && ((overlaySurface != null && overlaySurface.contains(el)) ||
66
+ (overlayRoot != null && overlayRoot.contains(el)));
45
67
  const annotate = (item) => {
46
68
  const el = findElement(item, document);
47
- return { ...item, domElement: el !== null && el !== void 0 ? el : undefined, elementPath: el ? getElementPath(el) : undefined };
69
+ if (!el)
70
+ return { ...item };
71
+ return {
72
+ ...item,
73
+ domElement: el,
74
+ elementPath: getElementPath(el),
75
+ elementSelector: getNthChildSelector(el),
76
+ };
48
77
  };
49
78
  return {
50
- violations: violations.map(annotate).filter(v => { var _a; return !isInOverlay((_a = v.domElement) !== null && _a !== void 0 ? _a : null); }),
51
- warnings: warnings.map(annotate).filter(w => { var _a; return !isInOverlay((_a = w.domElement) !== null && _a !== void 0 ? _a : null); }),
52
- passes,
79
+ violations: dedupeIssues(violations.map(annotate)).filter(v => { var _a; return !isInOverlay((_a = v.domElement) !== null && _a !== void 0 ? _a : null); }),
80
+ warnings: dedupeIssues(warnings.map(annotate)).filter(w => { var _a; return !isInOverlay((_a = w.domElement) !== null && _a !== void 0 ? _a : null); }),
81
+ passes: dedupePasses(passes),
53
82
  duration: Math.round(performance.now() - start),
54
83
  };
55
84
  }
56
- /** Build a readable CSS-selector-style breadcrumb for an element. */
85
+ function dedupeIssues(items) {
86
+ const seen = new Set();
87
+ return items.filter(item => {
88
+ var _a, _b, _c;
89
+ const key = [
90
+ item.rule,
91
+ item.description,
92
+ item.impact,
93
+ item.snippet,
94
+ (_a = item.element) === null || _a === void 0 ? void 0 : _a.tagName,
95
+ (_b = item.element) === null || _b === void 0 ? void 0 : _b.id,
96
+ (_c = item.element) === null || _c === void 0 ? void 0 : _c.className,
97
+ ].join('::');
98
+ if (seen.has(key))
99
+ return false;
100
+ seen.add(key);
101
+ return true;
102
+ });
103
+ }
104
+ function dedupePasses(items) {
105
+ const seen = new Set();
106
+ return items.filter(item => {
107
+ const key = [item.rule, item.description].join('::');
108
+ if (seen.has(key))
109
+ return false;
110
+ seen.add(key);
111
+ return true;
112
+ });
113
+ }
114
+ /**
115
+ * Build a precise nth-child CSS selector path for an element.
116
+ * This is unambiguous and always finds the exact element.
117
+ */
118
+ function getNthChildSelector(el) {
119
+ const parts = [];
120
+ let current = el;
121
+ while (current && current.nodeType === 1) {
122
+ const parent = current.parentElement;
123
+ if (!parent)
124
+ break;
125
+ // If element has a unique ID we can stop traversing early
126
+ const id = current.id;
127
+ if (id) {
128
+ parts.unshift(`#${CSS.escape(id)}`);
129
+ break;
130
+ }
131
+ const index = Array.from(parent.children).indexOf(current) + 1;
132
+ parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`);
133
+ current = parent;
134
+ if (parts.length >= 8)
135
+ break;
136
+ }
137
+ return parts.join(' > ');
138
+ }
139
+ /** Build a human-readable breadcrumb label for an element. */
57
140
  function getElementPath(el) {
58
141
  const parts = [];
59
142
  let current = el;
@@ -63,7 +146,6 @@ function getElementPath(el) {
63
146
  part += `#${current.id}`;
64
147
  }
65
148
  else if (current.className && typeof current.className === 'string') {
66
- // Skip auto-generated class names (hashed, uuid-like)
67
149
  const classes = current.className
68
150
  .trim()
69
151
  .split(/\s+/)
@@ -80,16 +162,19 @@ function getElementPath(el) {
80
162
  function findElement(item, doc) {
81
163
  var _a, _b;
82
164
  const { element: info, snippet } = item;
165
+ // 1. ID — most reliable
83
166
  if (info === null || info === void 0 ? void 0 : info.id) {
84
167
  const el = doc.getElementById(info.id);
85
168
  if (el)
86
169
  return el;
87
170
  }
171
+ // 2. Snippet attribute matching
88
172
  if (snippet) {
89
173
  const el = findBySnippet(snippet, doc);
90
174
  if (el)
91
175
  return el;
92
176
  }
177
+ // 3. tagName + className
93
178
  if (info === null || info === void 0 ? void 0 : info.tagName) {
94
179
  try {
95
180
  const classes = (_b = (_a = info.className) === null || _a === void 0 ? void 0 : _a.trim().split(/\s+/).filter(Boolean)) !== null && _b !== void 0 ? _b : [];
@@ -99,7 +184,7 @@ function findElement(item, doc) {
99
184
  return el;
100
185
  }
101
186
  catch (_c) {
102
- // invalid selector
187
+ // invalid selector — skip
103
188
  }
104
189
  }
105
190
  return null;
@@ -0,0 +1,12 @@
1
+ export interface AiSuggestion {
2
+ code: string;
3
+ explanation: string;
4
+ }
5
+ export declare function getAiSuggestion(apiKey: string, violation: {
6
+ rule?: string;
7
+ description?: string;
8
+ snippet?: string;
9
+ help?: string;
10
+ }): Promise<AiSuggestion>;
11
+ export declare function getStoredApiKey(): string;
12
+ export declare function setStoredApiKey(key: string): void;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getAiSuggestion = getAiSuggestion;
4
+ exports.getStoredApiKey = getStoredApiKey;
5
+ exports.setStoredApiKey = setStoredApiKey;
6
+ const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
7
+ async function getAiSuggestion(apiKey, violation) {
8
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
9
+ const prompt = [
10
+ 'You are a web accessibility expert. A WCAG violation was found:',
11
+ `Rule: ${(_a = violation.rule) !== null && _a !== void 0 ? _a : ''}`,
12
+ `Issue: ${(_b = violation.description) !== null && _b !== void 0 ? _b : ''}`,
13
+ violation.help ? `Hint: ${violation.help}` : '',
14
+ violation.snippet ? `HTML:\n${violation.snippet}` : '',
15
+ '',
16
+ 'Reply with ONLY:',
17
+ '1. The corrected HTML inside a ```html block',
18
+ '2. One sentence explaining the fix',
19
+ ]
20
+ .filter(Boolean)
21
+ .join('\n');
22
+ const res = await fetch(`${GEMINI_URL}?key=${encodeURIComponent(apiKey)}`, {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify({
26
+ contents: [{ parts: [{ text: prompt }] }],
27
+ generationConfig: { maxOutputTokens: 512, temperature: 0.1 },
28
+ }),
29
+ });
30
+ if (!res.ok) {
31
+ const body = await res.text().catch(() => '');
32
+ throw new Error(`Gemini ${res.status}: ${body.slice(0, 120)}`);
33
+ }
34
+ const data = await res.json();
35
+ const text = (_h = (_g = (_f = (_e = (_d = (_c = data.candidates) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d.content) === null || _e === void 0 ? void 0 : _e.parts) === null || _f === void 0 ? void 0 : _f[0]) === null || _g === void 0 ? void 0 : _g.text) !== null && _h !== void 0 ? _h : '';
36
+ const codeMatch = text.match(/```(?:html)?\s*([\s\S]*?)```/);
37
+ const code = codeMatch ? codeMatch[1].trim() : '';
38
+ const explanation = (_j = text
39
+ .replace(/```(?:html)?\s*[\s\S]*?```/g, '')
40
+ .trim()
41
+ .split('\n')
42
+ .filter(Boolean)
43
+ .slice(-1)[0]) !== null && _j !== void 0 ? _j : 'See corrected code above.';
44
+ return { code, explanation };
45
+ }
46
+ function getStoredApiKey() {
47
+ var _a;
48
+ try {
49
+ return (_a = localStorage.getItem('wcag-gemini-key')) !== null && _a !== void 0 ? _a : '';
50
+ }
51
+ catch (_b) {
52
+ return '';
53
+ }
54
+ }
55
+ function setStoredApiKey(key) {
56
+ try {
57
+ localStorage.setItem('wcag-gemini-key', key.trim());
58
+ }
59
+ catch ( /* */_a) { /* */ }
60
+ }
@@ -3,3 +3,6 @@ export type { WcagDevOverlayProps } from './WcagDevOverlay';
3
3
  export { scanBrowserPage } from './browserScanner';
4
4
  export type { BrowserScanResults, AnnotatedViolation, AnnotatedWarning } from './browserScanner';
5
5
  export { initWcagOverlay } from './init';
6
+ export { getAiSuggestion, getStoredApiKey, setStoredApiKey } from './gemini';
7
+ export type { AiSuggestion } from './gemini';
8
+ export { FAST_RULES, FULL_RULES, RULE_PRESETS, resolveRuleNames } from '../rules/presets';
@@ -1,9 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.initWcagOverlay = exports.scanBrowserPage = exports.WcagDevOverlay = void 0;
3
+ exports.resolveRuleNames = exports.RULE_PRESETS = exports.FULL_RULES = exports.FAST_RULES = exports.setStoredApiKey = exports.getStoredApiKey = exports.getAiSuggestion = exports.initWcagOverlay = exports.scanBrowserPage = exports.WcagDevOverlay = void 0;
4
4
  var WcagDevOverlay_1 = require("./WcagDevOverlay");
5
5
  Object.defineProperty(exports, "WcagDevOverlay", { enumerable: true, get: function () { return WcagDevOverlay_1.WcagDevOverlay; } });
6
6
  var browserScanner_1 = require("./browserScanner");
7
7
  Object.defineProperty(exports, "scanBrowserPage", { enumerable: true, get: function () { return browserScanner_1.scanBrowserPage; } });
8
8
  var init_1 = require("./init");
9
9
  Object.defineProperty(exports, "initWcagOverlay", { enumerable: true, get: function () { return init_1.initWcagOverlay; } });
10
+ var gemini_1 = require("./gemini");
11
+ Object.defineProperty(exports, "getAiSuggestion", { enumerable: true, get: function () { return gemini_1.getAiSuggestion; } });
12
+ Object.defineProperty(exports, "getStoredApiKey", { enumerable: true, get: function () { return gemini_1.getStoredApiKey; } });
13
+ Object.defineProperty(exports, "setStoredApiKey", { enumerable: true, get: function () { return gemini_1.setStoredApiKey; } });
14
+ var presets_1 = require("../rules/presets");
15
+ Object.defineProperty(exports, "FAST_RULES", { enumerable: true, get: function () { return presets_1.FAST_RULES; } });
16
+ Object.defineProperty(exports, "FULL_RULES", { enumerable: true, get: function () { return presets_1.FULL_RULES; } });
17
+ Object.defineProperty(exports, "RULE_PRESETS", { enumerable: true, get: function () { return presets_1.RULE_PRESETS; } });
18
+ Object.defineProperty(exports, "resolveRuleNames", { enumerable: true, get: function () { return presets_1.resolveRuleNames; } });
@@ -0,0 +1,8 @@
1
+ import { ScannerOptions, ScanResults } from '../types';
2
+ /**
3
+ * Check CSS background images for potentially meaningful content that lacks text alternatives.
4
+ */
5
+ declare const _default: {
6
+ check(document: Document, window: Window, _options: ScannerOptions): Promise<ScanResults>;
7
+ };
8
+ export default _default;
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Check CSS background images for potentially meaningful content that lacks text alternatives.
5
+ */
6
+ exports.default = {
7
+ async check(document, window, _options) {
8
+ const results = {
9
+ passes: [],
10
+ violations: [],
11
+ warnings: []
12
+ };
13
+ checkBackgroundImages(document, window, results);
14
+ return results;
15
+ }
16
+ };
17
+ function checkBackgroundImages(document, window, results) {
18
+ var _a, _b, _c, _d;
19
+ const root = (_a = document.body) !== null && _a !== void 0 ? _a : document.documentElement;
20
+ if (!root)
21
+ return;
22
+ const walker = document.createTreeWalker(root, 1);
23
+ let current = walker.currentNode;
24
+ while (current) {
25
+ const element = current;
26
+ if (!shouldInspectBackgroundImage(element)) {
27
+ current = walker.nextNode();
28
+ continue;
29
+ }
30
+ const style = window.getComputedStyle(element);
31
+ const backgroundImage = style.backgroundImage;
32
+ if (!backgroundImage || backgroundImage === 'none') {
33
+ current = walker.nextNode();
34
+ continue;
35
+ }
36
+ if (isElementHidden(element, window) || element.getAttribute('aria-hidden') === 'true') {
37
+ current = walker.nextNode();
38
+ continue;
39
+ }
40
+ const info = {
41
+ tagName: element.tagName.toLowerCase(),
42
+ id: element.id || null,
43
+ className: ((_b = element.className) === null || _b === void 0 ? void 0 : _b.toString()) || null,
44
+ textContent: ((_c = element.textContent) === null || _c === void 0 ? void 0 : _c.substring(0, 50)) || null
45
+ };
46
+ const hasTextContent = ((_d = element.textContent) === null || _d === void 0 ? void 0 : _d.trim()) !== '';
47
+ const hasAriaLabel = element.hasAttribute('aria-label');
48
+ const hasAriaLabelledby = element.hasAttribute('aria-labelledby');
49
+ const hasTitle = element.hasAttribute('title');
50
+ if (!hasTextContent && !hasAriaLabel && !hasAriaLabelledby && !hasTitle) {
51
+ if (backgroundImage.includes('url(') && !isBackgroundLikelyDecorative(element)) {
52
+ results.warnings.push({
53
+ rule: 'background-image',
54
+ element: info,
55
+ impact: 'moderate',
56
+ description: 'Element with background image may need text alternative',
57
+ snippet: element.outerHTML.slice(0, 150) + (element.outerHTML.length > 150 ? '...' : ''),
58
+ wcag: ['1.1.1'],
59
+ help: 'If the background image conveys meaning, add text alternative via aria-label or text content'
60
+ });
61
+ }
62
+ }
63
+ current = walker.nextNode();
64
+ }
65
+ }
66
+ function isElementHidden(element, window) {
67
+ if (element.hasAttribute('hidden') || element.getAttribute('aria-hidden') === 'true') {
68
+ return true;
69
+ }
70
+ try {
71
+ const style = window.getComputedStyle(element);
72
+ return style.display === 'none' || style.visibility === 'hidden';
73
+ }
74
+ catch (_a) {
75
+ return false;
76
+ }
77
+ }
78
+ function shouldInspectBackgroundImage(element) {
79
+ var _a;
80
+ const tagName = element.tagName.toLowerCase();
81
+ if (['script', 'style', 'link', 'meta', 'head', 'title', 'base', 'noscript'].includes(tagName)) {
82
+ return false;
83
+ }
84
+ if (element.hasAttribute('hidden') || element.getAttribute('aria-hidden') === 'true') {
85
+ return false;
86
+ }
87
+ if (element.hasAttribute('style')) {
88
+ const inlineStyle = (element.getAttribute('style') || '').toLowerCase();
89
+ if (inlineStyle.includes('background'))
90
+ return true;
91
+ }
92
+ if (element.hasAttribute('title') || element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby')) {
93
+ return true;
94
+ }
95
+ if (element.childElementCount === 0 && ((_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim())) {
96
+ return true;
97
+ }
98
+ return ['div', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav', 'figure', 'a', 'button'].includes(tagName);
99
+ }
100
+ function isBackgroundLikelyDecorative(element) {
101
+ var _a, _b;
102
+ const decorativeElements = ['header', 'footer', 'section', 'article', 'div'];
103
+ const tagName = ((_a = element.tagName) === null || _a === void 0 ? void 0 : _a.toLowerCase()) || '';
104
+ if (decorativeElements.includes(tagName)) {
105
+ const className = ((_b = element.className) === null || _b === void 0 ? void 0 : _b.toString()) || '';
106
+ if (className.includes('background') ||
107
+ className.includes('banner') ||
108
+ className.includes('hero') ||
109
+ className.includes('container') ||
110
+ className.includes('wrapper') ||
111
+ className.includes('section')) {
112
+ return true;
113
+ }
114
+ }
115
+ return false;
116
+ }
@@ -18,6 +18,8 @@ exports.default = {
18
18
  violations: [],
19
19
  warnings: []
20
20
  };
21
+ const backgroundColorCache = new WeakMap();
22
+ const parsedColorCache = new Map();
21
23
  // Minimum contrast requirements by WCAG level
22
24
  const contrastRequirements = {
23
25
  'A': {
@@ -39,13 +41,15 @@ exports.default = {
39
41
  const textElements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, span, div, a, button, label, li, td, th');
40
42
  for (const element of textElements) {
41
43
  // Skip empty or hidden elements
42
- if (!((_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim()) || isElementHidden(element)) {
44
+ if (!((_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim())) {
43
45
  continue;
44
46
  }
45
- // Get computed styles
46
47
  const style = window.getComputedStyle(element);
48
+ if (isElementHidden(element, style)) {
49
+ continue;
50
+ }
47
51
  const textColor = style.color;
48
- const bgColor = getBackgroundColor(element, window);
52
+ const bgColor = getBackgroundColor(element, window, backgroundColorCache);
49
53
  // Skip if we couldn't determine colors
50
54
  if (!textColor || !bgColor) {
51
55
  continue;
@@ -53,11 +57,11 @@ exports.default = {
53
57
  // Determine if text is large according to WCAG
54
58
  const fontSize = parseFloat(style.fontSize);
55
59
  const fontWeight = style.fontWeight;
56
- const isLargeText = fontSize >= 24 || (fontSize >= 18.5 && parseInt(fontWeight) >= 700);
60
+ const isLargeText = fontSize >= 24 || (fontSize >= 18.5 && isBoldWeight(fontWeight));
57
61
  // Get required contrast ratio
58
62
  const requiredRatio = isLargeText ? requirements.largeText : requirements.normalText;
59
63
  // Calculate contrast ratio
60
- const contrastRatio = calculateContrastRatio(textColor, bgColor);
64
+ const contrastRatio = calculateContrastRatio(textColor, bgColor, parsedColorCache);
61
65
  const info = {
62
66
  tagName: element.tagName.toLowerCase(),
63
67
  id: element.id || null,
@@ -95,34 +99,45 @@ exports.default = {
95
99
  * Check if an element is hidden
96
100
  * @param element Element to check
97
101
  */
98
- function isElementHidden(element) {
102
+ function isElementHidden(element, style) {
99
103
  if (element.hasAttribute('hidden') || element.getAttribute('aria-hidden') === 'true') {
100
104
  return true;
101
105
  }
102
- try {
103
- const style = getComputedStyle(element);
104
- return style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0';
105
- }
106
- catch (e) {
107
- return false;
108
- }
106
+ return style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0';
107
+ }
108
+ function isBoldWeight(fontWeight) {
109
+ const numeric = parseInt(fontWeight, 10);
110
+ if (!Number.isNaN(numeric))
111
+ return numeric >= 700;
112
+ return fontWeight === 'bold' || fontWeight === 'bolder';
109
113
  }
110
114
  /**
111
115
  * Get the effective background color of an element
112
116
  * @param element Element to check
113
117
  * @param window Browser window
114
118
  */
115
- function getBackgroundColor(element, window) {
119
+ function getBackgroundColor(element, window, cache) {
120
+ const cached = cache.get(element);
121
+ if (cached)
122
+ return cached;
116
123
  let current = element;
117
124
  while (current !== null && current.nodeType === 1) {
125
+ const cachedCurrent = cache.get(current);
126
+ if (cachedCurrent) {
127
+ cache.set(element, cachedCurrent);
128
+ return cachedCurrent;
129
+ }
118
130
  const style = window.getComputedStyle(current);
119
131
  const backgroundColor = style.backgroundColor;
120
132
  if (backgroundColor && backgroundColor !== 'transparent' && backgroundColor !== 'rgba(0, 0, 0, 0)') {
133
+ cache.set(current, backgroundColor);
134
+ cache.set(element, backgroundColor);
121
135
  return backgroundColor;
122
136
  }
123
137
  current = current.parentElement;
124
138
  }
125
139
  // If we couldn't find a background color, default to white
140
+ cache.set(element, 'rgb(255, 255, 255)');
126
141
  return 'rgb(255, 255, 255)';
127
142
  }
128
143
  /**
@@ -130,9 +145,9 @@ function getBackgroundColor(element, window) {
130
145
  * @param foreground Foreground color
131
146
  * @param background Background color
132
147
  */
133
- function calculateContrastRatio(foreground, background) {
134
- const fgRgb = parseColor(foreground);
135
- const bgRgb = parseColor(background);
148
+ function calculateContrastRatio(foreground, background, cache) {
149
+ const fgRgb = parseColor(foreground, cache);
150
+ const bgRgb = parseColor(background, cache);
136
151
  if (!fgRgb || !bgRgb) {
137
152
  return 0;
138
153
  }
@@ -146,42 +161,53 @@ function calculateContrastRatio(foreground, background) {
146
161
  * Parse color string to RGB values
147
162
  * @param color Color string
148
163
  */
149
- function parseColor(color) {
164
+ function parseColor(color, cache) {
165
+ const cached = cache.get(color);
166
+ if (cached !== undefined)
167
+ return cached;
150
168
  // Handle 'rgb(r, g, b)' format
151
169
  let match = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i);
152
170
  if (match) {
153
- return {
171
+ const parsed = {
154
172
  r: parseInt(match[1]),
155
173
  g: parseInt(match[2]),
156
174
  b: parseInt(match[3])
157
175
  };
176
+ cache.set(color, parsed);
177
+ return parsed;
158
178
  }
159
179
  // Handle 'rgba(r, g, b, a)' format
160
180
  match = color.match(/rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9.]+)\s*\)/i);
161
181
  if (match) {
162
- return {
182
+ const parsed = {
163
183
  r: parseInt(match[1]),
164
184
  g: parseInt(match[2]),
165
185
  b: parseInt(match[3])
166
186
  };
187
+ cache.set(color, parsed);
188
+ return parsed;
167
189
  }
168
190
  // Handle hex format (#RRGGBB)
169
191
  match = color.match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i);
170
192
  if (match) {
171
- return {
193
+ const parsed = {
172
194
  r: parseInt(match[1], 16),
173
195
  g: parseInt(match[2], 16),
174
196
  b: parseInt(match[3], 16)
175
197
  };
198
+ cache.set(color, parsed);
199
+ return parsed;
176
200
  }
177
201
  // Handle shorthand hex format (#RGB)
178
202
  match = color.match(/#([0-9a-f])([0-9a-f])([0-9a-f])/i);
179
203
  if (match) {
180
- return {
204
+ const parsed = {
181
205
  r: parseInt(match[1] + match[1], 16),
182
206
  g: parseInt(match[2] + match[2], 16),
183
207
  b: parseInt(match[3] + match[3], 16)
184
208
  };
209
+ cache.set(color, parsed);
210
+ return parsed;
185
211
  }
186
212
  // Handle common color names
187
213
  const colorNames = {
@@ -194,8 +220,11 @@ function parseColor(color) {
194
220
  gray: { r: 128, g: 128, b: 128 }
195
221
  };
196
222
  if (colorNames[color.toLowerCase()]) {
197
- return colorNames[color.toLowerCase()];
223
+ const parsed = colorNames[color.toLowerCase()];
224
+ cache.set(color, parsed);
225
+ return parsed;
198
226
  }
227
+ cache.set(color, null);
199
228
  return null;
200
229
  }
201
230
  /**