@diegovelasquezweb/a11y-engine 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +20 -0
- package/assets/discovery/crawler-config.json +11 -0
- package/assets/discovery/stack-detection.json +33 -0
- package/assets/remediation/axe-check-maps.json +31 -0
- package/assets/remediation/code-patterns.json +109 -0
- package/assets/remediation/guardrails.json +24 -0
- package/assets/remediation/intelligence.json +4166 -0
- package/assets/remediation/source-boundaries.json +46 -0
- package/assets/reporting/compliance-config.json +173 -0
- package/assets/reporting/manual-checks.json +944 -0
- package/assets/reporting/wcag-reference.json +588 -0
- package/package.json +37 -0
- package/scripts/audit.mjs +326 -0
- package/scripts/core/asset-loader.mjs +54 -0
- package/scripts/core/toolchain.mjs +102 -0
- package/scripts/core/utils.mjs +105 -0
- package/scripts/engine/analyzer.mjs +1022 -0
- package/scripts/engine/dom-scanner.mjs +685 -0
- package/scripts/engine/source-scanner.mjs +300 -0
- package/scripts/reports/builders/checklist.mjs +307 -0
- package/scripts/reports/builders/html.mjs +766 -0
- package/scripts/reports/builders/md.mjs +96 -0
- package/scripts/reports/builders/pdf.mjs +259 -0
- package/scripts/reports/renderers/findings.mjs +188 -0
- package/scripts/reports/renderers/html.mjs +452 -0
- package/scripts/reports/renderers/md.mjs +595 -0
- package/scripts/reports/renderers/pdf.mjs +551 -0
- package/scripts/reports/renderers/utils.mjs +42 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file format-html.mjs
|
|
3
|
+
* @description HTML report component builders and formatting logic.
|
|
4
|
+
* Responsible for generating individual UI components like issue cards, manual check sections,
|
|
5
|
+
* and technical evidence blocks for the final HTML report.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ASSET_PATHS, loadAssetJson } from "../../core/asset-loader.mjs";
|
|
9
|
+
import { SEVERITY_ORDER } from "./findings.mjs";
|
|
10
|
+
import { escapeHtml, formatMultiline, linkify } from "./utils.mjs";
|
|
11
|
+
|
|
12
|
+
const MANUAL_CHECKS = loadAssetJson(
|
|
13
|
+
ASSET_PATHS.reporting.manualChecks,
|
|
14
|
+
"assets/reporting/manual-checks.json",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders technical evidence (HTML snippets and failure summaries) for the dashboard.
|
|
19
|
+
* @param {Object[]} evidence - List of evidence objects containing HTML and failure summaries.
|
|
20
|
+
* @returns {string} The formatted HTML string for the evidence block.
|
|
21
|
+
*/
|
|
22
|
+
function formatEvidence(evidence) {
|
|
23
|
+
const data = Array.isArray(evidence) ? evidence : [];
|
|
24
|
+
if (data.length === 0) return "";
|
|
25
|
+
|
|
26
|
+
return data
|
|
27
|
+
.map((item) => {
|
|
28
|
+
const failureSummary = item.failureSummary
|
|
29
|
+
? `<div class="mt-2 p-3 bg-rose-50 border-l-4 border-rose-500 text-rose-700 text-xs font-mono whitespace-pre-wrap">${formatMultiline(item.failureSummary)}</div>`
|
|
30
|
+
: "";
|
|
31
|
+
const htmlSnippet = item.html
|
|
32
|
+
? `<div class="mb-2"><span class="text-xs font-semibold text-slate-600 uppercase tracking-wider block mb-1">Source</span><pre tabindex="0" class="bg-slate-900 text-slate-50 p-3 rounded-lg overflow-x-auto text-xs font-mono border border-slate-700"><code>${escapeHtml(item.html)}</code></pre></div>`
|
|
33
|
+
: "";
|
|
34
|
+
return `
|
|
35
|
+
<div class="mb-4 last:mb-0">
|
|
36
|
+
${htmlSnippet}
|
|
37
|
+
${failureSummary}
|
|
38
|
+
</div>`;
|
|
39
|
+
})
|
|
40
|
+
.join("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds an interactive HTML card for an automated accessibility violation finding.
|
|
45
|
+
* @param {Object} finding - The normalized finding object to render.
|
|
46
|
+
* @returns {string} The complete HTML string for the issue card.
|
|
47
|
+
*/
|
|
48
|
+
export function buildIssueCard(finding) {
|
|
49
|
+
const cardId = escapeHtml(finding.id);
|
|
50
|
+
let severityBadge = "";
|
|
51
|
+
let borderClass = "";
|
|
52
|
+
|
|
53
|
+
switch (finding.severity) {
|
|
54
|
+
case "Critical":
|
|
55
|
+
severityBadge = "bg-rose-100 text-rose-800 border-rose-200";
|
|
56
|
+
borderClass = "border-rose-200 hover:border-rose-300";
|
|
57
|
+
break;
|
|
58
|
+
case "Serious":
|
|
59
|
+
severityBadge = "bg-orange-100 text-orange-800 border-orange-200";
|
|
60
|
+
borderClass = "border-orange-200 hover:border-orange-300";
|
|
61
|
+
break;
|
|
62
|
+
case "Moderate":
|
|
63
|
+
severityBadge = "bg-amber-100 text-amber-800 border-amber-200";
|
|
64
|
+
borderClass = "border-amber-200 hover:border-amber-300";
|
|
65
|
+
break;
|
|
66
|
+
case "Minor":
|
|
67
|
+
severityBadge = "bg-emerald-100 text-emerald-700 border-emerald-200";
|
|
68
|
+
borderClass = "border-emerald-200 hover:border-emerald-300";
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
severityBadge = "bg-slate-100 text-slate-800 border-slate-200";
|
|
72
|
+
borderClass = "border-slate-200";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let effortBadge = "";
|
|
76
|
+
const effortLevel = finding.effort ?? (finding.fixCode ? "low" : "high");
|
|
77
|
+
if (effortLevel === "low") {
|
|
78
|
+
effortBadge = `<span class="px-2.5 py-1 rounded-full text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200/60 uppercase tracking-widest shadow-sm">Low Effort</span>`;
|
|
79
|
+
} else if (effortLevel === "medium") {
|
|
80
|
+
effortBadge = `<span class="px-2.5 py-1 rounded-full text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-200/60 uppercase tracking-widest shadow-sm">Med Effort</span>`;
|
|
81
|
+
} else {
|
|
82
|
+
effortBadge = `<span class="px-2.5 py-1 rounded-full text-[10px] font-bold bg-rose-50 text-rose-700 border border-rose-200/60 uppercase tracking-widest shadow-sm">High Effort</span>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stackNotes = [
|
|
86
|
+
...(finding.frameworkNotes && typeof finding.frameworkNotes === "object"
|
|
87
|
+
? Object.entries(finding.frameworkNotes).map(([fw, note]) => ({ key: fw, note, style: "bg-slate-100 text-slate-600 border-slate-200" }))
|
|
88
|
+
: []),
|
|
89
|
+
...(finding.cmsNotes && typeof finding.cmsNotes === "object"
|
|
90
|
+
? Object.entries(finding.cmsNotes).map(([cms, note]) => ({ key: cms, note, style: "bg-violet-50 text-violet-700 border-violet-200" }))
|
|
91
|
+
: []),
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const stackNotesHtml = stackNotes.length > 0
|
|
95
|
+
? `<div class="mt-4 pt-3 border-t border-indigo-100/50">
|
|
96
|
+
<h4 class="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
|
97
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
|
|
98
|
+
Stack Notes
|
|
99
|
+
</h4>
|
|
100
|
+
<div class="space-y-2">
|
|
101
|
+
${stackNotes.map(({ key, note, style }) => `
|
|
102
|
+
<div class="flex gap-2 items-start">
|
|
103
|
+
<span class="flex-shrink-0 px-2 py-0.5 rounded text-[10px] font-bold border ${style} uppercase tracking-wider mt-0.5">${escapeHtml(key)}</span>
|
|
104
|
+
<p class="text-[12px] text-slate-600 leading-relaxed">${escapeHtml(note)}</p>
|
|
105
|
+
</div>`).join("")}
|
|
106
|
+
</div>
|
|
107
|
+
</div>`
|
|
108
|
+
: "";
|
|
109
|
+
|
|
110
|
+
const implNotesHtml = finding.fixDifficultyNotes
|
|
111
|
+
? `<div class="mt-4 pt-3 border-t border-indigo-100/50">
|
|
112
|
+
<h4 class="text-[10px] font-black text-amber-700 uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
|
113
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
|
114
|
+
Implementation Notes
|
|
115
|
+
</h4>
|
|
116
|
+
<p class="text-[12px] text-amber-900/80 leading-relaxed bg-amber-50/60 border border-amber-100/60 rounded-lg p-3">${escapeHtml(finding.fixDifficultyNotes)}</p>
|
|
117
|
+
</div>`
|
|
118
|
+
: "";
|
|
119
|
+
|
|
120
|
+
const problemPanelHtml = `
|
|
121
|
+
<div class="grid grid-cols-1 gap-6">
|
|
122
|
+
<div class="bg-white rounded-xl border border-slate-200/60 shadow-sm p-5 space-y-5">
|
|
123
|
+
<h4 class="text-[11px] font-black text-slate-500 uppercase tracking-widest flex items-center gap-2">
|
|
124
|
+
<div class="p-1 bg-slate-100 rounded-md">
|
|
125
|
+
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
|
126
|
+
</div>
|
|
127
|
+
The Problem
|
|
128
|
+
</h4>
|
|
129
|
+
<div>
|
|
130
|
+
<span class="text-[10px] font-bold text-rose-600 uppercase tracking-wider block mb-1">Actual Behavior</span>
|
|
131
|
+
<p class="text-[13px] text-slate-700 leading-relaxed border-l-2 border-rose-300 pl-3">${formatMultiline(finding.actual)}</p>
|
|
132
|
+
</div>
|
|
133
|
+
${finding.expected ? `
|
|
134
|
+
<div>
|
|
135
|
+
<span class="text-[10px] font-bold text-emerald-600 uppercase tracking-wider block mb-1">Expected Behavior</span>
|
|
136
|
+
<p class="text-[13px] text-slate-700 leading-relaxed border-l-2 border-emerald-300 pl-3">${formatMultiline(finding.expected)}</p>
|
|
137
|
+
</div>` : ""}
|
|
138
|
+
<div>
|
|
139
|
+
<span class="text-[10px] font-bold text-slate-600 uppercase tracking-wider block mb-1">Impacted Users</span>
|
|
140
|
+
<p class="text-[13px] text-slate-600 leading-relaxed italic border-l-2 border-slate-200 pl-3">${formatMultiline(finding.impactedUsers)}</p>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>`;
|
|
144
|
+
|
|
145
|
+
const fixPanelHtml = `
|
|
146
|
+
<div class="bg-gradient-to-br from-indigo-50 to-white border border-indigo-100/80 rounded-xl p-5 relative overflow-hidden shadow-sm">
|
|
147
|
+
<div class="absolute top-0 right-0 p-4 opacity-[0.03] transform translate-x-4 -translate-y-4 pointer-events-none">
|
|
148
|
+
<svg class="w-32 h-32 text-indigo-900" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clip-rule="evenodd"></path></svg>
|
|
149
|
+
</div>
|
|
150
|
+
<h4 class="text-[11px] font-black text-indigo-700 uppercase tracking-widest mb-4 relative z-10 flex items-center gap-2">
|
|
151
|
+
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
|
152
|
+
The Fix
|
|
153
|
+
</h4>
|
|
154
|
+
<div class="relative z-10 space-y-4">
|
|
155
|
+
${finding.fixDescription ? `<p class="text-sm text-indigo-800 leading-relaxed">${escapeHtml(finding.fixDescription)}</p>` : ""}
|
|
156
|
+
${finding.fixCode ? `
|
|
157
|
+
<div class="relative group/code">
|
|
158
|
+
<pre tabindex="0" class="bg-slate-900 text-emerald-300 p-3 rounded-lg overflow-x-auto text-xs font-mono border border-slate-700 whitespace-pre-wrap">${escapeHtml(finding.fixCode)}</pre>
|
|
159
|
+
<button aria-label="Copy code snippet" title="Copy code snippet" onclick="copyToClipboard(\`${escapeHtml(finding.fixCode).replace(/`/g, "\\`")}\`, this)" class="absolute top-2 right-2 p-1.5 rounded-lg bg-indigo-500/50 text-white opacity-0 group-hover/code:opacity-100 transition-all hover:bg-indigo-500">
|
|
160
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
|
161
|
+
</button>
|
|
162
|
+
</div>` : ""}
|
|
163
|
+
${finding.mdn ? `
|
|
164
|
+
<div class="pt-1">
|
|
165
|
+
<a href="${escapeHtml(finding.mdn)}" target="_blank" class="text-[11px] font-bold text-slate-500 hover:text-indigo-600 transition-colors flex items-center gap-1.5 uppercase tracking-wider">
|
|
166
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
|
167
|
+
MDN Docs
|
|
168
|
+
</a>
|
|
169
|
+
</div>` : ""}
|
|
170
|
+
${implNotesHtml}
|
|
171
|
+
${stackNotesHtml}
|
|
172
|
+
</div>
|
|
173
|
+
</div>`;
|
|
174
|
+
|
|
175
|
+
const technicalEvidencePanelHtml = finding.evidence && finding.evidence.length > 0
|
|
176
|
+
? `<div class="bg-slate-900 rounded-xl p-6 border border-slate-700 shadow-2xl relative overflow-hidden">
|
|
177
|
+
<div class="absolute top-0 right-0 w-32 h-32 bg-indigo-500/10 rounded-full blur-3xl -mr-10 -mt-10 pointer-events-none"></div>
|
|
178
|
+
<h4 class="text-[11px] font-black text-slate-600 uppercase tracking-widest mb-4 relative z-10 flex items-center gap-2">
|
|
179
|
+
<div class="p-1 bg-slate-800 rounded-md border border-slate-700">
|
|
180
|
+
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
|
|
181
|
+
</div>
|
|
182
|
+
Technical Evidence
|
|
183
|
+
</h4>
|
|
184
|
+
<div class="relative z-10">
|
|
185
|
+
${formatEvidence(finding.evidence)}
|
|
186
|
+
</div>
|
|
187
|
+
</div>`
|
|
188
|
+
: "";
|
|
189
|
+
|
|
190
|
+
const visualEvidencePanelHtml = finding.screenshotPath
|
|
191
|
+
? `<div class="border border-slate-200 rounded-xl p-4 bg-white shadow-sm">
|
|
192
|
+
<h4 class="text-[11px] font-black text-slate-600 uppercase tracking-widest mb-4 flex items-center gap-2">
|
|
193
|
+
<div class="p-1 bg-slate-100 rounded-md">
|
|
194
|
+
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
|
195
|
+
</div>
|
|
196
|
+
Visual Evidence
|
|
197
|
+
</h4>
|
|
198
|
+
<div class="bg-slate-50/50 p-2 rounded-xl border border-slate-200/60 inline-block shadow-sm">
|
|
199
|
+
<img src="${escapeHtml(finding.screenshotPath)}" alt="Screenshot of ${escapeHtml(finding.title)}" class="rounded-lg border border-slate-200 shadow-sm max-h-[360px] w-auto object-contain bg-white" loading="lazy">
|
|
200
|
+
</div>
|
|
201
|
+
</div>`
|
|
202
|
+
: "";
|
|
203
|
+
|
|
204
|
+
const normalizeBadgeText = (value, { keepAcronyms = false } = {}) =>
|
|
205
|
+
String(value || "")
|
|
206
|
+
.split(/[-_]/g)
|
|
207
|
+
.map((part) => {
|
|
208
|
+
if (!part) return "";
|
|
209
|
+
if (keepAcronyms && part.toLowerCase() === "aria") return "ARIA";
|
|
210
|
+
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
|
|
211
|
+
})
|
|
212
|
+
.join(" ");
|
|
213
|
+
|
|
214
|
+
const ruleLabel = normalizeBadgeText(finding.ruleId);
|
|
215
|
+
const categoryLabel = finding.category
|
|
216
|
+
? normalizeBadgeText(finding.category, { keepAcronyms: true })
|
|
217
|
+
: "";
|
|
218
|
+
|
|
219
|
+
const tabs = [
|
|
220
|
+
{ key: "problem", label: "The Problem", content: problemPanelHtml },
|
|
221
|
+
{ key: "fix", label: "The Fix", content: fixPanelHtml },
|
|
222
|
+
];
|
|
223
|
+
if (technicalEvidencePanelHtml) {
|
|
224
|
+
tabs.push({ key: "technical", label: "Technical Evidence", content: technicalEvidencePanelHtml });
|
|
225
|
+
}
|
|
226
|
+
if (visualEvidencePanelHtml) {
|
|
227
|
+
tabs.push({ key: "visual", label: "Visual Evidence", content: visualEvidencePanelHtml });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const tabsMarkup = tabs.map((tab, index) => {
|
|
231
|
+
const active = index === 0;
|
|
232
|
+
return `<button
|
|
233
|
+
id="tab-${cardId}-${tab.key}"
|
|
234
|
+
role="tab"
|
|
235
|
+
type="button"
|
|
236
|
+
aria-selected="${active ? "true" : "false"}"
|
|
237
|
+
aria-controls="panel-${cardId}-${tab.key}"
|
|
238
|
+
tabindex="${active ? "0" : "-1"}"
|
|
239
|
+
onclick="switchIssueTab(this, '${tab.key}')"
|
|
240
|
+
onkeydown="handleIssueTabKeydown(event, this)"
|
|
241
|
+
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${active ? "bg-white text-indigo-700 border border-indigo-200 shadow-sm" : "text-slate-600 border border-transparent hover:bg-white/70"}"
|
|
242
|
+
>${tab.label}<span class="sr-only"> for ${cardId}</span></button>`;
|
|
243
|
+
}).join("");
|
|
244
|
+
|
|
245
|
+
const panelsMarkup = tabs.map((tab, index) => {
|
|
246
|
+
const active = index === 0;
|
|
247
|
+
return `<section
|
|
248
|
+
id="panel-${cardId}-${tab.key}"
|
|
249
|
+
role="tabpanel"
|
|
250
|
+
aria-labelledby="tab-${cardId}-${tab.key}"
|
|
251
|
+
data-tab-panel="${tab.key}"
|
|
252
|
+
class="${active ? "" : "hidden"}"
|
|
253
|
+
tabindex="0"
|
|
254
|
+
>
|
|
255
|
+
${tab.content}
|
|
256
|
+
</section>`;
|
|
257
|
+
}).join("");
|
|
258
|
+
|
|
259
|
+
return `
|
|
260
|
+
<article class="issue-card bg-white/90 backdrop-blur-xl rounded-2xl border ${borderClass} shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5 mb-8 overflow-hidden group" data-severity="${finding.severity}" data-rule-id="${escapeHtml(finding.ruleId)}" data-wcag="${escapeHtml(finding.wcag)}" data-collapsed="true" id="${escapeHtml(finding.id)}">
|
|
261
|
+
<button
|
|
262
|
+
class="card-header w-full text-left p-5 md:p-6 bg-gradient-to-r from-white to-slate-50/80 cursor-pointer select-none relative focus:outline-none focus:ring-4 focus:ring-indigo-500/20"
|
|
263
|
+
onclick="toggleCard(this)"
|
|
264
|
+
aria-expanded="false"
|
|
265
|
+
aria-controls="body-${escapeHtml(finding.id)}"
|
|
266
|
+
>
|
|
267
|
+
<div class="absolute inset-y-0 left-0 w-1.5 ${severityBadge.split(" ")[0]} opacity-80"></div>
|
|
268
|
+
<div class="flex items-start gap-4 pl-2">
|
|
269
|
+
<div class="flex-1 min-w-0">
|
|
270
|
+
<div class="flex flex-wrap items-center gap-2.5 mb-3.5">
|
|
271
|
+
<span class="px-3 py-1 rounded-full text-[11px] font-bold border ${severityBadge} shadow-sm backdrop-blur-sm uppercase tracking-wider">${escapeHtml(finding.severity)}</span>
|
|
272
|
+
${effortBadge}
|
|
273
|
+
<span class="wcag-label px-3 py-1 rounded-full text-[11px] font-bold bg-indigo-50/80 text-indigo-700 border border-indigo-100/80 shadow-sm backdrop-blur-sm">WCAG ${escapeHtml(finding.wcag)}</span>
|
|
274
|
+
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-slate-100 text-slate-700 border border-slate-200">${escapeHtml(ruleLabel)}</span>
|
|
275
|
+
${categoryLabel ? `<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-violet-50 text-violet-700 border border-violet-200">${escapeHtml(categoryLabel)}</span>` : ""}
|
|
276
|
+
</div>
|
|
277
|
+
<h3 class="text-lg md:text-xl font-extrabold text-slate-900 leading-tight mb-3 group-hover:text-indigo-900 transition-colors searchable-field issue-title">${escapeHtml(finding.title)}</h3>
|
|
278
|
+
<div class="flex flex-wrap gap-x-4 gap-y-2 text-[13px] text-slate-600 font-medium">
|
|
279
|
+
<div class="flex items-center gap-1.5 bg-slate-50/50 px-2 py-1 rounded-md border border-slate-100">
|
|
280
|
+
<svg class="w-3.5 h-3.5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>
|
|
281
|
+
<span class="truncate max-w-[200px] md:max-w-md transition-colors searchable-field issue-url">${escapeHtml(finding.url)}</span>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="flex items-center gap-1.5 min-w-0 bg-slate-50/50 px-2 py-1 rounded-md border border-slate-100">
|
|
284
|
+
<svg class="w-3.5 h-3.5 text-slate-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
|
|
285
|
+
<code class="text-[12px] text-slate-800 font-mono truncate min-w-0 flex-1 searchable-field issue-selector">${escapeHtml(finding.selector)}</code>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="bg-white p-1.5 rounded-full border border-slate-200 shadow-sm group-hover:bg-slate-50 transition-colors mt-1 flex-shrink-0">
|
|
290
|
+
<svg class="card-chevron w-5 h-5 text-slate-500 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"></path></svg>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</button>
|
|
294
|
+
|
|
295
|
+
<div class="card-body grid transition-all duration-300 ease-in-out" style="grid-template-rows: 0fr;" id="body-${escapeHtml(finding.id)}">
|
|
296
|
+
<div class="overflow-hidden">
|
|
297
|
+
<div class="p-6 md:p-8 bg-slate-50/30 border-t border-slate-100/60">
|
|
298
|
+
<div class="rounded-xl border border-slate-200 bg-slate-100/70 p-2 mb-4" data-issue-tab-root>
|
|
299
|
+
<div role="tablist" aria-label="Issue detail sections for ${escapeHtml(finding.id)}" class="flex flex-wrap gap-2">
|
|
300
|
+
${tabsMarkup}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<div class="space-y-4" id="issue-tabs-${cardId}">
|
|
305
|
+
${panelsMarkup}
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
</article>`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Builds an interactive accordion card for a manual accessibility check.
|
|
315
|
+
* @param {Object} check - The manual check definition object.
|
|
316
|
+
* @returns {string} The complete HTML string for the manual check card.
|
|
317
|
+
*/
|
|
318
|
+
export function buildManualCheckCard(check) {
|
|
319
|
+
const id = `manual-${check.criterion.replace(/\./g, "-")}`;
|
|
320
|
+
const steps = check.steps
|
|
321
|
+
.map((s, i) => `<li class="text-[13px] text-slate-600 leading-relaxed"><span class="font-bold text-slate-400 mr-1.5">${i + 1}.</span>${escapeHtml(s)}</li>`)
|
|
322
|
+
.join("");
|
|
323
|
+
|
|
324
|
+
const criterionPill = check.level !== "AT"
|
|
325
|
+
? `<span class="px-2.5 py-1 rounded-full text-[10px] font-mono font-semibold bg-slate-100 text-slate-700 border border-slate-200">${escapeHtml(check.criterion)}</span>`
|
|
326
|
+
: "";
|
|
327
|
+
|
|
328
|
+
const levelPill = check.level === "AT"
|
|
329
|
+
? `<span class="px-2.5 py-1 rounded-full text-[11px] font-bold bg-violet-50 text-violet-700 border border-violet-200">Assistive Technology</span>`
|
|
330
|
+
: `<span class="px-2.5 py-1 rounded-full text-[11px] font-bold bg-indigo-50 text-indigo-700 border border-indigo-100">WCAG ${escapeHtml(check.level)}</span>`;
|
|
331
|
+
|
|
332
|
+
const conditionalNote = check.conditional
|
|
333
|
+
? `<div class="mb-5 flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-xl px-4 py-3">
|
|
334
|
+
<svg class="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
|
335
|
+
<p class="text-[12px] text-amber-800 font-medium leading-relaxed">${escapeHtml(check.conditional)}</p>
|
|
336
|
+
</div>`
|
|
337
|
+
: "";
|
|
338
|
+
|
|
339
|
+
const codeExampleHtml = check.code_example
|
|
340
|
+
? `<div class="border-t border-slate-100 mt-2 pt-6">
|
|
341
|
+
<h4 class="text-[11px] font-black text-slate-600 uppercase tracking-widest mb-4">Before / After</h4>
|
|
342
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
343
|
+
<div>
|
|
344
|
+
<span class="text-[10px] font-bold text-rose-600 uppercase tracking-widest block mb-1.5">Before</span>
|
|
345
|
+
<pre tabindex="0" class="bg-slate-900 text-slate-50 p-3 rounded-lg overflow-x-auto text-xs font-mono border border-slate-700"><code>${escapeHtml(check.code_example.before)}</code></pre>
|
|
346
|
+
</div>
|
|
347
|
+
<div>
|
|
348
|
+
<span class="text-[10px] font-bold text-emerald-600 uppercase tracking-widest block mb-1.5">After</span>
|
|
349
|
+
<pre tabindex="0" class="bg-slate-900 text-slate-50 p-3 rounded-lg overflow-x-auto text-xs font-mono border border-slate-700"><code>${escapeHtml(check.code_example.after)}</code></pre>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
</div>`
|
|
353
|
+
: "";
|
|
354
|
+
|
|
355
|
+
return `
|
|
356
|
+
<article class="manual-card bg-white/90 backdrop-blur-xl rounded-2xl border border-amber-200 hover:border-amber-300 shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5 mb-4 overflow-hidden group" id="${id}" data-criterion="${escapeHtml(check.criterion)}" data-level="${escapeHtml(check.level)}" data-state="" data-collapsed="true">
|
|
357
|
+
<div class="manual-header flex items-stretch bg-gradient-to-r from-amber-50/60 to-white">
|
|
358
|
+
<button
|
|
359
|
+
class="card-header flex-1 text-left p-5 md:p-6 cursor-pointer select-none relative focus:outline-none focus:ring-4 focus:ring-amber-500/20"
|
|
360
|
+
onclick="toggleCard(this)"
|
|
361
|
+
aria-expanded="false"
|
|
362
|
+
aria-controls="${id}-body"
|
|
363
|
+
>
|
|
364
|
+
<div class="flex items-center gap-4">
|
|
365
|
+
<div class="flex-1 min-w-0">
|
|
366
|
+
<div class="flex flex-wrap items-center gap-2 mb-2.5">
|
|
367
|
+
<span class="manual-badge px-2.5 py-1 rounded-full text-[11px] font-bold bg-amber-100 text-amber-800 border border-amber-200 uppercase tracking-wider">Manual</span>
|
|
368
|
+
${criterionPill}
|
|
369
|
+
${levelPill}
|
|
370
|
+
</div>
|
|
371
|
+
<h3 class="text-base font-extrabold text-slate-900 group-hover:text-amber-900 transition-colors">${escapeHtml(check.title)}</h3>
|
|
372
|
+
</div>
|
|
373
|
+
<div class="bg-white p-1.5 rounded-full border border-amber-200 shadow-sm group-hover:bg-amber-50 transition-colors flex-shrink-0">
|
|
374
|
+
<svg class="card-chevron w-5 h-5 text-amber-500 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"></path></svg>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
</button>
|
|
378
|
+
<div class="flex items-center gap-2 px-4 border-l border-amber-100 flex-shrink-0">
|
|
379
|
+
<button class="manual-verified-btn px-3 py-1.5 rounded-full text-xs font-bold border border-slate-200 bg-white text-slate-500 hover:border-emerald-300 hover:text-emerald-700 hover:bg-emerald-50 transition-colors whitespace-nowrap" onclick="setManualState('${escapeHtml(check.criterion)}', 'verified')">✓ Verified</button>
|
|
380
|
+
<button class="manual-na-btn px-3 py-1.5 rounded-full text-xs font-bold border border-slate-200 bg-white text-slate-500 hover:border-slate-400 hover:text-slate-600 hover:bg-slate-50 transition-colors whitespace-nowrap" onclick="setManualState('${escapeHtml(check.criterion)}', 'na')">N/A</button>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<div class="card-body grid transition-all duration-300 ease-in-out" style="grid-template-rows: 0fr;" id="${id}-body">
|
|
385
|
+
<div class="overflow-hidden">
|
|
386
|
+
<div class="p-6 md:p-8 bg-slate-50/30 border-t border-amber-100/60">
|
|
387
|
+
|
|
388
|
+
${conditionalNote}
|
|
389
|
+
|
|
390
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
391
|
+
<div class="bg-white rounded-xl border border-slate-200/60 shadow-sm p-5">
|
|
392
|
+
<h4 class="text-[11px] font-black text-slate-600 uppercase tracking-widest mb-3">What to verify</h4>
|
|
393
|
+
<p class="text-[13px] text-slate-600 leading-relaxed">${escapeHtml(check.description)}</p>
|
|
394
|
+
</div>
|
|
395
|
+
<div class="bg-white rounded-xl border border-slate-200/60 shadow-sm p-5">
|
|
396
|
+
<h4 class="text-[11px] font-black text-slate-600 uppercase tracking-widest mb-3">How to test</h4>
|
|
397
|
+
<ol class="space-y-2 list-none">
|
|
398
|
+
${steps}
|
|
399
|
+
</ol>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
${codeExampleHtml}
|
|
404
|
+
|
|
405
|
+
<div class="mt-4 pt-4 border-t border-slate-100">
|
|
406
|
+
<a href="${escapeHtml(check.ref)}" target="_blank" class="text-[11px] font-bold text-indigo-600 hover:text-indigo-800 hover:underline transition-colors flex items-center gap-1.5 uppercase tracking-wider">
|
|
407
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
|
408
|
+
${check.level === "AT" ? `W3C Reference — ${escapeHtml(check.title)}` : `WCAG 2.2 — ${escapeHtml(check.criterion)} ${escapeHtml(check.title)}`}
|
|
409
|
+
</a>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
</article>`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Groups findings by page area (URL or path) and renders sorted issue cards for each group.
|
|
420
|
+
* @param {Object[]} findings - The normalized list of findings to group and render.
|
|
421
|
+
* @returns {string} The complete HTML string for the grouped sections.
|
|
422
|
+
*/
|
|
423
|
+
export function buildPageGroupedSection(findings) {
|
|
424
|
+
if (findings.length === 0) return "";
|
|
425
|
+
|
|
426
|
+
const pageGroups = {};
|
|
427
|
+
for (const f of findings) {
|
|
428
|
+
if (!pageGroups[f.area]) pageGroups[f.area] = [];
|
|
429
|
+
pageGroups[f.area].push(f);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const sorted = Object.entries(pageGroups).sort(
|
|
433
|
+
(a, b) => b[1].length - a[1].length,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
return sorted
|
|
437
|
+
.map(([page, pageFinding]) => {
|
|
438
|
+
const cards = pageFinding
|
|
439
|
+
.sort(
|
|
440
|
+
(a, b) =>
|
|
441
|
+
(SEVERITY_ORDER[a.severity] ?? 99) -
|
|
442
|
+
(SEVERITY_ORDER[b.severity] ?? 99),
|
|
443
|
+
)
|
|
444
|
+
.map((f) => buildIssueCard(f))
|
|
445
|
+
.join("\n");
|
|
446
|
+
|
|
447
|
+
return `<div class="page-group mb-10" data-page="${escapeHtml(page)}">
|
|
448
|
+
${cards}
|
|
449
|
+
</div>`;
|
|
450
|
+
})
|
|
451
|
+
.join("");
|
|
452
|
+
}
|