@surea11y/core 1.1.2 → 1.3.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 (160) hide show
  1. package/CHANGELOG.md +48 -4
  2. package/LICENSE +373 -21
  3. package/README.md +70 -1
  4. package/bin/core.js +240 -11
  5. package/docs/API_STABILITY.md +61 -0
  6. package/docs/BASELINE.md +66 -0
  7. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  8. package/docs/CI_INTEGRATIONS.md +103 -0
  9. package/docs/CLI.md +80 -1
  10. package/docs/ENGINE_OPTIONS.md +4 -0
  11. package/docs/INTEGRATION.md +19 -1
  12. package/docs/OUTPUT_SCHEMA.md +2 -2
  13. package/docs/REPORT.md +33 -0
  14. package/docs/RULE_AUTHORING.md +31 -0
  15. package/docs/RULE_CATALOG.md +1 -1
  16. package/docs/SARIF.md +59 -0
  17. package/package.json +15 -4
  18. package/src/baseline.js +0 -0
  19. package/src/catalogs/composites.wcag.js +414 -450
  20. package/src/checks/automatic/area-alt-present.js +59 -25
  21. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  22. package/src/checks/automatic/aria-allowed-role.js +21 -7
  23. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  24. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  25. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  26. package/src/checks/automatic/aria-hidden-body.js +50 -19
  27. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  28. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  29. package/src/checks/automatic/aria-prohibited-children.js +125 -29
  30. package/src/checks/automatic/aria-required-attr.js +23 -8
  31. package/src/checks/automatic/aria-required-children.js +37 -14
  32. package/src/checks/automatic/aria-required-parent.js +48 -14
  33. package/src/checks/automatic/aria-role-name-present.js +47 -21
  34. package/src/checks/automatic/aria-roles-valid.js +22 -12
  35. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  36. package/src/checks/automatic/aria-valid-attr.js +17 -5
  37. package/src/checks/automatic/autocomplete-valid.js +74 -16
  38. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  39. package/src/checks/automatic/binary-control-name-present.js +60 -50
  40. package/src/checks/automatic/button-name-present.js +48 -18
  41. package/src/checks/automatic/bypass-blocks-present.js +50 -25
  42. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  43. package/src/checks/automatic/combobox-name-present.js +38 -45
  44. package/src/checks/automatic/contrast-computable.js +361 -341
  45. package/src/checks/automatic/contrast-enhanced.js +487 -466
  46. package/src/checks/automatic/contrast-minimum.js +486 -465
  47. package/src/checks/automatic/css-orientation-lock.js +39 -9
  48. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  49. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  50. package/src/checks/automatic/dialog-name-present.js +37 -75
  51. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  52. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  53. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  55. package/src/checks/automatic/form-control-single-label.js +47 -10
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
  57. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  58. package/src/checks/automatic/iframe-name-present.js +31 -9
  59. package/src/checks/automatic/iframe-title-unique.js +29 -8
  60. package/src/checks/automatic/img-alt-present.js +47 -43
  61. package/src/checks/automatic/input-image-alt-present.js +143 -112
  62. package/src/checks/automatic/label-in-name.js +50 -22
  63. package/src/checks/automatic/language-page-present.js +117 -109
  64. package/src/checks/automatic/link-in-text-block.js +59 -19
  65. package/src/checks/automatic/link-name-present.js +45 -14
  66. package/src/checks/automatic/list-children-valid.js +26 -9
  67. package/src/checks/automatic/listbox-name-present.js +39 -19
  68. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  69. package/src/checks/automatic/menuitem-name-present.js +39 -61
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
  73. package/src/checks/automatic/meter-name-present.js +36 -33
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  75. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  76. package/src/checks/automatic/option-name-present.js +38 -21
  77. package/src/checks/automatic/page-title-present.js +26 -7
  78. package/src/checks/automatic/progressbar-name-present.js +41 -34
  79. package/src/checks/automatic/role-img-alt-present.js +209 -157
  80. package/src/checks/automatic/searchbox-name-present.js +39 -19
  81. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  82. package/src/checks/automatic/slider-name-present.js +40 -47
  83. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  84. package/src/checks/automatic/summary-name-present.js +37 -17
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  86. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  87. package/src/checks/automatic/tab-name-present.js +37 -60
  88. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  89. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  90. package/src/checks/automatic/target-size-minimum.js +118 -48
  91. package/src/checks/automatic/td-has-header.js +29 -11
  92. package/src/checks/automatic/textbox-name-present.js +39 -19
  93. package/src/checks/automatic/tooltip-name-present.js +37 -18
  94. package/src/checks/automatic/treeitem-name-present.js +38 -21
  95. package/src/checks/automatic/valid-lang.js +20 -6
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  97. package/src/checks/manual/accesskeys-manual.js +14 -5
  98. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  99. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  101. package/src/checks/manual/aria-text-manual.js +14 -6
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  103. package/src/checks/manual/css-hidden-focus.js +196 -165
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  105. package/src/checks/manual/empty-heading-manual.js +24 -12
  106. package/src/checks/manual/empty-table-header-manual.js +21 -14
  107. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  109. package/src/checks/manual/heading-order-manual.js +22 -12
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  112. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  113. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  115. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  116. package/src/checks/manual/label-title-only-manual.js +14 -5
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  123. package/src/checks/manual/landmark-one-main-manual.js +35 -21
  124. package/src/checks/manual/landmark-unique-manual.js +73 -45
  125. package/src/checks/manual/link-name-quality-manual.js +43 -12
  126. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  127. package/src/checks/manual/meta-viewport-large-manual.js +25 -6
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  129. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  131. package/src/checks/manual/p-as-heading-manual.js +22 -7
  132. package/src/checks/manual/page-has-heading-one-manual.js +40 -23
  133. package/src/checks/manual/page-title-patterns-manual.js +87 -51
  134. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  135. package/src/checks/manual/region-manual.js +275 -62
  136. package/src/checks/manual/scope-attr-valid-manual.js +11 -9
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  138. package/src/checks/manual/skip-link-manual.js +35 -12
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  140. package/src/checks/manual/tabindex-manual.js +11 -9
  141. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  142. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  143. package/src/checks/manual/video-caption-manual.js +15 -4
  144. package/src/checks/manual-review.js +56 -12
  145. package/src/core/aria-helpers.js +1128 -823
  146. package/src/core/contrast-helpers.js +1217 -1062
  147. package/src/core/dom-helpers.js +4176 -3886
  148. package/src/core/dom-runner.js +720 -596
  149. package/src/core/frame-messaging.js +189 -138
  150. package/src/core/frame-scan.js +94 -82
  151. package/src/core/rollup-composites.js +94 -102
  152. package/src/core/rule-meta.js +71 -35
  153. package/src/core.js +37939 -29121
  154. package/src/i18n/en.js +1194 -887
  155. package/src/i18n/fr.js +1136 -793
  156. package/src/policy/contracts.js +13 -13
  157. package/src/policy/resolvePolicy.js +48 -44
  158. package/src/report.js +502 -0
  159. package/src/sarif.js +175 -0
  160. package/surea11y.browser.js +36042 -0
@@ -1,18 +1,18 @@
1
1
  'use strict';
2
2
 
3
3
  module.exports = {
4
- POLICY_CONTRACTS: {
5
- a11y: {
6
- id: 'a11y',
7
- allowedOutcomes: ['fail', 'pass', 'cantTell', 'notApplicable'],
8
- allowedConfidence: ['high', 'medium', 'low'],
9
- coerceManualFailToCantTell: true
10
- },
11
- generic: {
12
- id: 'generic',
13
- allowedOutcomes: ['fail', 'pass', 'cantTell', 'notApplicable'],
14
- allowedConfidence: ['high', 'medium', 'low'],
15
- coerceManualFailToCantTell: false
16
- }
4
+ POLICY_CONTRACTS: {
5
+ a11y: {
6
+ id: 'a11y',
7
+ allowedOutcomes: ['fail', 'pass', 'cantTell', 'notApplicable'],
8
+ allowedConfidence: ['high', 'medium', 'low'],
9
+ coerceManualFailToCantTell: true
10
+ },
11
+ generic: {
12
+ id: 'generic',
13
+ allowedOutcomes: ['fail', 'pass', 'cantTell', 'notApplicable'],
14
+ allowedConfidence: ['high', 'medium', 'low'],
15
+ coerceManualFailToCantTell: false
17
16
  }
17
+ }
18
18
  };
@@ -1,55 +1,59 @@
1
1
  'use strict';
2
2
 
3
3
  function resolvePolicy(POLICY_CONTRACTS, engineOptions) {
4
- function normalizePolicyContract(POLICY_CONTRACTS, contract, fallbackId) {
5
- const fallback = POLICY_CONTRACTS[fallbackId] || POLICY_CONTRACTS.a11y;
6
- if (typeof contract === 'string') return POLICY_CONTRACTS[contract] || fallback;
7
-
8
- if (contract && typeof contract === 'object') {
9
- const allowedOutcomes = Array.isArray(contract.allowedOutcomes)
10
- ? contract.allowedOutcomes.slice()
11
- : fallback.allowedOutcomes.slice();
12
-
13
- const allowedConfidence = Array.isArray(contract.allowedConfidence)
14
- ? contract.allowedConfidence.slice()
15
- : fallback.allowedConfidence.slice();
16
-
17
- return {
18
- id: (typeof contract.id === 'string' && contract.id.trim())
19
- ? contract.id.trim()
20
- : (fallback.id || fallbackId || 'custom'),
21
- allowedOutcomes,
22
- allowedConfidence,
23
- coerceManualFailToCantTell:
24
- typeof contract.coerceManualFailToCantTell === 'boolean'
25
- ? contract.coerceManualFailToCantTell
26
- : !!fallback.coerceManualFailToCantTell
27
- };
28
- }
29
-
30
- return fallback;
31
- }
32
-
33
- function normalizePolicyOverrides(policy) {
34
- const p = (policy && typeof policy === 'object') ? policy : {};
35
- return {
36
- allowedOutcomes: Array.isArray(p.allowedOutcomes) ? p.allowedOutcomes.slice() : null,
37
- allowedConfidence: Array.isArray(p.allowedConfidence) ? p.allowedConfidence.slice() : null,
38
- coerceManualFailToCantTell: typeof p.coerceManualFailToCantTell === 'boolean' ? p.coerceManualFailToCantTell : null
39
- };
4
+ function normalizePolicyContract(POLICY_CONTRACTS, contract, fallbackId) {
5
+ const fallback = POLICY_CONTRACTS[fallbackId] || POLICY_CONTRACTS.a11y;
6
+ if (typeof contract === 'string') return POLICY_CONTRACTS[contract] || fallback;
7
+
8
+ if (contract && typeof contract === 'object') {
9
+ const allowedOutcomes = Array.isArray(contract.allowedOutcomes)
10
+ ? contract.allowedOutcomes.slice()
11
+ : fallback.allowedOutcomes.slice();
12
+
13
+ const allowedConfidence = Array.isArray(contract.allowedConfidence)
14
+ ? contract.allowedConfidence.slice()
15
+ : fallback.allowedConfidence.slice();
16
+
17
+ return {
18
+ id:
19
+ typeof contract.id === 'string' && contract.id.trim()
20
+ ? contract.id.trim()
21
+ : fallback.id || fallbackId || 'custom',
22
+ allowedOutcomes,
23
+ allowedConfidence,
24
+ coerceManualFailToCantTell:
25
+ typeof contract.coerceManualFailToCantTell === 'boolean'
26
+ ? contract.coerceManualFailToCantTell
27
+ : !!fallback.coerceManualFailToCantTell
28
+ };
40
29
  }
41
30
 
42
- const opts = (engineOptions && typeof engineOptions === 'object') ? engineOptions : {};
43
- const contract = normalizePolicyContract(POLICY_CONTRACTS, opts.policyContract, 'a11y');
44
- const ov = normalizePolicyOverrides(opts.policy);
31
+ return fallback;
32
+ }
45
33
 
34
+ function normalizePolicyOverrides(policy) {
35
+ const p = policy && typeof policy === 'object' ? policy : {};
46
36
  return {
47
- contractId: contract.id,
48
- allowedOutcomes: ov.allowedOutcomes || contract.allowedOutcomes.slice(),
49
- allowedConfidence: ov.allowedConfidence || contract.allowedConfidence.slice(),
50
- coerceManualFailToCantTell:
51
- ov.coerceManualFailToCantTell !== null ? ov.coerceManualFailToCantTell : !!contract.coerceManualFailToCantTell
37
+ allowedOutcomes: Array.isArray(p.allowedOutcomes) ? p.allowedOutcomes.slice() : null,
38
+ allowedConfidence: Array.isArray(p.allowedConfidence) ? p.allowedConfidence.slice() : null,
39
+ coerceManualFailToCantTell:
40
+ typeof p.coerceManualFailToCantTell === 'boolean' ? p.coerceManualFailToCantTell : null
52
41
  };
42
+ }
43
+
44
+ const opts = engineOptions && typeof engineOptions === 'object' ? engineOptions : {};
45
+ const contract = normalizePolicyContract(POLICY_CONTRACTS, opts.policyContract, 'a11y');
46
+ const ov = normalizePolicyOverrides(opts.policy);
47
+
48
+ return {
49
+ contractId: contract.id,
50
+ allowedOutcomes: ov.allowedOutcomes || contract.allowedOutcomes.slice(),
51
+ allowedConfidence: ov.allowedConfidence || contract.allowedConfidence.slice(),
52
+ coerceManualFailToCantTell:
53
+ ov.coerceManualFailToCantTell !== null
54
+ ? ov.coerceManualFailToCantTell
55
+ : !!contract.coerceManualFailToCantTell
56
+ };
53
57
  }
54
58
 
55
59
  module.exports = { resolvePolicy };
package/src/report.js ADDED
@@ -0,0 +1,502 @@
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
+ * Self-contained single file: hero bar + legend, grouped "worth reviewing"
8
+ * cards with an overflow cap, collapsible "full technical data" with a
9
+ * scorecard + searchable/filterable/paginated table, dark-mode CSS.
10
+ */
11
+
12
+ // Uses the dataviz skill's validated status palette, mapped 1:1 onto this
13
+ // engine's own 4 outcomes.
14
+ const STATUS = {
15
+ good: { color: '#0ca30c', bg: '#e9f7e9', icon: '✓' },
16
+ serious: { color: '#c1502e', bg: '#fdece5', icon: '⚠' },
17
+ warning: { color: '#8a6400', bg: '#fdf3d9', icon: 'ℹ' },
18
+ neutral: { color: '#5f6368', bg: '#f1f2f3', icon: '–' }
19
+ };
20
+
21
+ // fail first: a QA tester scanning a report wants violations up front,
22
+ // not passes.
23
+ const OUTCOME_ORDER = ['fail', 'cantTell', 'pass', 'notApplicable'];
24
+ const OUTCOME_INFO = {
25
+ fail: { label: 'Fail', status: 'serious', defaultOn: true },
26
+ cantTell: { label: 'Needs review', status: 'warning', defaultOn: true },
27
+ pass: { label: 'Pass', status: 'good', defaultOn: false },
28
+ notApplicable: { label: 'Not applicable', status: 'neutral', defaultOn: false }
29
+ };
30
+ for (const code of OUTCOME_ORDER) {
31
+ Object.assign(OUTCOME_INFO[code], STATUS[OUTCOME_INFO[code].status]);
32
+ }
33
+
34
+ function esc(s) {
35
+ return String(s == null ? '' : s).replace(
36
+ /[&<>"']/g,
37
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
38
+ );
39
+ }
40
+
41
+ // Neutralizes '<' so embedded JSON can never break out of its <script> tag,
42
+ // even if an occurrence's own html snippet literally contains "</script>".
43
+ function jsonForScript(value) {
44
+ return JSON.stringify(value).replace(/</g, '\\u003c');
45
+ }
46
+
47
+ function fmtPct(n, total) {
48
+ return total ? `${((n / total) * 100).toFixed(1)}%` : '0.0%';
49
+ }
50
+
51
+ function countByOutcome(checksResults) {
52
+ const counts = { pass: 0, fail: 0, cantTell: 0, notApplicable: 0 };
53
+ for (const r of checksResults) {
54
+ if (Object.prototype.hasOwnProperty.call(counts, r.outcome)) counts[r.outcome] += 1;
55
+ }
56
+ return counts;
57
+ }
58
+
59
+ // One plain-language headline + one horizontal stacked bar + a legend with
60
+ // icon+label+count (status color is never the only signal) -- the first
61
+ // thing a reader sees; exhaustive detail lives in the collapsed
62
+ // "full technical data" section further down.
63
+ function renderHeroBar(byOutcome) {
64
+ const total = OUTCOME_ORDER.reduce((sum, c) => sum + byOutcome[c], 0) || 1;
65
+ const applicable = byOutcome.pass + byOutcome.fail + byOutcome.cantTell;
66
+
67
+ const segments = OUTCOME_ORDER.filter((c) => byOutcome[c] > 0)
68
+ .map((c) => {
69
+ const n = byOutcome[c];
70
+ const pct = (n / total) * 100;
71
+ const info = OUTCOME_INFO[c];
72
+ return `<div class="hero-bar-seg" style="width:${pct}%; background:${info.color}" title="${esc(info.label)}: ${n.toLocaleString()} (${pct.toFixed(1)}%)"></div>`;
73
+ })
74
+ .join('');
75
+
76
+ const legend = OUTCOME_ORDER.map((c) => {
77
+ const n = byOutcome[c];
78
+ const info = OUTCOME_INFO[c];
79
+ return `<div class="hero-legend-item">
80
+ <span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
81
+ <span class="hero-legend-label">${esc(info.label)}</span>
82
+ <span class="hero-legend-count">${n.toLocaleString()} (${fmtPct(n, total)})</span>
83
+ </div>`;
84
+ }).join('\n');
85
+
86
+ let headline;
87
+ if (byOutcome.fail > 0) {
88
+ 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.`;
89
+ } else if (byOutcome.cantTell > 0) {
90
+ headline = `<strong>${byOutcome.pass.toLocaleString()}</strong> of <strong>${applicable.toLocaleString()}</strong> applicable checks passed, with <strong>${byOutcome.cantTell.toLocaleString()}</strong> needing manual review.`;
91
+ } else if (applicable > 0) {
92
+ headline = `All <strong>${applicable.toLocaleString()}</strong> applicable checks passed.`;
93
+ } else {
94
+ headline = 'No applicable checks ran for this scan.';
95
+ }
96
+
97
+ return `<div class="hero">
98
+ <p class="hero-headline">${headline}</p>
99
+ <div class="hero-bar">${segments}</div>
100
+ <div class="hero-legend">${legend}</div>
101
+ </div>`;
102
+ }
103
+
104
+ function renderScorecard(byOutcome) {
105
+ const total = OUTCOME_ORDER.reduce((sum, c) => sum + byOutcome[c], 0);
106
+ const tiles = OUTCOME_ORDER.map((c) => {
107
+ const n = byOutcome[c];
108
+ const info = OUTCOME_INFO[c];
109
+ return `<div class="tile" style="border-color:${info.color}; background:${info.bg}">
110
+ <div class="tile-num" style="color:${info.color}">${n.toLocaleString()}</div>
111
+ <div class="tile-pct">${fmtPct(n, total)}</div>
112
+ <div class="tile-label">${esc(info.label)}</div>
113
+ </div>`;
114
+ }).join('\n');
115
+ return `<div class="scorecard">${tiles}</div>`;
116
+ }
117
+
118
+ // WCAG rollup -- straight from the engine's own rulesResults (one composite
119
+ // entry per Success Criterion, docs/WCAG_CONFORMANCE.md), not an invented
120
+ // grouping. Grouped by conformance level (A / AA / AAA) since that's the
121
+ // axis a compliance-minded reader actually cares about.
122
+ function renderWcagRollup(rulesResults) {
123
+ if (!Array.isArray(rulesResults) || !rulesResults.length) {
124
+ return '<p class="note">No WCAG composite rollups available for this scan (composite rules were excluded via runOnly/engineOptions).</p>';
125
+ }
126
+
127
+ const byLevel = { A: [], AA: [], AAA: [], other: [] };
128
+ for (const r of rulesResults) {
129
+ const mapping =
130
+ (r.meta && Array.isArray(r.meta.normativeMappings) && r.meta.normativeMappings[0]) || null;
131
+ const level = (mapping && mapping.level) || 'other';
132
+ (byLevel[level] || byLevel.other).push({ rule: r, mapping });
133
+ }
134
+
135
+ const sections = ['A', 'AA', 'AAA', 'other']
136
+ .filter((level) => byLevel[level].length)
137
+ .map((level) => {
138
+ const rows = byLevel[level]
139
+ .sort((a, b) =>
140
+ (a.mapping ? a.mapping.requirement : '').localeCompare(
141
+ b.mapping ? b.mapping.requirement : '',
142
+ undefined,
143
+ { numeric: true }
144
+ )
145
+ )
146
+ .map(({ rule, mapping }) => {
147
+ const info = OUTCOME_INFO[rule.outcome] || OUTCOME_INFO.notApplicable;
148
+ const metrics = (rule.data && rule.data.details && rule.data.details.metrics) || {};
149
+ const checksIds = (rule.data && rule.data.details && rule.data.details.checksIds) || [];
150
+ const chip = `<span class="chip" style="background:${info.bg};color:${info.color}">${esc(info.label)}</span>`;
151
+ const scLabel = mapping ? `WCAG ${esc(mapping.requirement)}` : 'WCAG (unmapped)';
152
+ const metricsLabel = `${metrics.passCount || 0} pass / ${metrics.failCount || 0} fail / ${metrics.cantTellCount || 0} needs review / ${metrics.notApplicableCount || 0} n/a`;
153
+ return `<tr>
154
+ <td class="sc-cell">${scLabel}</td>
155
+ <td>${esc(rule.title || rule.ruleId)}</td>
156
+ <td>${chip}</td>
157
+ <td class="note">${esc(metricsLabel)}</td>
158
+ <td class="note">${esc(checksIds.join(', '))}</td>
159
+ </tr>`;
160
+ })
161
+ .join('\n');
162
+
163
+ return `<h3 class="wcag-level-heading">Level ${level === 'other' ? '(unmapped)' : level}</h3>
164
+ <table class="wcag-table">
165
+ <thead><tr><th>SC</th><th>Requirement</th><th>Outcome</th><th>Breakdown</th><th>Contributing rules</th></tr></thead>
166
+ <tbody>${rows}</tbody>
167
+ </table>`;
168
+ })
169
+ .join('\n');
170
+
171
+ return sections;
172
+ }
173
+
174
+ // Collapse internal whitespace/newlines and cap length for card display --
175
+ // the findings table below shows the untruncated value.
176
+ const CARD_SNIPPET_MAX = 220;
177
+ function truncateForCard(s) {
178
+ const collapsed = String(s || '')
179
+ .replace(/\s+/g, ' ')
180
+ .trim();
181
+ return collapsed.length > CARD_SNIPPET_MAX
182
+ ? `${collapsed.slice(0, CARD_SNIPPET_MAX)}…`
183
+ : collapsed;
184
+ }
185
+
186
+ // One card per rule (not per occurrence) -- a rule with many occurrences of
187
+ // the same underlying issue is one thing worth a person's attention, not N.
188
+ const MAX_CARDS = 24;
189
+
190
+ function renderCards(checksResults) {
191
+ const withIssues = checksResults.filter(
192
+ (r) =>
193
+ (r.outcome === 'fail' || r.outcome === 'cantTell') &&
194
+ Array.isArray(r.occurrences) &&
195
+ r.occurrences.length > 0
196
+ );
197
+ if (!withIssues.length) {
198
+ return '<p class="note">No fail/cantTell rules with occurrences on this scan.</p>';
199
+ }
200
+
201
+ const sorted = withIssues.slice().sort((a, b) => {
202
+ if (a.outcome !== b.outcome) return a.outcome === 'fail' ? -1 : 1;
203
+ return b.occurrences.length - a.occurrences.length;
204
+ });
205
+ const shown = sorted.slice(0, MAX_CARDS);
206
+
207
+ const cards = shown
208
+ .map((r) => {
209
+ const info = OUTCOME_INFO[r.outcome];
210
+ const occ = r.occurrences[0];
211
+ const wcagChips = ((r.meta && r.meta.normativeMappings) || [])
212
+ .map(
213
+ (m) =>
214
+ `<span class="chip" style="background:${STATUS.neutral.bg};color:${STATUS.neutral.color}">WCAG ${esc(m.requirement)}</span>`
215
+ )
216
+ .join('');
217
+ const countLabel =
218
+ r.occurrences.length > 1
219
+ ? ` <span class="card-count">× ${r.occurrences.length.toLocaleString()}</span>`
220
+ : '';
221
+
222
+ return `<div class="card">
223
+ <div class="card-head">
224
+ <span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
225
+ <span class="card-title"><strong>${esc(r.ruleId)}</strong> (${esc(info.label)}, ${esc(r.severity)})${countLabel}</span>
226
+ </div>
227
+ <div class="card-body">
228
+ <div class="card-meta">${wcagChips}</div>
229
+ <div class="card-selector"><span class="card-selector-label">Selector:</span> <code>${esc(occ.selector || '(none)')}</code></div>
230
+ <div class="card-snippet">${esc(occ.summary)}${occ.hint ? ` — ${esc(occ.hint)}` : ''}</div>
231
+ ${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>` : ''}
232
+ </div>
233
+ </div>`;
234
+ })
235
+ .join('\n');
236
+
237
+ const overflow =
238
+ sorted.length > MAX_CARDS
239
+ ? `<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>`
240
+ : '';
241
+
242
+ return `<div class="cards">${cards}</div>${overflow}`;
243
+ }
244
+
245
+ // Flatten every occurrence across every rule into one row per occurrence --
246
+ // the searchable/filterable/paginated detail view. Rules with zero
247
+ // occurrences (pass/notApplicable) have nothing to show at this granularity.
248
+ function flattenOccurrences(checksResults) {
249
+ const rows = [];
250
+ for (const r of checksResults) {
251
+ if (!Array.isArray(r.occurrences)) continue;
252
+ for (const occ of r.occurrences) {
253
+ rows.push({
254
+ ruleId: r.ruleId,
255
+ outcome: r.outcome,
256
+ severity: r.severity,
257
+ selector: occ.selector || '',
258
+ html: occ.html || '',
259
+ summary: occ.summary || '',
260
+ hint: occ.hint || ''
261
+ });
262
+ }
263
+ }
264
+ return rows;
265
+ }
266
+
267
+ function renderHtmlReport(result, options = {}) {
268
+ const checksResults = Array.isArray(result && result.checksResults) ? result.checksResults : [];
269
+ const rulesResults = Array.isArray(result && result.rulesResults) ? result.rulesResults : [];
270
+ const byOutcome = countByOutcome(checksResults);
271
+ const generatedAtLabel = new Date().toLocaleString('en-US', {
272
+ dateStyle: 'medium',
273
+ timeStyle: 'medium'
274
+ });
275
+ const title = (options && options.title) || 'surea11y scan report';
276
+
277
+ const defaultOnList = OUTCOME_ORDER.filter((c) => OUTCOME_INFO[c].defaultOn);
278
+ const rows = flattenOccurrences(checksResults);
279
+
280
+ return `<!doctype html>
281
+ <html lang="en">
282
+ <head>
283
+ <meta charset="utf-8">
284
+ <title>${esc(title)} — ${esc(generatedAtLabel)}</title>
285
+ <style>
286
+ :root { color-scheme: light dark; }
287
+ * { box-sizing: border-box; }
288
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; margin: 0; padding: 0 0 48px; background: #fafafa; color: #1a1a1a; }
289
+ @media (prefers-color-scheme: dark) {
290
+ body { background: #16181c; color: #e6e6e6; }
291
+ .tile, .wcag-table, .findings-table, .hero, .card, details { background: #1f2227 !important; border-color: #3a3d44 !important; }
292
+ .wcag-table th, .findings-table th { background: #262a30 !important; color: #e6e6e6 !important; }
293
+ a { color: #7db6ff; }
294
+ input, select { background: #1f2227; color: #e6e6e6; border-color: #3a3d44; }
295
+ .meta-bar { background: #1f2227 !important; border-color: #3a3d44 !important; }
296
+ .hero-bar { background: #2a2d33; }
297
+ summary { color: #e6e6e6 !important; }
298
+ }
299
+ header { padding: 24px 32px; background: #202531; color: #fff; }
300
+ header h1 { margin: 0 0 4px; font-size: 22px; }
301
+ header .sub { opacity: 0.8; font-size: 13px; }
302
+ .meta-bar { display: flex; flex-wrap: wrap; gap: 24px; padding: 12px 32px; background: #fff; border-bottom: 1px solid #ddd; font-size: 13px; }
303
+ .meta-bar b { display: block; font-size: 15px; }
304
+ main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
305
+ h2 { font-size: 16px; text-transform: uppercase; letter-spacing: 0.04em; color: #555; margin: 32px 0 12px; }
306
+ h3.wcag-level-heading { font-size: 14px; margin: 20px 0 8px; }
307
+
308
+ .hero { background: #fff; border: 1px solid #e0e0e0; border-radius: 10px; padding: 20px 24px; margin-bottom: 28px; }
309
+ .hero-headline { font-size: 17px; margin: 0 0 16px; line-height: 1.5; }
310
+ .hero-bar { display: flex; height: 22px; border-radius: 6px; overflow: hidden; background: #eee; }
311
+ .hero-bar-seg { height: 100%; }
312
+ .hero-bar-seg:not(:last-child) { border-right: 2px solid #fff; }
313
+ @media (prefers-color-scheme: dark) { .hero-bar-seg:not(:last-child) { border-right-color: #16181c; } }
314
+ .hero-legend { display: flex; flex-wrap: wrap; gap: 14px 24px; margin-top: 16px; }
315
+ .hero-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; }
316
+ .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; }
317
+ .hero-legend-count { opacity: 0.65; font-variant-numeric: tabular-nums; }
318
+
319
+ .cards { display: grid; gap: 10px; margin-bottom: 8px; }
320
+ .card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 12px 14px; background: #fff; }
321
+ .card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
322
+ .card-title { font-size: 13.5px; }
323
+ .card-count { font-weight: 700; opacity: 0.65; font-variant-numeric: tabular-nums; }
324
+ .card-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
325
+ .card-selector { font-size: 12px; margin-bottom: 6px; word-break: break-all; }
326
+ .card-selector-label { opacity: 0.65; margin-right: 4px; }
327
+ .card-selector code { font-family: ui-monospace, monospace; background: #f0f0f2; border-radius: 4px; padding: 1px 5px; user-select: all; }
328
+ @media (prefers-color-scheme: dark) { .card-selector code { background: #2a2d33 !important; } }
329
+ .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; }
330
+ @media (prefers-color-scheme: dark) { .card-snippet { background: #262a30 !important; } }
331
+ .card-note { font-size: 11px; opacity: 0.6; margin: 6px 0 0; }
332
+
333
+ details.tech-details { border: 1px solid #e0e0e0; border-radius: 8px; margin-top: 8px; }
334
+ details.tech-details summary { padding: 14px 18px; cursor: pointer; font-weight: 600; font-size: 14px; color: #333; list-style: none; }
335
+ details.tech-details summary::-webkit-details-marker { display: none; }
336
+ details.tech-details summary::before { content: '▶'; display: inline-block; margin-right: 8px; font-size: 11px; transition: transform 0.15s; }
337
+ details.tech-details[open] summary::before { transform: rotate(90deg); }
338
+ details.tech-details > .tech-body { padding: 0 18px 20px; }
339
+
340
+ .scorecard { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
341
+ .tile { border: 1.5px solid; border-radius: 8px; padding: 12px 14px; }
342
+ .tile-num { font-size: 24px; font-weight: 700; line-height: 1.1; }
343
+ .tile-pct { font-size: 12px; opacity: 0.75; }
344
+ .tile-label { font-size: 12.5px; margin-top: 4px; font-weight: 600; }
345
+ table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
346
+ .wcag-table, .findings-table { background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; margin-bottom: 8px; }
347
+ .wcag-table th, .wcag-table td, .findings-table th, .findings-table td { padding: 5px 8px; border-bottom: 1px solid #eee; text-align: left; }
348
+ .wcag-table th, .findings-table th { background: #f2f2f4; position: sticky; top: 0; font-size: 11px; }
349
+ .sc-cell { font-family: ui-monospace, monospace; font-size: 11.5px; white-space: nowrap; }
350
+ .filters { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; margin-bottom: 12px; }
351
+ .filters label { display: inline-flex; align-items: center; gap: 4px; font-size: 12.5px; cursor: pointer; user-select: none; }
352
+ .chip { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 10.5px; font-weight: 700; white-space: nowrap; }
353
+ .findings-table td.snippet { font-family: ui-monospace, monospace; font-size: 11px; max-width: 320px; overflow-x: auto; white-space: pre; }
354
+ .pager { display: flex; gap: 10px; align-items: center; margin-top: 10px; font-size: 13px; }
355
+ .pager button { padding: 5px 12px; border: 1px solid #ccc; background: #fff; border-radius: 5px; cursor: pointer; }
356
+ .pager button:disabled { opacity: 0.4; cursor: default; }
357
+ .note { font-size: 12.5px; opacity: 0.75; margin-top: 6px; }
358
+ </style>
359
+ </head>
360
+ <body>
361
+
362
+ <header>
363
+ <h1>${esc(title)}</h1>
364
+ <div class="sub">${esc(result && result.url ? result.url : '(no url)')} — generated ${esc(generatedAtLabel)}</div>
365
+ </header>
366
+
367
+ <div class="meta-bar">
368
+ <div><b>${checksResults.length}</b>rules run</div>
369
+ <div><b>${rows.length}</b>total occurrences</div>
370
+ <div><b>${esc((result && result.engine && result.engine.tag) || '?')}</b>engine</div>
371
+ <div><b>${esc((result && result.engine && result.engine.schemaVersion) || '?')}</b>schema version</div>
372
+ </div>
373
+
374
+ <main>
375
+ ${renderHeroBar(byOutcome)}
376
+
377
+ <h2>Worth reviewing</h2>
378
+ ${renderCards(checksResults)}
379
+
380
+ <h2>WCAG rollup</h2>
381
+ ${renderWcagRollup(rulesResults)}
382
+
383
+ <details class="tech-details">
384
+ <summary>Full technical data — scorecard, searchable occurrence browser</summary>
385
+ <div class="tech-body">
386
+ <h2>Scorecard</h2>
387
+ ${renderScorecard(byOutcome)}
388
+
389
+ <h2>Occurrences</h2>
390
+ <div class="filters" id="filters"></div>
391
+ <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;">
392
+ <div style="overflow-x:auto;">
393
+ <table class="findings-table" id="findings-table">
394
+ <thead>
395
+ <tr>
396
+ <th>Rule</th>
397
+ <th>Outcome</th>
398
+ <th>Severity</th>
399
+ <th>Selector</th>
400
+ <th>Summary</th>
401
+ </tr>
402
+ </thead>
403
+ <tbody id="findings-body"></tbody>
404
+ </table>
405
+ </div>
406
+ <div class="pager">
407
+ <button id="prev-page">&larr; Prev</button>
408
+ <span id="page-info"></span>
409
+ <button id="next-page">Next &rarr;</button>
410
+ </div>
411
+ </div>
412
+ </details>
413
+ </main>
414
+
415
+ <script type="application/json" id="report-data">${jsonForScript(rows)}</script>
416
+ <script>
417
+ (function () {
418
+ var OUTCOME_INFO = ${jsonForScript(OUTCOME_INFO)};
419
+ var ORDER = ${jsonForScript(OUTCOME_ORDER)};
420
+ var DEFAULT_ON = ${jsonForScript(defaultOnList)};
421
+ var rows = JSON.parse(document.getElementById('report-data').textContent);
422
+
423
+ var PAGE_SIZE = 100;
424
+ var page = 0;
425
+ var active = {};
426
+ DEFAULT_ON.forEach(function (c) { active[c] = true; });
427
+
428
+ var filtersEl = document.getElementById('filters');
429
+ ORDER.forEach(function (c) {
430
+ var info = OUTCOME_INFO[c];
431
+ var id = 'chk-' + c;
432
+ var label = document.createElement('label');
433
+ var checked = DEFAULT_ON.indexOf(c) !== -1;
434
+ label.innerHTML = '<input type="checkbox" id="' + id + '"' + (checked ? ' checked' : '') + '> ' +
435
+ '<span class="chip" style="background:' + info.bg + ';color:' + info.color + '">' + c + '</span>';
436
+ filtersEl.appendChild(label);
437
+ label.querySelector('input').addEventListener('change', function (e) {
438
+ active[c] = e.target.checked;
439
+ page = 0;
440
+ render();
441
+ });
442
+ });
443
+
444
+ var searchEl = document.getElementById('search');
445
+ var bodyEl = document.getElementById('findings-body');
446
+ var pageInfoEl = document.getElementById('page-info');
447
+ var prevBtn = document.getElementById('prev-page');
448
+ var nextBtn = document.getElementById('next-page');
449
+
450
+ function esc(s) {
451
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
452
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
453
+ });
454
+ }
455
+
456
+ function matches(r, q) {
457
+ if (!active[r.outcome]) return false;
458
+ if (!q) return true;
459
+ var hay = (r.ruleId + ' ' + r.selector + ' ' + r.html + ' ' + r.summary).toLowerCase();
460
+ return hay.indexOf(q) !== -1;
461
+ }
462
+
463
+ function render() {
464
+ var q = searchEl.value.trim().toLowerCase();
465
+ var filtered = rows.filter(function (r) { return matches(r, q); });
466
+ var totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
467
+ if (page >= totalPages) page = totalPages - 1;
468
+ if (page < 0) page = 0;
469
+
470
+ var slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
471
+
472
+ bodyEl.innerHTML = slice.map(function (r) {
473
+ var info = OUTCOME_INFO[r.outcome];
474
+ var chip = '<span class="chip" style="background:' + info.bg + ';color:' + info.color + '">' + r.outcome + '</span>';
475
+ return '<tr>' +
476
+ '<td class="snippet">' + esc(r.ruleId) + '</td>' +
477
+ '<td>' + chip + '</td>' +
478
+ '<td>' + esc(r.severity) + '</td>' +
479
+ '<td class="snippet" title="' + esc(r.html) + '">' + esc(r.selector) + '</td>' +
480
+ '<td class="snippet">' + esc(r.summary) + '</td>' +
481
+ '</tr>';
482
+ }).join('');
483
+
484
+ pageInfoEl.textContent = filtered.length === 0
485
+ ? 'No matching occurrences'
486
+ : ('Page ' + (page + 1) + ' / ' + totalPages + ' (' + filtered.length.toLocaleString() + ' matching, ' + rows.length.toLocaleString() + ' total)');
487
+ prevBtn.disabled = page <= 0;
488
+ nextBtn.disabled = page >= totalPages - 1;
489
+ }
490
+
491
+ searchEl.addEventListener('input', function () { page = 0; render(); });
492
+ prevBtn.addEventListener('click', function () { page--; render(); });
493
+ nextBtn.addEventListener('click', function () { page++; render(); });
494
+
495
+ render();
496
+ })();
497
+ </script>
498
+ </body>
499
+ </html>`;
500
+ }
501
+
502
+ module.exports = { renderHtmlReport };