@surea11y/core 1.1.2 → 1.2.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -0
  3. package/bin/core.js +107 -3
  4. package/docs/API_STABILITY.md +61 -0
  5. package/docs/BASELINE.md +66 -0
  6. package/docs/CLI.md +26 -0
  7. package/docs/ENGINE_OPTIONS.md +2 -0
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/OUTPUT_SCHEMA.md +1 -1
  10. package/docs/REPORT.md +33 -0
  11. package/docs/RULE_AUTHORING.md +31 -0
  12. package/package.json +1 -1
  13. package/src/baseline.js +0 -0
  14. package/src/checks/automatic/aria-hidden-body.js +9 -1
  15. package/src/checks/automatic/aria-prohibited-children.js +71 -14
  16. package/src/checks/automatic/bypass-blocks-present.js +9 -1
  17. package/src/checks/automatic/css-orientation-lock.js +9 -1
  18. package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
  19. package/src/checks/automatic/language-page-present.js +9 -1
  20. package/src/checks/automatic/meta-refresh-no-exceptions.js +9 -1
  21. package/src/checks/automatic/meta-refresh-timing-absent.js +9 -1
  22. package/src/checks/automatic/meta-viewport-zoom-enabled.js +9 -1
  23. package/src/checks/automatic/page-title-present.js +9 -1
  24. package/src/checks/manual/empty-heading-manual.js +1 -6
  25. package/src/checks/manual/empty-table-header-manual.js +5 -9
  26. package/src/checks/manual/heading-order-manual.js +1 -6
  27. package/src/checks/manual/landmark-banner-is-top-level-manual.js +30 -14
  28. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +30 -14
  29. package/src/checks/manual/landmark-main-is-top-level-manual.js +30 -14
  30. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +25 -13
  31. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +25 -13
  32. package/src/checks/manual/landmark-one-main-manual.js +9 -1
  33. package/src/checks/manual/landmark-unique-manual.js +32 -30
  34. package/src/checks/manual/meta-viewport-large-manual.js +9 -1
  35. package/src/checks/manual/page-has-heading-one-manual.js +9 -1
  36. package/src/checks/manual/page-title-patterns-manual.js +9 -1
  37. package/src/checks/manual/region-manual.js +34 -14
  38. package/src/checks/manual/scope-attr-valid-manual.js +2 -7
  39. package/src/checks/manual/tabindex-manual.js +2 -7
  40. package/src/core/aria-helpers.js +82 -18
  41. package/src/core/dom-helpers.js +32 -0
  42. package/src/core/dom-runner.js +8 -0
  43. package/src/core/rule-meta.js +19 -0
  44. package/src/core.js +1781 -404
  45. package/src/i18n/en.js +2 -0
  46. package/src/i18n/fr.js +2 -0
  47. package/src/report.js +482 -0
package/src/i18n/en.js CHANGED
@@ -581,6 +581,8 @@ module.exports = {
581
581
  "ariaProhibitedChildren_hint_fail": "Remove or change this role so it matches one of the container's allowed owned roles ({{allowedRoles}}), or move this element outside the {{containerRole}} container.",
582
582
  "ariaProhibitedChildren_summary_fail_roleless": "This element has no explicit role but carries {{attr}}, making it a real accessible-tree node that is not an allowed owned child of the enclosing role=\"{{containerRole}}\" container.",
583
583
  "ariaProhibitedChildren_hint_fail_roleless": "Remove {{attr}} (or the role=\"{{containerRole}}\" container ownership), or give this element role=\"presentation\"/\"none\" if it isn't meant to be its own accessible-tree node.",
584
+ "ariaProhibitedChildren_summary_fail_native_focusable": "This element has no explicit role but is natively focusable, making it a real accessible-tree node that is not an allowed owned child of the enclosing role=\"{{containerRole}}\" container.",
585
+ "ariaProhibitedChildren_hint_fail_native_focusable": "Give this element role=\"presentation\"/\"none\", remove its native focusability (e.g. drop the href/tabindex-granting attribute), or move it outside the {{containerRole}} container.",
584
586
 
585
587
  "ariaRequiredParent_title": "Roles requiring a specific context role must be in that context",
586
588
  "ariaRequiredParent_description": "Checks that roles with a documented \"required context role\" entry (listitem, option, tab, treeitem, row, cell, ...) have an ancestor or aria-owns owner with an acceptable context role.",
package/src/i18n/fr.js CHANGED
@@ -568,6 +568,8 @@ module.exports = {
568
568
  "ariaProhibitedChildren_hint_fail": "Retirez ou modifiez ce rôle afin qu’il corresponde à l’un des rôles possédés autorisés du conteneur ({{allowedRoles}}), ou déplacez cet élément hors du conteneur {{containerRole}}.",
569
569
  "ariaProhibitedChildren_summary_fail_roleless": "Cet élément n’a pas de rôle explicite mais porte {{attr}}, ce qui en fait un véritable nœud de l’arbre d’accessibilité qui n’est pas un enfant possédé autorisé du conteneur englobant role=\"{{containerRole}}\".",
570
570
  "ariaProhibitedChildren_hint_fail_roleless": "Retirez {{attr}} (ou la relation de possession avec le conteneur role=\"{{containerRole}}\"), ou attribuez à cet élément role=\"presentation\"/\"none\" s’il n’est pas censé constituer son propre nœud dans l’arbre d’accessibilité.",
571
+ "ariaProhibitedChildren_summary_fail_native_focusable": "Cet élément n’a pas de rôle explicite mais est nativement focalisable, ce qui en fait un véritable nœud de l’arbre d’accessibilité qui n’est pas un enfant possédé autorisé du conteneur englobant role=\"{{containerRole}}\".",
572
+ "ariaProhibitedChildren_hint_fail_native_focusable": "Attribuez à cet élément role=\"presentation\"/\"none\", retirez sa focalisabilité native (par ex. supprimez l’attribut href/tabindex à l’origine), ou déplacez-le hors du conteneur {{containerRole}}.",
571
573
  "ariaRequiredParent_title": "Les rôles exigeant un rôle de contexte spécifique doivent se trouver dans ce contexte",
572
574
  "ariaRequiredParent_description": "Vérifie que les rôles disposant d’une entrée documentée « rôle de contexte requis » (listitem, option, tab, treeitem, row, cell, ...) ont un ancêtre, ou un propriétaire aria-owns, ayant un rôle de contexte acceptable.",
573
575
  "ariaRequiredParent_summary_fail": "role=\"{{role}}\" exige un rôle de contexte parmi : {{requiredRoles}}, qui n’a pas été trouvé.",
package/src/report.js ADDED
@@ -0,0 +1,482 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Renders one scan result into a single, self-contained HTML report (no
5
+ * external CSS/JS/fonts -- opens straight from disk, no server, no network).
6
+ *
7
+ * Structurally adapted from the sibling cross-engine-diff project's own HTML
8
+ * report tool (~/dev/surea11y/comparisons/cross-engine/render-html-report.js)
9
+ * -- that tool's shell (self-contained single file, hero bar + legend,
10
+ * grouped "worth reviewing" cards with an overflow cap, collapsible
11
+ * "full technical data" with a scorecard + searchable/filterable/paginated
12
+ * table, dark-mode CSS) is generic and reusable; everything here is written
13
+ * fresh for a single-engine scan result, not copied, since that tool's own
14
+ * organizing principle (a 7-way "does surea11y vs axe-core agree" taxonomy)
15
+ * has no single-engine analog.
16
+ */
17
+
18
+ // Same 4-status vocabulary/palette as the reference tool (the dataviz
19
+ // skill's validated status palette) -- maps 1:1 onto this engine's own
20
+ // 4 outcomes, so no new palette is needed.
21
+ const STATUS = {
22
+ good: { color: '#0ca30c', bg: '#e9f7e9', icon: '✓' },
23
+ serious: { color: '#c1502e', bg: '#fdece5', icon: '⚠' },
24
+ warning: { color: '#8a6400', bg: '#fdf3d9', icon: 'ℹ' },
25
+ neutral: { color: '#5f6368', bg: '#f1f2f3', icon: '–' }
26
+ };
27
+
28
+ // fail first: a QA tester scanning a report wants violations up front,
29
+ // matching axe-html-reporter/accessibility-insights-report's own convention
30
+ // of leading with violations, not passes.
31
+ const OUTCOME_ORDER = ['fail', 'cantTell', 'pass', 'notApplicable'];
32
+ const OUTCOME_INFO = {
33
+ fail: { label: 'Fail', status: 'serious', defaultOn: true },
34
+ cantTell: { label: 'Needs review', status: 'warning', defaultOn: true },
35
+ pass: { label: 'Pass', status: 'good', defaultOn: false },
36
+ notApplicable: { label: 'Not applicable', status: 'neutral', defaultOn: false }
37
+ };
38
+ for (const code of OUTCOME_ORDER) {
39
+ Object.assign(OUTCOME_INFO[code], STATUS[OUTCOME_INFO[code].status]);
40
+ }
41
+
42
+ function esc(s) {
43
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
44
+ }
45
+
46
+ // Neutralizes '<' so embedded JSON can never break out of its <script> tag,
47
+ // even if an occurrence's own html snippet literally contains "</script>".
48
+ function jsonForScript(value) {
49
+ return JSON.stringify(value).replace(/</g, '\\u003c');
50
+ }
51
+
52
+ function fmtPct(n, total) {
53
+ return total ? `${((n / total) * 100).toFixed(1)}%` : '0.0%';
54
+ }
55
+
56
+ function countByOutcome(checksResults) {
57
+ const counts = { pass: 0, fail: 0, cantTell: 0, notApplicable: 0 };
58
+ for (const r of checksResults) {
59
+ if (Object.prototype.hasOwnProperty.call(counts, r.outcome)) counts[r.outcome] += 1;
60
+ }
61
+ return counts;
62
+ }
63
+
64
+ // One plain-language headline + one horizontal stacked bar + a legend with
65
+ // icon+label+count (status color is never the only signal) -- the first
66
+ // thing a reader sees; exhaustive detail lives in the collapsed
67
+ // "full technical data" section further down.
68
+ function renderHeroBar(byOutcome) {
69
+ const total = OUTCOME_ORDER.reduce((sum, c) => sum + byOutcome[c], 0) || 1;
70
+ const applicable = byOutcome.pass + byOutcome.fail + byOutcome.cantTell;
71
+
72
+ const segments = OUTCOME_ORDER
73
+ .filter((c) => byOutcome[c] > 0)
74
+ .map((c) => {
75
+ const n = byOutcome[c];
76
+ const pct = (n / total) * 100;
77
+ const info = OUTCOME_INFO[c];
78
+ return `<div class="hero-bar-seg" style="width:${pct}%; background:${info.color}" title="${esc(info.label)}: ${n.toLocaleString()} (${pct.toFixed(1)}%)"></div>`;
79
+ })
80
+ .join('');
81
+
82
+ const legend = OUTCOME_ORDER
83
+ .map((c) => {
84
+ const n = byOutcome[c];
85
+ const info = OUTCOME_INFO[c];
86
+ return `<div class="hero-legend-item">
87
+ <span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
88
+ <span class="hero-legend-label">${esc(info.label)}</span>
89
+ <span class="hero-legend-count">${n.toLocaleString()} (${fmtPct(n, total)})</span>
90
+ </div>`;
91
+ })
92
+ .join('\n');
93
+
94
+ let headline;
95
+ if (byOutcome.fail > 0) {
96
+ headline = `<strong>${byOutcome.pass.toLocaleString()}</strong> of <strong>${applicable.toLocaleString()}</strong> applicable checks passed. <strong>${byOutcome.fail.toLocaleString()}</strong> failure${byOutcome.fail === 1 ? '' : 's'} need${byOutcome.fail === 1 ? 's' : ''} attention below.`;
97
+ } else if (byOutcome.cantTell > 0) {
98
+ headline = `<strong>${byOutcome.pass.toLocaleString()}</strong> of <strong>${applicable.toLocaleString()}</strong> applicable checks passed, with <strong>${byOutcome.cantTell.toLocaleString()}</strong> needing manual review.`;
99
+ } else if (applicable > 0) {
100
+ headline = `All <strong>${applicable.toLocaleString()}</strong> applicable checks passed.`;
101
+ } else {
102
+ headline = 'No applicable checks ran for this scan.';
103
+ }
104
+
105
+ return `<div class="hero">
106
+ <p class="hero-headline">${headline}</p>
107
+ <div class="hero-bar">${segments}</div>
108
+ <div class="hero-legend">${legend}</div>
109
+ </div>`;
110
+ }
111
+
112
+ function renderScorecard(byOutcome) {
113
+ const total = OUTCOME_ORDER.reduce((sum, c) => sum + byOutcome[c], 0);
114
+ const tiles = OUTCOME_ORDER.map((c) => {
115
+ const n = byOutcome[c];
116
+ const info = OUTCOME_INFO[c];
117
+ return `<div class="tile" style="border-color:${info.color}; background:${info.bg}">
118
+ <div class="tile-num" style="color:${info.color}">${n.toLocaleString()}</div>
119
+ <div class="tile-pct">${fmtPct(n, total)}</div>
120
+ <div class="tile-label">${esc(info.label)}</div>
121
+ </div>`;
122
+ }).join('\n');
123
+ return `<div class="scorecard">${tiles}</div>`;
124
+ }
125
+
126
+ // WCAG rollup -- straight from the engine's own rulesResults (one composite
127
+ // entry per Success Criterion, docs/WCAG_CONFORMANCE.md), not an invented
128
+ // grouping. Grouped by conformance level (A / AA / AAA) since that's the
129
+ // axis a compliance-minded reader actually cares about.
130
+ function renderWcagRollup(rulesResults) {
131
+ if (!Array.isArray(rulesResults) || !rulesResults.length) {
132
+ return '<p class="note">No WCAG composite rollups available for this scan (composite rules were excluded via runOnly/engineOptions).</p>';
133
+ }
134
+
135
+ const byLevel = { A: [], AA: [], AAA: [], other: [] };
136
+ for (const r of rulesResults) {
137
+ const mapping = (r.meta && Array.isArray(r.meta.normativeMappings) && r.meta.normativeMappings[0]) || null;
138
+ const level = (mapping && mapping.level) || 'other';
139
+ (byLevel[level] || byLevel.other).push({ rule: r, mapping });
140
+ }
141
+
142
+ const sections = ['A', 'AA', 'AAA', 'other']
143
+ .filter((level) => byLevel[level].length)
144
+ .map((level) => {
145
+ const rows = byLevel[level]
146
+ .sort((a, b) => (a.mapping ? a.mapping.requirement : '').localeCompare(b.mapping ? b.mapping.requirement : '', undefined, { numeric: true }))
147
+ .map(({ rule, mapping }) => {
148
+ const info = OUTCOME_INFO[rule.outcome] || OUTCOME_INFO.notApplicable;
149
+ const metrics = (rule.data && rule.data.details && rule.data.details.metrics) || {};
150
+ const checksIds = (rule.data && rule.data.details && rule.data.details.checksIds) || [];
151
+ const chip = `<span class="chip" style="background:${info.bg};color:${info.color}">${esc(info.label)}</span>`;
152
+ const scLabel = mapping ? `WCAG ${esc(mapping.requirement)}` : 'WCAG (unmapped)';
153
+ const metricsLabel = `${metrics.passCount || 0} pass / ${metrics.failCount || 0} fail / ${metrics.cantTellCount || 0} needs review / ${metrics.notApplicableCount || 0} n/a`;
154
+ return `<tr>
155
+ <td class="sc-cell">${scLabel}</td>
156
+ <td>${esc(rule.title || rule.ruleId)}</td>
157
+ <td>${chip}</td>
158
+ <td class="note">${esc(metricsLabel)}</td>
159
+ <td class="note">${esc(checksIds.join(', '))}</td>
160
+ </tr>`;
161
+ })
162
+ .join('\n');
163
+
164
+ return `<h3 class="wcag-level-heading">Level ${level === 'other' ? '(unmapped)' : level}</h3>
165
+ <table class="wcag-table">
166
+ <thead><tr><th>SC</th><th>Requirement</th><th>Outcome</th><th>Breakdown</th><th>Contributing rules</th></tr></thead>
167
+ <tbody>${rows}</tbody>
168
+ </table>`;
169
+ })
170
+ .join('\n');
171
+
172
+ return sections;
173
+ }
174
+
175
+ // Collapse internal whitespace/newlines and cap length for card display --
176
+ // the findings table below shows the untruncated value.
177
+ const CARD_SNIPPET_MAX = 220;
178
+ function truncateForCard(s) {
179
+ const collapsed = String(s || '').replace(/\s+/g, ' ').trim();
180
+ return collapsed.length > CARD_SNIPPET_MAX ? `${collapsed.slice(0, CARD_SNIPPET_MAX)}…` : collapsed;
181
+ }
182
+
183
+ // One card per rule (not per occurrence) -- a rule with many occurrences of
184
+ // the same underlying issue is one thing worth a person's attention, not N.
185
+ const MAX_CARDS = 24;
186
+
187
+ function renderCards(checksResults) {
188
+ const withIssues = checksResults.filter((r) => (r.outcome === 'fail' || r.outcome === 'cantTell') && Array.isArray(r.occurrences) && r.occurrences.length > 0);
189
+ if (!withIssues.length) {
190
+ return '<p class="note">No fail/cantTell rules with occurrences on this scan.</p>';
191
+ }
192
+
193
+ const sorted = withIssues.slice().sort((a, b) => {
194
+ if (a.outcome !== b.outcome) return a.outcome === 'fail' ? -1 : 1;
195
+ return b.occurrences.length - a.occurrences.length;
196
+ });
197
+ const shown = sorted.slice(0, MAX_CARDS);
198
+
199
+ const cards = shown.map((r) => {
200
+ const info = OUTCOME_INFO[r.outcome];
201
+ const occ = r.occurrences[0];
202
+ const wcagChips = ((r.meta && r.meta.normativeMappings) || [])
203
+ .map((m) => `<span class="chip" style="background:${STATUS.neutral.bg};color:${STATUS.neutral.color}">WCAG ${esc(m.requirement)}</span>`)
204
+ .join('');
205
+ const countLabel = r.occurrences.length > 1 ? ` <span class="card-count">× ${r.occurrences.length.toLocaleString()}</span>` : '';
206
+
207
+ return `<div class="card">
208
+ <div class="card-head">
209
+ <span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
210
+ <span class="card-title"><strong>${esc(r.ruleId)}</strong> (${esc(info.label)}, ${esc(r.severity)})${countLabel}</span>
211
+ </div>
212
+ <div class="card-body">
213
+ <div class="card-meta">${wcagChips}</div>
214
+ <div class="card-selector"><span class="card-selector-label">Selector:</span> <code>${esc(occ.selector || '(none)')}</code></div>
215
+ <div class="card-snippet">${esc(occ.summary)}${occ.hint ? ` — ${esc(occ.hint)}` : ''}</div>
216
+ ${r.occurrences.length > 1 ? `<p class="card-note">Selector/summary above are from one representative occurrence — ${r.occurrences.length.toLocaleString()} total on this rule.</p>` : ''}
217
+ </div>
218
+ </div>`;
219
+ }).join('\n');
220
+
221
+ const overflow = sorted.length > MAX_CARDS
222
+ ? `<p class="note">Showing the ${MAX_CARDS} highest-priority rules of ${sorted.length.toLocaleString()} with issues — see the full technical data below for the rest.</p>`
223
+ : '';
224
+
225
+ return `<div class="cards">${cards}</div>${overflow}`;
226
+ }
227
+
228
+ // Flatten every occurrence across every rule into one row per occurrence --
229
+ // the searchable/filterable/paginated detail view. Rules with zero
230
+ // occurrences (pass/notApplicable) have nothing to show at this granularity.
231
+ function flattenOccurrences(checksResults) {
232
+ const rows = [];
233
+ for (const r of checksResults) {
234
+ if (!Array.isArray(r.occurrences)) continue;
235
+ for (const occ of r.occurrences) {
236
+ rows.push({
237
+ ruleId: r.ruleId,
238
+ outcome: r.outcome,
239
+ severity: r.severity,
240
+ selector: occ.selector || '',
241
+ html: occ.html || '',
242
+ summary: occ.summary || '',
243
+ hint: occ.hint || ''
244
+ });
245
+ }
246
+ }
247
+ return rows;
248
+ }
249
+
250
+ function renderHtmlReport(result, options = {}) {
251
+ const checksResults = Array.isArray(result && result.checksResults) ? result.checksResults : [];
252
+ const rulesResults = Array.isArray(result && result.rulesResults) ? result.rulesResults : [];
253
+ const byOutcome = countByOutcome(checksResults);
254
+ const generatedAtLabel = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'medium' });
255
+ const title = (options && options.title) || 'surea11y scan report';
256
+
257
+ const defaultOnList = OUTCOME_ORDER.filter((c) => OUTCOME_INFO[c].defaultOn);
258
+ const rows = flattenOccurrences(checksResults);
259
+
260
+ return `<!doctype html>
261
+ <html lang="en">
262
+ <head>
263
+ <meta charset="utf-8">
264
+ <title>${esc(title)} — ${esc(generatedAtLabel)}</title>
265
+ <style>
266
+ :root { color-scheme: light dark; }
267
+ * { box-sizing: border-box; }
268
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; margin: 0; padding: 0 0 48px; background: #fafafa; color: #1a1a1a; }
269
+ @media (prefers-color-scheme: dark) {
270
+ body { background: #16181c; color: #e6e6e6; }
271
+ .tile, .wcag-table, .findings-table, .hero, .card, details { background: #1f2227 !important; border-color: #3a3d44 !important; }
272
+ .wcag-table th, .findings-table th { background: #262a30 !important; color: #e6e6e6 !important; }
273
+ a { color: #7db6ff; }
274
+ input, select { background: #1f2227; color: #e6e6e6; border-color: #3a3d44; }
275
+ .meta-bar { background: #1f2227 !important; border-color: #3a3d44 !important; }
276
+ .hero-bar { background: #2a2d33; }
277
+ summary { color: #e6e6e6 !important; }
278
+ }
279
+ header { padding: 24px 32px; background: #202531; color: #fff; }
280
+ header h1 { margin: 0 0 4px; font-size: 22px; }
281
+ header .sub { opacity: 0.8; font-size: 13px; }
282
+ .meta-bar { display: flex; flex-wrap: wrap; gap: 24px; padding: 12px 32px; background: #fff; border-bottom: 1px solid #ddd; font-size: 13px; }
283
+ .meta-bar b { display: block; font-size: 15px; }
284
+ main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
285
+ h2 { font-size: 16px; text-transform: uppercase; letter-spacing: 0.04em; color: #555; margin: 32px 0 12px; }
286
+ h3.wcag-level-heading { font-size: 14px; margin: 20px 0 8px; }
287
+
288
+ .hero { background: #fff; border: 1px solid #e0e0e0; border-radius: 10px; padding: 20px 24px; margin-bottom: 28px; }
289
+ .hero-headline { font-size: 17px; margin: 0 0 16px; line-height: 1.5; }
290
+ .hero-bar { display: flex; height: 22px; border-radius: 6px; overflow: hidden; background: #eee; }
291
+ .hero-bar-seg { height: 100%; }
292
+ .hero-bar-seg:not(:last-child) { border-right: 2px solid #fff; }
293
+ @media (prefers-color-scheme: dark) { .hero-bar-seg:not(:last-child) { border-right-color: #16181c; } }
294
+ .hero-legend { display: flex; flex-wrap: wrap; gap: 14px 24px; margin-top: 16px; }
295
+ .hero-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; }
296
+ .hero-legend-swatch { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 5px; color: #fff; font-size: 11px; font-weight: 700; flex-shrink: 0; }
297
+ .hero-legend-count { opacity: 0.65; font-variant-numeric: tabular-nums; }
298
+
299
+ .cards { display: grid; gap: 10px; margin-bottom: 8px; }
300
+ .card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 12px 14px; background: #fff; }
301
+ .card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
302
+ .card-title { font-size: 13.5px; }
303
+ .card-count { font-weight: 700; opacity: 0.65; font-variant-numeric: tabular-nums; }
304
+ .card-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
305
+ .card-selector { font-size: 12px; margin-bottom: 6px; word-break: break-all; }
306
+ .card-selector-label { opacity: 0.65; margin-right: 4px; }
307
+ .card-selector code { font-family: ui-monospace, monospace; background: #f0f0f2; border-radius: 4px; padding: 1px 5px; user-select: all; }
308
+ @media (prefers-color-scheme: dark) { .card-selector code { background: #2a2d33 !important; } }
309
+ .card-snippet { font-family: ui-monospace, monospace; font-size: 11.5px; background: #f7f7f8; border-radius: 5px; padding: 6px 8px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
310
+ @media (prefers-color-scheme: dark) { .card-snippet { background: #262a30 !important; } }
311
+ .card-note { font-size: 11px; opacity: 0.6; margin: 6px 0 0; }
312
+
313
+ details.tech-details { border: 1px solid #e0e0e0; border-radius: 8px; margin-top: 8px; }
314
+ details.tech-details summary { padding: 14px 18px; cursor: pointer; font-weight: 600; font-size: 14px; color: #333; list-style: none; }
315
+ details.tech-details summary::-webkit-details-marker { display: none; }
316
+ details.tech-details summary::before { content: '▶'; display: inline-block; margin-right: 8px; font-size: 11px; transition: transform 0.15s; }
317
+ details.tech-details[open] summary::before { transform: rotate(90deg); }
318
+ details.tech-details > .tech-body { padding: 0 18px 20px; }
319
+
320
+ .scorecard { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
321
+ .tile { border: 1.5px solid; border-radius: 8px; padding: 12px 14px; }
322
+ .tile-num { font-size: 24px; font-weight: 700; line-height: 1.1; }
323
+ .tile-pct { font-size: 12px; opacity: 0.75; }
324
+ .tile-label { font-size: 12.5px; margin-top: 4px; font-weight: 600; }
325
+ table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
326
+ .wcag-table, .findings-table { background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; margin-bottom: 8px; }
327
+ .wcag-table th, .wcag-table td, .findings-table th, .findings-table td { padding: 5px 8px; border-bottom: 1px solid #eee; text-align: left; }
328
+ .wcag-table th, .findings-table th { background: #f2f2f4; position: sticky; top: 0; font-size: 11px; }
329
+ .sc-cell { font-family: ui-monospace, monospace; font-size: 11.5px; white-space: nowrap; }
330
+ .filters { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; margin-bottom: 12px; }
331
+ .filters label { display: inline-flex; align-items: center; gap: 4px; font-size: 12.5px; cursor: pointer; user-select: none; }
332
+ .chip { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 10.5px; font-weight: 700; white-space: nowrap; }
333
+ .findings-table td.snippet { font-family: ui-monospace, monospace; font-size: 11px; max-width: 320px; overflow-x: auto; white-space: pre; }
334
+ .pager { display: flex; gap: 10px; align-items: center; margin-top: 10px; font-size: 13px; }
335
+ .pager button { padding: 5px 12px; border: 1px solid #ccc; background: #fff; border-radius: 5px; cursor: pointer; }
336
+ .pager button:disabled { opacity: 0.4; cursor: default; }
337
+ .note { font-size: 12.5px; opacity: 0.75; margin-top: 6px; }
338
+ </style>
339
+ </head>
340
+ <body>
341
+
342
+ <header>
343
+ <h1>${esc(title)}</h1>
344
+ <div class="sub">${esc(result && result.url ? result.url : '(no url)')} — generated ${esc(generatedAtLabel)}</div>
345
+ </header>
346
+
347
+ <div class="meta-bar">
348
+ <div><b>${checksResults.length}</b>rules run</div>
349
+ <div><b>${rows.length}</b>total occurrences</div>
350
+ <div><b>${esc((result && result.engine && result.engine.tag) || '?')}</b>engine</div>
351
+ <div><b>${esc((result && result.engine && result.engine.schemaVersion) || '?')}</b>schema version</div>
352
+ </div>
353
+
354
+ <main>
355
+ ${renderHeroBar(byOutcome)}
356
+
357
+ <h2>Worth reviewing</h2>
358
+ ${renderCards(checksResults)}
359
+
360
+ <h2>WCAG rollup</h2>
361
+ ${renderWcagRollup(rulesResults)}
362
+
363
+ <details class="tech-details">
364
+ <summary>Full technical data — scorecard, searchable occurrence browser</summary>
365
+ <div class="tech-body">
366
+ <h2>Scorecard</h2>
367
+ ${renderScorecard(byOutcome)}
368
+
369
+ <h2>Occurrences</h2>
370
+ <div class="filters" id="filters"></div>
371
+ <input type="search" id="search" placeholder="Search rule id / selector / html…" style="margin-bottom:10px; padding:6px 10px; border:1px solid #ccc; border-radius:5px; font-size:13px; width:100%; max-width:480px;">
372
+ <div style="overflow-x:auto;">
373
+ <table class="findings-table" id="findings-table">
374
+ <thead>
375
+ <tr>
376
+ <th>Rule</th>
377
+ <th>Outcome</th>
378
+ <th>Severity</th>
379
+ <th>Selector</th>
380
+ <th>Summary</th>
381
+ </tr>
382
+ </thead>
383
+ <tbody id="findings-body"></tbody>
384
+ </table>
385
+ </div>
386
+ <div class="pager">
387
+ <button id="prev-page">&larr; Prev</button>
388
+ <span id="page-info"></span>
389
+ <button id="next-page">Next &rarr;</button>
390
+ </div>
391
+ </div>
392
+ </details>
393
+ </main>
394
+
395
+ <script type="application/json" id="report-data">${jsonForScript(rows)}</script>
396
+ <script>
397
+ (function () {
398
+ var OUTCOME_INFO = ${jsonForScript(OUTCOME_INFO)};
399
+ var ORDER = ${jsonForScript(OUTCOME_ORDER)};
400
+ var DEFAULT_ON = ${jsonForScript(defaultOnList)};
401
+ var rows = JSON.parse(document.getElementById('report-data').textContent);
402
+
403
+ var PAGE_SIZE = 100;
404
+ var page = 0;
405
+ var active = {};
406
+ DEFAULT_ON.forEach(function (c) { active[c] = true; });
407
+
408
+ var filtersEl = document.getElementById('filters');
409
+ ORDER.forEach(function (c) {
410
+ var info = OUTCOME_INFO[c];
411
+ var id = 'chk-' + c;
412
+ var label = document.createElement('label');
413
+ var checked = DEFAULT_ON.indexOf(c) !== -1;
414
+ label.innerHTML = '<input type="checkbox" id="' + id + '"' + (checked ? ' checked' : '') + '> ' +
415
+ '<span class="chip" style="background:' + info.bg + ';color:' + info.color + '">' + c + '</span>';
416
+ filtersEl.appendChild(label);
417
+ label.querySelector('input').addEventListener('change', function (e) {
418
+ active[c] = e.target.checked;
419
+ page = 0;
420
+ render();
421
+ });
422
+ });
423
+
424
+ var searchEl = document.getElementById('search');
425
+ var bodyEl = document.getElementById('findings-body');
426
+ var pageInfoEl = document.getElementById('page-info');
427
+ var prevBtn = document.getElementById('prev-page');
428
+ var nextBtn = document.getElementById('next-page');
429
+
430
+ function esc(s) {
431
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
432
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
433
+ });
434
+ }
435
+
436
+ function matches(r, q) {
437
+ if (!active[r.outcome]) return false;
438
+ if (!q) return true;
439
+ var hay = (r.ruleId + ' ' + r.selector + ' ' + r.html + ' ' + r.summary).toLowerCase();
440
+ return hay.indexOf(q) !== -1;
441
+ }
442
+
443
+ function render() {
444
+ var q = searchEl.value.trim().toLowerCase();
445
+ var filtered = rows.filter(function (r) { return matches(r, q); });
446
+ var totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
447
+ if (page >= totalPages) page = totalPages - 1;
448
+ if (page < 0) page = 0;
449
+
450
+ var slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
451
+
452
+ bodyEl.innerHTML = slice.map(function (r) {
453
+ var info = OUTCOME_INFO[r.outcome];
454
+ var chip = '<span class="chip" style="background:' + info.bg + ';color:' + info.color + '">' + r.outcome + '</span>';
455
+ return '<tr>' +
456
+ '<td class="snippet">' + esc(r.ruleId) + '</td>' +
457
+ '<td>' + chip + '</td>' +
458
+ '<td>' + esc(r.severity) + '</td>' +
459
+ '<td class="snippet" title="' + esc(r.html) + '">' + esc(r.selector) + '</td>' +
460
+ '<td class="snippet">' + esc(r.summary) + '</td>' +
461
+ '</tr>';
462
+ }).join('');
463
+
464
+ pageInfoEl.textContent = filtered.length === 0
465
+ ? 'No matching occurrences'
466
+ : ('Page ' + (page + 1) + ' / ' + totalPages + ' (' + filtered.length.toLocaleString() + ' matching, ' + rows.length.toLocaleString() + ' total)');
467
+ prevBtn.disabled = page <= 0;
468
+ nextBtn.disabled = page >= totalPages - 1;
469
+ }
470
+
471
+ searchEl.addEventListener('input', function () { page = 0; render(); });
472
+ prevBtn.addEventListener('click', function () { page--; render(); });
473
+ nextBtn.addEventListener('click', function () { page++; render(); });
474
+
475
+ render();
476
+ })();
477
+ </script>
478
+ </body>
479
+ </html>`;
480
+ }
481
+
482
+ module.exports = { renderHtmlReport };