@govtechsg/oobee 0.10.70 → 0.10.74

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.
Files changed (93) hide show
  1. package/DETAILS.md +1 -1
  2. package/README.md +12 -0
  3. package/S3_UPLOAD_README.md +172 -0
  4. package/dev/runGenerateJustHtmlReport.ts +25 -0
  5. package/package.json +7 -4
  6. package/src/combine.ts +72 -15
  7. package/src/constants/common.ts +91 -93
  8. package/src/constants/constants.ts +536 -59
  9. package/src/crawlers/crawlDomain.ts +313 -305
  10. package/src/crawlers/crawlIntelligentSitemap.ts +24 -18
  11. package/src/crawlers/crawlLocalFile.ts +29 -27
  12. package/src/crawlers/crawlSitemap.ts +265 -254
  13. package/src/crawlers/custom/utils.ts +809 -119
  14. package/src/crawlers/runCustom.ts +29 -4
  15. package/src/generateHtmlReport.ts +224 -0
  16. package/src/mergeAxeResults.ts +133 -46
  17. package/src/runGenerateJustHtmlReport.ts +20 -0
  18. package/src/services/s3Uploader.ts +184 -0
  19. package/src/static/ejs/partials/components/allIssues/AllIssues.ejs +9 -0
  20. package/src/static/ejs/partials/components/allIssues/CategoryBadges.ejs +82 -0
  21. package/src/static/ejs/partials/components/allIssues/FilterBar.ejs +33 -0
  22. package/src/static/ejs/partials/components/allIssues/IssuesTable.ejs +41 -0
  23. package/src/static/ejs/partials/components/header/SiteInfo.ejs +119 -0
  24. package/src/static/ejs/partials/components/header/aboutScanModal/AboutScanModal.ejs +15 -0
  25. package/src/static/ejs/partials/components/header/aboutScanModal/ScanConfiguration.ejs +44 -0
  26. package/src/static/ejs/partials/components/header/aboutScanModal/ScanDetails.ejs +142 -0
  27. package/src/static/ejs/partials/components/prioritiseIssues/IssueDetailCard.ejs +36 -0
  28. package/src/static/ejs/partials/components/prioritiseIssues/PrioritiseIssues.ejs +47 -0
  29. package/src/static/ejs/partials/components/ruleModal/ruleOffcanvas.ejs +196 -0
  30. package/src/static/ejs/partials/components/scannedPagesSegmentedTabs.ejs +48 -0
  31. package/src/static/ejs/partials/components/shared/InfoAlert.ejs +3 -0
  32. package/src/static/ejs/partials/components/{topFive.ejs → topTen.ejs} +2 -2
  33. package/src/static/ejs/partials/components/wcagCompliance/FailedCriteria.ejs +47 -0
  34. package/src/static/ejs/partials/components/wcagCompliance/WcagCompliance.ejs +16 -0
  35. package/src/static/ejs/partials/components/wcagCompliance/WcagGaugeBar.ejs +16 -0
  36. package/src/static/ejs/partials/components/wcagCoverageDetails.ejs +18 -0
  37. package/src/static/ejs/partials/footer.ejs +1 -1
  38. package/src/static/ejs/partials/header.ejs +7 -223
  39. package/src/static/ejs/partials/main.ejs +12 -23
  40. package/src/static/ejs/partials/scripts/allIssues/AllIssues.ejs +376 -0
  41. package/src/static/ejs/partials/scripts/categorySummary.ejs +1 -1
  42. package/src/static/ejs/partials/scripts/header/SiteInfo.ejs +44 -0
  43. package/src/static/ejs/partials/scripts/header/aboutScanModal/AboutScanModal.ejs +51 -0
  44. package/src/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +127 -0
  45. package/src/static/ejs/partials/scripts/header/aboutScanModal/ScanDetails.ejs +60 -0
  46. package/src/static/ejs/partials/scripts/prioritiseIssues/IssueDetailCard.ejs +137 -0
  47. package/src/static/ejs/partials/scripts/prioritiseIssues/PrioritiseIssues.ejs +214 -0
  48. package/src/static/ejs/partials/scripts/prioritiseIssues/wcagSvgMap.ejs +861 -0
  49. package/src/static/ejs/partials/scripts/ruleModal/constants.ejs +957 -0
  50. package/src/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +353 -0
  51. package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +468 -0
  52. package/src/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +306 -0
  53. package/src/static/ejs/partials/scripts/ruleModal/utilities.ejs +483 -0
  54. package/src/static/ejs/partials/scripts/scannedPagesSegmentedTabs.ejs +35 -0
  55. package/src/static/ejs/partials/scripts/screenshotLightbox.ejs +61 -57
  56. package/src/static/ejs/partials/scripts/topTen.ejs +61 -0
  57. package/src/static/ejs/partials/scripts/utils.ejs +15 -0
  58. package/src/static/ejs/partials/scripts/wcagCompliance/FailedCriteria.ejs +103 -0
  59. package/src/static/ejs/partials/scripts/wcagCompliance/WcagGaugeBar.ejs +47 -0
  60. package/src/static/ejs/partials/scripts/wcagCompliance.ejs +15 -0
  61. package/src/static/ejs/partials/scripts/wcagCoverageDetails.ejs +75 -0
  62. package/src/static/ejs/partials/styles/allIssues/AllIssues.ejs +384 -0
  63. package/src/static/ejs/partials/styles/bootstrap.ejs +17 -1
  64. package/src/static/ejs/partials/styles/header/SiteInfo.ejs +121 -0
  65. package/src/static/ejs/partials/styles/header/aboutScanModal/AboutScanModal.ejs +82 -0
  66. package/src/static/ejs/partials/styles/header/aboutScanModal/ScanConfiguration.ejs +50 -0
  67. package/src/static/ejs/partials/styles/header/aboutScanModal/ScanDetails.ejs +149 -0
  68. package/src/static/ejs/partials/styles/header.ejs +7 -0
  69. package/src/static/ejs/partials/styles/prioritiseIssues/IssueDetailCard.ejs +141 -0
  70. package/src/static/ejs/partials/styles/prioritiseIssues/PrioritiseIssues.ejs +204 -0
  71. package/src/static/ejs/partials/styles/ruleModal/ruleOffcanvas.ejs +456 -0
  72. package/src/static/ejs/partials/styles/scannedPagesSegmentedTabs.ejs +46 -0
  73. package/src/static/ejs/partials/styles/shared/InfoAlert.ejs +12 -0
  74. package/src/static/ejs/partials/styles/styles.ejs +198 -470
  75. package/src/static/ejs/partials/styles/topTenCard.ejs +44 -0
  76. package/src/static/ejs/partials/styles/wcagCompliance/FailedCriteria.ejs +59 -0
  77. package/src/static/ejs/partials/styles/wcagCompliance/WcagGaugeBar.ejs +62 -0
  78. package/src/static/ejs/partials/styles/wcagCompliance.ejs +36 -0
  79. package/src/static/ejs/partials/styles/wcagCoverageDetails.ejs +33 -0
  80. package/src/static/ejs/report.ejs +42 -259
  81. package/src/static/ejs/summary.ejs +1 -1
  82. package/src/utils.ts +30 -0
  83. package/src/static/ejs/partials/components/categorySelector.ejs +0 -4
  84. package/src/static/ejs/partials/components/categorySelectorDropdown.ejs +0 -57
  85. package/src/static/ejs/partials/components/pagesScannedModal.ejs +0 -70
  86. package/src/static/ejs/partials/components/reportSearch.ejs +0 -47
  87. package/src/static/ejs/partials/components/ruleOffcanvas.ejs +0 -105
  88. package/src/static/ejs/partials/components/scanAbout.ejs +0 -328
  89. package/src/static/ejs/partials/components/wcagCompliance.ejs +0 -52
  90. package/src/static/ejs/partials/scripts/categorySelectorDropdownScript.ejs +0 -190
  91. package/src/static/ejs/partials/scripts/reportSearch.ejs +0 -287
  92. package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +0 -804
  93. package/src/static/ejs/partials/scripts/scanAboutScript.ejs +0 -38
@@ -0,0 +1,483 @@
1
+ <script>
2
+ function generateWcagConformanceLinks(conformanceList) {
3
+ const wcagConformanceUrls = {
4
+ wcag111: 'https://www.w3.org/TR/WCAG22/#non-text-content',
5
+ wcag122: 'https://www.w3.org/TR/WCAG22/#captions-prerecorded',
6
+ wcag131: 'https://www.w3.org/TR/WCAG22/#info-and-relationships',
7
+ wcag135: 'https://www.w3.org/TR/WCAG22/#identify-input-purpose',
8
+ wcag141: 'https://www.w3.org/TR/WCAG22/#use-of-color',
9
+ wcag142: 'https://www.w3.org/TR/WCAG22/#audio-control',
10
+ wcag143: 'https://www.w3.org/TR/WCAG22/#contrast-minimum',
11
+ wcag144: 'https://www.w3.org/TR/WCAG22/#resize-text',
12
+ wcag146: 'https://www.w3.org/TR/WCAG22/#contrast-enhanced',
13
+ wcag1412: 'https://www.w3.org/TR/WCAG22/#text-spacing',
14
+ wcag211: 'https://www.w3.org/TR/WCAG22/#keyboard',
15
+ wcag213: 'https://www.w3.org/WAI/WCAG22/Understanding/keyboard-no-exception.html',
16
+ wcag221: 'https://www.w3.org/TR/WCAG22/#timing-adjustable',
17
+ wcag222: 'https://www.w3.org/TR/WCAG22/#pause-stop-hide',
18
+ wcag224: 'https://www.w3.org/TR/WCAG22/#interruptions',
19
+ wcag241: 'https://www.w3.org/TR/WCAG22/#bypass-blocks',
20
+ wcag242: 'https://www.w3.org/TR/WCAG22/#page-titled',
21
+ wcag244: 'https://www.w3.org/TR/WCAG22/#link-purpose-in-context',
22
+ wcag249: 'https://www.w3.org/TR/WCAG22/#link-purpose-link-only',
23
+ wcag258: 'https://www.w3.org/TR/WCAG22/#target-size-minimum',
24
+ wcag311: 'https://www.w3.org/TR/WCAG22/#language-of-page',
25
+ wcag312: 'https://www.w3.org/TR/WCAG22/#language-of-parts',
26
+ wcag315: 'https://www.w3.org/TR/WCAG22/#reading-level',
27
+ wcag325: 'https://www.w3.org/TR/WCAG22/#change-on-request',
28
+ wcag332: 'https://www.w3.org/TR/WCAG22/#labels-or-instructions',
29
+ wcag412: 'https://www.w3.org/TR/WCAG22/#name-role-value',
30
+ };
31
+
32
+ const links = [];
33
+ for (let i = 1; i < conformanceList.length; i++) {
34
+ const [wcagSection, subSection, ...sectionItem] = conformanceList[i].slice(4);
35
+ const formattedConformanceNumber = `${wcagSection}.${subSection}.${sectionItem.join('')}`;
36
+ links.push(
37
+ `<a href="${wcagConformanceUrls[conformanceList[i]]}" target="_blank">WCAG ${formattedConformanceNumber}</a>`,
38
+ );
39
+ }
40
+ return links.join('&nbsp&nbsp,&nbsp&nbsp&nbsp');
41
+ }
42
+
43
+ function generateItemMessageElement(displayNeedsReview, rawMessage) {
44
+ if (rawMessage.includes('\n\nFix')) {
45
+ rawMessage = rawMessage.replace('\n\nFix', '\n Fix');
46
+ }
47
+
48
+ const htmlEscapedMessageArray = rawMessage.split('\n ').map(m => htmlEscapeString(m));
49
+
50
+ if (displayNeedsReview) {
51
+ if (htmlEscapedMessageArray.length === 1) {
52
+ return `<p class="mb-0">${htmlEscapedMessageArray[0]}</p>`;
53
+ } else {
54
+ return `<ul>${htmlEscapedMessageArray.map(m => `<li>${m}</li>`).join('')}</ul>`;
55
+ }
56
+ } else {
57
+ let i = 0;
58
+ const elements = [];
59
+ while (i < htmlEscapedMessageArray.length) {
60
+ if (htmlEscapedMessageArray[i].startsWith('Fix ')) {
61
+ elements.push(`<p class="mb-0">${htmlEscapedMessageArray[i]}</p>`);
62
+ i++;
63
+ } else {
64
+ const fixesList = [];
65
+ while (
66
+ i < htmlEscapedMessageArray.length &&
67
+ !htmlEscapedMessageArray[i].startsWith('Fix a')
68
+ ) {
69
+ fixesList.push(`<li>${htmlEscapedMessageArray[i]}</li>`);
70
+ i++;
71
+ }
72
+ elements.push(`<ul>${fixesList.join('')}</ul>`);
73
+ }
74
+ }
75
+
76
+ return elements.join('');
77
+ }
78
+ }
79
+
80
+ // Generate AI-powered fix suggestions using wcag-eval API
81
+ const generateGenAiSuggestFix = async (ruleId, accordionDiv, html, buttonDiv, errorDiv) => {
82
+ console.log('Gen AI Suggest Fix called:', { ruleId, accordionDiv, html, buttonDiv, errorDiv });
83
+
84
+ const SUPPORTED_RULES = ['color-contrast', 'oobee-accessible-label', 'image-alt', 'listitem', 'link-in-text-block', 'target-size'];
85
+ if (!SUPPORTED_RULES.includes(ruleId)) {
86
+ console.warn(`Gen AI Suggest Fix not yet available for ${ruleId}`);
87
+ return;
88
+ }
89
+
90
+ // Update button state
91
+ const button = document.getElementById(buttonDiv);
92
+ if (!button) {
93
+ console.error('Gen AI button not found:', buttonDiv);
94
+ return;
95
+ }
96
+
97
+ button.disabled = true;
98
+ button.textContent = 'Generating Fix...';
99
+
100
+ // Clear previous errors
101
+ const errorContainer = document.getElementById(errorDiv);
102
+ if (errorContainer) {
103
+ errorContainer.innerHTML = '';
104
+ }
105
+
106
+ try {
107
+ let violationContext = null;
108
+ let elementContext = null;
109
+
110
+ // Attempt to get violation context from embedded JSON data
111
+ if (window.oobeeJsonContextParser && window.oobeeJsonContextParser.jsonData) {
112
+ violationContext = window.oobeeJsonContextParser.getViolationContextByHtml(html, ruleId);
113
+
114
+ if (violationContext) {
115
+ console.log('Found violation context from embedded JSON:', violationContext);
116
+ } else {
117
+ console.warn('No matching violation found in embedded JSON data, will extract context from DOM');
118
+ }
119
+ } else {
120
+ console.warn('Embedded JSON context parser not available, will extract context from DOM');
121
+ }
122
+
123
+ // Extract element context from DOM if embedded JSON data is not available
124
+ if (!violationContext || !violationContext.colorData) {
125
+ console.log('Extracting element context from DOM as fallback');
126
+ elementContext = extractElementContext(html);
127
+ } else {
128
+ console.log('Using embedded JSON violation context, skipping DOM extraction');
129
+ }
130
+
131
+ // Build scanner findings from embedded JSON data or fallback to extracted context
132
+ let scannerFindings;
133
+ if (violationContext && violationContext.colorData) {
134
+ const colorData = violationContext.colorData;
135
+ console.log('Using color data from embedded JSON:', {
136
+ foreground: colorData.foreground,
137
+ background: colorData.background,
138
+ currentRatio: colorData.currentRatio,
139
+ requiredRatio: colorData.requiredRatio,
140
+ fontSize: colorData.fontSize,
141
+ fontWeight: colorData.fontWeight
142
+ });
143
+
144
+ scannerFindings = {
145
+ foreground: colorData.foreground || '#000000',
146
+ background: colorData.background || '#ffffff',
147
+ font_size_px: colorData.fontSize || 16,
148
+ font_weight: colorData.fontWeight || '400',
149
+ contrast_ratio: colorData.currentRatio || 1.0,
150
+ required_ratio: colorData.requiredRatio || 4.5,
151
+ actual_html: violationContext.html,
152
+ xpath_selector: violationContext.xpath
153
+ };
154
+ console.log('Using embedded JSON-based scanner findings with contrast ratio:', scannerFindings.contrast_ratio);
155
+ } else {
156
+ scannerFindings = {
157
+ foreground: elementContext?.foreground || '#000000',
158
+ background: elementContext?.background || '#ffffff',
159
+ font_size_px: elementContext?.fontSize || 16,
160
+ font_weight: elementContext?.fontWeight || '400',
161
+ contrast_ratio: elementContext?.contrastRatio || 1.0,
162
+ required_ratio: 4.5
163
+ };
164
+ console.log('Using fallback scanner findings:', scannerFindings);
165
+ }
166
+
167
+ const violationTypeMap = {
168
+ 'color-contrast': 'color-contrast',
169
+ 'oobee-accessible-label': 'accessible-label',
170
+ 'image-alt': 'image-alt',
171
+ 'listitem': 'listitem',
172
+ 'target-size': 'target-size'
173
+ };
174
+ let violationType = violationTypeMap[ruleId] || ruleId;
175
+
176
+ // Prepare payload for wcag-eval API
177
+ const apiPayload = {
178
+ violationType: violationType,
179
+ html: html,
180
+ css: elementContext?.css || '',
181
+ violationContext: violationContext ? {
182
+ severity: violationContext.severity,
183
+ issue_description: violationContext.issueDescription,
184
+ wcag_conformance: violationContext.wcagConformance,
185
+ page_title: violationContext.pageTitle,
186
+ page_url: violationContext.url,
187
+ how_to_fix: violationContext.howToFix,
188
+ axe_impact: violationContext.axeImpact,
189
+ learn_more: violationContext.learnMore
190
+ } : {},
191
+ context: {
192
+ rule_id: ruleId,
193
+ timestamp: new Date().toISOString(),
194
+ data_source: violationContext ? 'json_scan_results' : 'dom_extraction'
195
+ }
196
+ };
197
+
198
+ // Add scannerFindings for color-contrast violations
199
+ if (violationType === 'color-contrast') {
200
+ apiPayload.scannerFindings = scannerFindings;
201
+ }
202
+
203
+ console.log('Sending request to wcag-eval API:', apiPayload);
204
+
205
+ // Call wcag-eval API (now in oobee-web-proxy)
206
+ const wcagEvalApiUrl = `${proxyUrl}/api/ai/generate-fix` || 'http://localhost:3002/api/ai/generate-fix';
207
+ const response = await fetch(wcagEvalApiUrl, {
208
+ method: 'POST',
209
+ headers: {
210
+ 'Content-Type': 'application/json',
211
+ 'Accept': 'application/json'
212
+ },
213
+ body: JSON.stringify(apiPayload)
214
+ });
215
+
216
+ if (!response.ok) {
217
+ throw new Error(`API request failed: ${response.status} ${response.statusText}`);
218
+ }
219
+
220
+ const apiResponse = await response.json();
221
+ console.log('Received response from wcag-eval API:', apiResponse);
222
+
223
+ if (!apiResponse.success) {
224
+ throw new Error(apiResponse.error || 'API returned unsuccessful response');
225
+ }
226
+
227
+ const fixSuggestion = apiResponse.fixSuggestion;
228
+ console.log('Fix suggestion data:', fixSuggestion);
229
+ console.log('Code fixes array:', fixSuggestion?.codeFixes);
230
+
231
+ // Format the AI response for display
232
+ const formattedResponse = formatFixSuggestionResponse(fixSuggestion);
233
+
234
+ // Display the generated fix
235
+ const responseContainer = document.getElementById(accordionDiv);
236
+ if (responseContainer) {
237
+ responseContainer.innerHTML = formattedResponse;
238
+
239
+ // Highlight code blocks
240
+ responseContainer.querySelectorAll('.codeForAiResponse').forEach(el => {
241
+ if (typeof hljs !== 'undefined') {
242
+ hljs.highlightElement(el);
243
+ }
244
+ });
245
+ }
246
+
247
+ // Reset button to regenerate state
248
+ button.disabled = false;
249
+ button.style = 'border: 1px solid var(--oobee-blue-100); color: var(--oobee-blue-100);';
250
+ button.textContent = 'Regenerate Fix';
251
+
252
+ } catch (error) {
253
+ console.error('Error in Gen AI fix generation:', error);
254
+
255
+ // Reset button to initial state
256
+ button.disabled = false;
257
+ button.style = 'border: 1px solid var(--oobee-blue-100); color: var(--oobee-blue-100);';
258
+ button.textContent = 'Gen AI Suggest Fix';
259
+
260
+ if (errorContainer) {
261
+ const isConnectionError = error.message.includes('fetch') || error.message.includes('Failed to fetch');
262
+ const errorMessage = isConnectionError
263
+ ? 'Unable to connect to AI service. Please ensure the wcag-eval API is running on port 5000.'
264
+ : `Failed to generate fix suggestion: ${error.message}`;
265
+
266
+ errorContainer.innerHTML = `<div class="generateAiError">${errorMessage}</div>`;
267
+ }
268
+ }
269
+ };
270
+
271
+ // Helper function to extract element context from HTML/CSS
272
+ function extractElementContext(html) {
273
+ const context = {
274
+ css: '',
275
+ foreground: '#000000',
276
+ background: '#ffffff',
277
+ fontSize: 16,
278
+ fontWeight: '400',
279
+ contrastRatio: 1.0
280
+ };
281
+
282
+ try {
283
+ const tempDiv = document.createElement('div');
284
+ tempDiv.innerHTML = html;
285
+ const element = tempDiv.querySelector('*') || tempDiv;
286
+
287
+ if (element && element.parentNode) {
288
+ document.body.appendChild(tempDiv);
289
+ const computedStyle = window.getComputedStyle(element);
290
+
291
+ context.foreground = rgbToHex(computedStyle.color) || context.foreground;
292
+ context.background = rgbToHex(computedStyle.backgroundColor) || context.background;
293
+ context.fontSize = parseFloat(computedStyle.fontSize) || context.fontSize;
294
+ context.fontWeight = computedStyle.fontWeight || context.fontWeight;
295
+
296
+ if (element.style && element.style.cssText) {
297
+ context.css = element.style.cssText;
298
+ }
299
+
300
+ document.body.removeChild(tempDiv);
301
+ }
302
+
303
+ if (context.foreground && context.background) {
304
+ context.contrastRatio = calculateContrastRatio(context.foreground, context.background);
305
+ }
306
+
307
+ } catch (error) {
308
+ console.warn('Could not extract element context:', error);
309
+ }
310
+
311
+ return context;
312
+ }
313
+
314
+ // Format fix suggestion response for display
315
+ function formatFixSuggestionResponse(fixSuggestion) {
316
+ // Support both snake_case (Python) and camelCase (TypeScript) responses
317
+ const contrastAnalysis = fixSuggestion.contrast_analysis || fixSuggestion.contrastAnalysis || {};
318
+ const targetSizeAnalysis = fixSuggestion.target_size_analysis || fixSuggestion.targetSizeAnalysis || {};
319
+ const plainExplanation = fixSuggestion.why_it_matters || fixSuggestion.explanation || 'This fix improves accessibility for users with visual impairments.';
320
+ const diagnosis = fixSuggestion.diagnosis || fixSuggestion.explanation || plainExplanation;
321
+
322
+ // Escape HTML for safe rendering
323
+ const escapeHtml = (text) => String(text)
324
+ .replace(/&/g, '&amp;')
325
+ .replace(/</g, '&lt;')
326
+ .replace(/>/g, '&gt;')
327
+ .replace(/"/g, '&quot;')
328
+ .replace(/'/g, '&#039;');
329
+
330
+ // Build code fixes section
331
+ let codeFixesHtml = '';
332
+
333
+ // Handle TypeScript format (css/html directly in response)
334
+ if (fixSuggestion.css || fixSuggestion.html) {
335
+ const mainFix = [];
336
+ if (fixSuggestion.css) {
337
+ mainFix.push(`
338
+ <div style="margin-bottom: 16px;">
339
+ <p style="font-size: 13px; margin-bottom: 8px; color: #666; font-weight: 500;">Recommended CSS Fix:</p>
340
+ <code class="codeForAiResponse language-css hljs" style="display: block; padding: 16px; background: #f8f9fa; border-radius: 6px; font-size: 13px; border: 1px solid #e0e0e0; white-space: pre-wrap; word-break: break-all;">${escapeHtml(fixSuggestion.css)}</code>
341
+ </div>
342
+ `);
343
+ }
344
+ if (fixSuggestion.html) {
345
+ mainFix.push(`
346
+ <div style="margin-bottom: 16px;">
347
+ <p style="font-size: 13px; margin-bottom: 8px; color: #666; font-weight: 500;">Recommended HTML Fix:</p>
348
+ <code class="codeForAiResponse language-html hljs" style="display: block; padding: 16px; background: #f8f9fa; border-radius: 6px; font-size: 13px; border: 1px solid #e0e0e0; white-space: pre-wrap; word-break: break-all;">${escapeHtml(fixSuggestion.html)}</code>
349
+ </div>
350
+ `);
351
+ }
352
+
353
+ // Handle alternatives
354
+ if (fixSuggestion.alternatives && Array.isArray(fixSuggestion.alternatives) && fixSuggestion.alternatives.length > 0) {
355
+ const alternatives = fixSuggestion.alternatives.map((alt, index) => {
356
+ const altCode = alt.css || alt.html || '';
357
+ return `
358
+ <div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #e0e0e0;">
359
+ <p style="font-size: 13px; margin-bottom: 8px; color: #666; font-weight: 500;">Alternative ${index + 1}: ${escapeHtml(alt.explanation || '')}</p>
360
+ <code class="codeForAiResponse language-${alt.css ? 'css' : 'html'} hljs" style="display: block; padding: 16px; background: #f8f9fa; border-radius: 6px; font-size: 13px; border: 1px solid #e0e0e0; white-space: pre-wrap; word-break: break-all;">${escapeHtml(altCode)}</code>
361
+ </div>
362
+ `;
363
+ }).join('');
364
+ mainFix.push(alternatives);
365
+ }
366
+
367
+ codeFixesHtml = `
368
+ <div style="margin-bottom: 16px;">
369
+ <h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1a1a1a;">Code Fix:</h4>
370
+ ${mainFix.join('')}
371
+ </div>
372
+ `;
373
+ }
374
+ // Handle Python format (code_fixes array)
375
+ else if (fixSuggestion.code_fixes && Array.isArray(fixSuggestion.code_fixes) && fixSuggestion.code_fixes.length > 0) {
376
+ const codeFixes = fixSuggestion.code_fixes.map((fix, index) => {
377
+ const language = fix.language || 'html';
378
+ const description = fix.description || `Option ${index + 1}`;
379
+ const code = fix.after || fix.before || '';
380
+ const marginBottom = index === fixSuggestion.code_fixes.length - 1 ? '0' : '20px';
381
+
382
+ return `
383
+ <div style="margin-bottom: ${marginBottom};">
384
+ <p style="font-size: 13px; margin-bottom: 8px; color: #666; font-weight: 500;">${escapeHtml(description)}</p>
385
+ <code class="codeForAiResponse language-${escapeHtml(language)} hljs" style="display: block; padding: 16px; background: #f8f9fa; border-radius: 6px; font-size: 13px; border: 1px solid #e0e0e0; white-space: pre-wrap; word-break: break-all;">${escapeHtml(code)}</code>
386
+ </div>`;
387
+ }).join('');
388
+
389
+ codeFixesHtml = `
390
+ <div style="margin-bottom: 16px;">
391
+ <h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1a1a1a;">Code Fix${fixSuggestion.code_fixes.length > 1 ? 'es' : ''}:</h4>
392
+ ${codeFixes}
393
+ </div>
394
+ `;
395
+ }
396
+
397
+ return `
398
+ <div class="genai-response-card">
399
+ <div style="margin-bottom: 24px;">
400
+ <h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1a1a1a;">What's the problem?</h4>
401
+ <p style="font-size: 14px; line-height: 1.6; margin-bottom: 16px; color: #333;">
402
+ ${escapeHtml(diagnosis)}
403
+ </p>
404
+
405
+ ${contrastAnalysis.current_ratio ? `
406
+ <div style="background: #FAF8FD; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
407
+ <p style="margin: 0; font-size: 14px; line-height: 1.6; color: #333;">
408
+ The current text has a contrast ratio of <strong>${contrastAnalysis.current_ratio}:1</strong>,
409
+ but it needs at least <strong>${contrastAnalysis.required_ratio}:1</strong> to meet accessibility standards.
410
+ ${contrastAnalysis.passes_aa
411
+ ? `The suggested fix achieves <strong>${contrastAnalysis.proposed_ratio}:1</strong>, which passes WCAG AA requirements.`
412
+ : `The suggested fix improves it to <strong>${contrastAnalysis.proposed_ratio}:1</strong>.`}
413
+ </p>
414
+ </div>
415
+ ` : ''}
416
+
417
+ ${targetSizeAnalysis && (targetSizeAnalysis.estimated_width !== undefined || targetSizeAnalysis.estimated_height !== undefined || targetSizeAnalysis.notes) ? `
418
+ <div style="background: #F5FAFF; padding: 12px; border-radius: 6px; margin-bottom: 16px; border: 1px solid #e0efff;">
419
+ <p style="margin: 0; font-size: 14px; line-height: 1.6; color: #333;">
420
+ ${targetSizeAnalysis.meets_minimum === false
421
+ ? 'Estimated hit area is below the WCAG 2.5.8 minimum of 24x24 CSS pixels.'
422
+ : 'Target size analysis for this control:'}
423
+ ${targetSizeAnalysis.estimated_width !== undefined && targetSizeAnalysis.estimated_width !== null
424
+ ? ` Estimated width: <strong>${targetSizeAnalysis.estimated_width}${typeof targetSizeAnalysis.estimated_width === 'number' ? 'px' : ''}</strong>.`
425
+ : ''}
426
+ ${targetSizeAnalysis.estimated_height !== undefined && targetSizeAnalysis.estimated_height !== null
427
+ ? ` Estimated height: <strong>${targetSizeAnalysis.estimated_height}${typeof targetSizeAnalysis.estimated_height === 'number' ? 'px' : ''}</strong>.`
428
+ : ''}
429
+ ${targetSizeAnalysis.notes ? ` ${escapeHtml(targetSizeAnalysis.notes)}` : ''}
430
+ </p>
431
+ </div>
432
+ ` : ''}
433
+
434
+ ${fixSuggestion.fix_steps && fixSuggestion.fix_steps.length > 0 ? `
435
+ <div style="margin-top: 16px;">
436
+ <h5 style="font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #1a1a1a;">How to fix it:</h5>
437
+ <ol style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8; color: #333;">
438
+ ${fixSuggestion.fix_steps.map(step => `<li style="word-wrap: break-word; overflow-wrap: break-word;">${escapeHtml(step)}</li>`).join('')}
439
+ </ol>
440
+ </div>
441
+ ` : ''}
442
+ </div>
443
+
444
+ ${codeFixesHtml}
445
+ </div>
446
+ `;
447
+ }
448
+
449
+ // Convert RGB color to hex format
450
+ function rgbToHex(rgb) {
451
+ if (!rgb || rgb === 'transparent' || rgb === 'inherit') return null;
452
+
453
+ const result = rgb.match(/\d+/g);
454
+ if (!result || result.length < 3) return null;
455
+
456
+ const r = parseInt(result[0]);
457
+ const g = parseInt(result[1]);
458
+ const b = parseInt(result[2]);
459
+
460
+ return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
461
+ }
462
+
463
+ // Calculate WCAG contrast ratio between two colors
464
+ function calculateContrastRatio(color1, color2) {
465
+ try {
466
+ const getLuminance = (hex) => {
467
+ const rgb = hex.match(/\w\w/g).map(h => parseInt(h, 16) / 255);
468
+ const [r, g, b] = rgb.map(c => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
469
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
470
+ };
471
+
472
+ const l1 = getLuminance(color1.replace('#', ''));
473
+ const l2 = getLuminance(color2.replace('#', ''));
474
+ const lightest = Math.max(l1, l2);
475
+ const darkest = Math.min(l1, l2);
476
+
477
+ return Math.round(((lightest + 0.05) / (darkest + 0.05)) * 100) / 100;
478
+ } catch (error) {
479
+ console.warn('Could not calculate contrast ratio:', error);
480
+ return 1.0;
481
+ }
482
+ }
483
+ </script>
@@ -0,0 +1,35 @@
1
+ <script>
2
+ document.addEventListener('click', function (e) {
3
+ const btn = e.target.closest('.seg-pill');
4
+ if (!btn) return;
5
+
6
+ const container = btn.closest('.segmented-tabs');
7
+ const targetSel = btn.getAttribute('data-tab-target');
8
+ const panel = document.querySelector(targetSel);
9
+ if (!panel) return;
10
+
11
+ container.querySelectorAll('.seg-pill').forEach((p) =>
12
+ p.setAttribute('aria-selected', 'false')
13
+ );
14
+ btn.setAttribute('aria-selected', 'true');
15
+
16
+ document
17
+ .querySelectorAll('.seg-panels > [role="tabpanel"]')
18
+ .forEach((p) => (p.hidden = true));
19
+ panel.hidden = false;
20
+ });
21
+
22
+ window.addEventListener('DOMContentLoaded', () => {
23
+ const pairs = [
24
+ ['#seg-not-scanned', '#totalPagesNotScannedLabel'],
25
+ ['#seg-unsupported', '#totalUnsupportedDocsLabel']
26
+ ];
27
+ pairs.forEach(([btnSel, countSel]) => {
28
+ const btn = document.querySelector(btnSel);
29
+ const countEl = document.querySelector(countSel);
30
+ if (btn && countEl && countEl.textContent.trim() === '0') {
31
+ btn.style.display = 'none';
32
+ }
33
+ });
34
+ });
35
+ </script>
@@ -1,71 +1,75 @@
1
1
  <%# functions used to show lightbox of screenshot when thumbnail is clicked on %>
2
2
  <script>
3
- const lightbox = document.getElementsByClassName('screenshot-lightbox')[0];
4
- const lightboxHeader = document.getElementsByClassName('lightbox-header')[0];
5
- const lightboxTitle = document.querySelector('.lightbox-header h5');
6
- const lightboxContent = document.getElementsByClassName('lightbox-content')[0];
7
- const lightboxImg = document.getElementById('lightbox-image');
3
+ const lightbox = document.getElementsByClassName('screenshot-lightbox')[0];
4
+ const lightboxHeader = document.getElementsByClassName('lightbox-header')[0];
5
+ const lightboxTitle = document.querySelector('.lightbox-header h5');
6
+ const lightboxContent = document.getElementsByClassName('lightbox-content')[0];
7
+ const lightboxImg = document.getElementById('lightbox-image');
8
8
 
9
- var customFlowScreenshots = document.getElementsByClassName('custom-flow-screenshot');
10
- Array.from(customFlowScreenshots).forEach(screenshot => {
11
- screenshot.onerror = function(event) {
12
- screenshot.onerror = null;
13
- screenshot.remove();
14
- }
15
- screenshot.onclick = event => {
16
- event.preventDefault();
17
- const pageTitle = screenshot.parentNode.getElementsByTagName('a')[0].textContent;
18
- const pageUrl = screenshot.parentNode.getElementsByTagName('a')[0].href;
19
- openLightbox(screenshot.src, pageTitle, pageUrl);
20
- }
21
- })
9
+ var customFlowScreenshots = document.getElementsByClassName('custom-flow-screenshot');
10
+ Array.from(customFlowScreenshots).forEach(screenshot => {
11
+ screenshot.onerror = function (event) {
12
+ screenshot.onerror = null;
13
+ screenshot.remove();
14
+ };
15
+ screenshot.onclick = event => {
16
+ event.preventDefault();
17
+ const pageTitle = screenshot.parentNode.getElementsByTagName('a')[0].textContent;
18
+ const pageUrl = screenshot.parentNode.getElementsByTagName('a')[0].href;
19
+ openLightbox(screenshot.src, pageTitle, pageUrl);
20
+ };
21
+ });
22
22
 
23
- lightbox.addEventListener('click', event => {
24
- if (event.target === lightbox || event.target === lightboxHeader || event.target === lightboxTitle) {
25
- closeLightbox();
26
- }
27
- })
23
+ lightbox.addEventListener('click', event => {
24
+ if (
25
+ event.target === lightbox ||
26
+ event.target === lightboxHeader ||
27
+ event.target === lightboxTitle
28
+ ) {
29
+ closeLightbox();
30
+ }
31
+ });
28
32
 
29
- const offcanvasElem = document.getElementsByClassName('offcanvas')[0];
30
- const offcanvasItem = new bootstrap.Offcanvas(offcanvasElem);
31
- offcanvasItem._config.keyboard = false; // Disable default keyboard handling
33
+ const offcanvasElem = document.getElementsByClassName('offcanvas')[0];
34
+ const offcanvasItem = new bootstrap.Offcanvas(offcanvasElem);
35
+ offcanvasItem._config.keyboard = false; // Disable default keyboard handling
32
36
 
33
- const pagesScannedModalElem = document.getElementById('pagesScannedModal');
34
- const pagesScannedModalItem = new bootstrap.Modal(pagesScannedModalElem);
35
- pagesScannedModalItem._config.keyboard = false;
37
+ const pagesScannedModalElem = document.getElementById('pagesScannedModal');
38
+ const pagesScannedModalItem = new bootstrap.Modal(pagesScannedModalElem);
39
+ pagesScannedModalItem._config.keyboard = false;
36
40
 
37
- document.addEventListener('keydown', event => {
38
- if (event.key === 'Escape') {
39
- if (offcanvasItem._isShown) {
40
- if (lightbox.style.display === 'block') {
41
- event.preventDefault(); // Prevent default bootstrap behaviour
42
- closeLightbox();
43
- } else {
44
- offcanvasItem.hide();
45
- }
41
+ document.addEventListener('keydown', event => {
42
+ if (event.key === 'Escape') {
43
+ if (offcanvasItem._isShown) {
44
+ if (lightbox.style.display === 'block') {
45
+ event.preventDefault(); // Prevent default bootstrap behaviour
46
+ closeLightbox();
47
+ } else {
48
+ offcanvasItem.hide();
46
49
  }
50
+ }
47
51
 
48
- if (pagesScannedModalItem._isShown) {
49
- if (lightbox.style.display === 'block') {
50
- event.preventDefault(); // Prevent default bootstrap behaviour
51
- closeLightbox();
52
- } else {
53
- pagesScannedModalItem.hide();
54
- }
52
+ if (pagesScannedModalItem._isShown) {
53
+ if (lightbox.style.display === 'block') {
54
+ event.preventDefault(); // Prevent default bootstrap behaviour
55
+ closeLightbox();
56
+ } else {
57
+ pagesScannedModalItem.hide();
55
58
  }
56
59
  }
57
- })
60
+ }
61
+ });
58
62
 
59
- const openLightbox = (imgSrc, pageTitle, pageUrl) => {
60
- lightbox.style.display = 'block';
61
-
62
- lightboxImg.src = imgSrc;
63
- lightboxImg.alt = `Screenshot of ${pageUrl}`;
63
+ function openLightbox(imgSrc, pageTitle, pageUrl) {
64
+ lightbox.style.display = 'block';
64
65
 
65
- lightboxTitle.textContent = pageTitle;
66
- }
66
+ lightboxImg.src = imgSrc;
67
+ lightboxImg.alt = `Screenshot of ${pageUrl}`;
67
68
 
68
- const closeLightbox = () => {
69
- lightbox.style.display = 'none';
70
- }
71
- </script>
69
+ lightboxTitle.textContent = pageTitle;
70
+ };
71
+
72
+ function closeLightbox() {
73
+ lightbox.style.display = 'none';
74
+ };
75
+ </script>