@setsquare/review-sdk 0.0.1

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 (181) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +57 -0
  3. package/README.md +338 -0
  4. package/THIRD-PARTY-NOTICES.md +61 -0
  5. package/dist/autoscan.d.ts +25 -0
  6. package/dist/autoscan.d.ts.map +1 -0
  7. package/dist/autoscan.js +332 -0
  8. package/dist/autoscan.js.map +1 -0
  9. package/dist/axe.d.ts +96 -0
  10. package/dist/axe.d.ts.map +1 -0
  11. package/dist/axe.js +210 -0
  12. package/dist/axe.js.map +1 -0
  13. package/dist/bin/setsquare.d.ts +3 -0
  14. package/dist/bin/setsquare.d.ts.map +1 -0
  15. package/dist/bin/setsquare.js +21 -0
  16. package/dist/bin/setsquare.js.map +1 -0
  17. package/dist/brand.d.ts +84 -0
  18. package/dist/brand.d.ts.map +1 -0
  19. package/dist/brand.js +335 -0
  20. package/dist/brand.js.map +1 -0
  21. package/dist/checks/dynamic.d.ts +57 -0
  22. package/dist/checks/dynamic.d.ts.map +1 -0
  23. package/dist/checks/dynamic.js +184 -0
  24. package/dist/checks/dynamic.js.map +1 -0
  25. package/dist/checks/rule-pack.d.ts +34 -0
  26. package/dist/checks/rule-pack.d.ts.map +1 -0
  27. package/dist/checks/rule-pack.js +270 -0
  28. package/dist/checks/rule-pack.js.map +1 -0
  29. package/dist/checks/versions.d.ts +28 -0
  30. package/dist/checks/versions.d.ts.map +1 -0
  31. package/dist/checks/versions.js +45 -0
  32. package/dist/checks/versions.js.map +1 -0
  33. package/dist/ci.d.ts +37 -0
  34. package/dist/ci.d.ts.map +1 -0
  35. package/dist/ci.js +199 -0
  36. package/dist/ci.js.map +1 -0
  37. package/dist/contract.d.ts +334 -0
  38. package/dist/contract.d.ts.map +1 -0
  39. package/dist/contract.js +35 -0
  40. package/dist/contract.js.map +1 -0
  41. package/dist/dedupe.d.ts +79 -0
  42. package/dist/dedupe.d.ts.map +1 -0
  43. package/dist/dedupe.js +102 -0
  44. package/dist/dedupe.js.map +1 -0
  45. package/dist/document.d.ts +29 -0
  46. package/dist/document.d.ts.map +1 -0
  47. package/dist/document.js +94 -0
  48. package/dist/document.js.map +1 -0
  49. package/dist/envelope.d.ts +124 -0
  50. package/dist/envelope.d.ts.map +1 -0
  51. package/dist/envelope.js +337 -0
  52. package/dist/envelope.js.map +1 -0
  53. package/dist/escape.d.ts +14 -0
  54. package/dist/escape.d.ts.map +1 -0
  55. package/dist/escape.js +20 -0
  56. package/dist/escape.js.map +1 -0
  57. package/dist/findings.d.ts +47 -0
  58. package/dist/findings.d.ts.map +1 -0
  59. package/dist/findings.js +229 -0
  60. package/dist/findings.js.map +1 -0
  61. package/dist/fixture.d.ts +7 -0
  62. package/dist/fixture.d.ts.map +1 -0
  63. package/dist/fixture.js +237 -0
  64. package/dist/fixture.js.map +1 -0
  65. package/dist/group.d.ts +214 -0
  66. package/dist/group.d.ts.map +1 -0
  67. package/dist/group.js +709 -0
  68. package/dist/group.js.map +1 -0
  69. package/dist/html-report.d.ts +68 -0
  70. package/dist/html-report.d.ts.map +1 -0
  71. package/dist/html-report.js +649 -0
  72. package/dist/html-report.js.map +1 -0
  73. package/dist/human.d.ts +65 -0
  74. package/dist/human.d.ts.map +1 -0
  75. package/dist/human.js +111 -0
  76. package/dist/human.js.map +1 -0
  77. package/dist/index.d.ts +34 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +33 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/infer.d.ts +65 -0
  82. package/dist/infer.d.ts.map +1 -0
  83. package/dist/infer.js +67 -0
  84. package/dist/infer.js.map +1 -0
  85. package/dist/insights.d.ts +140 -0
  86. package/dist/insights.d.ts.map +1 -0
  87. package/dist/insights.js +483 -0
  88. package/dist/insights.js.map +1 -0
  89. package/dist/install.d.ts +8 -0
  90. package/dist/install.d.ts.map +1 -0
  91. package/dist/install.js +114 -0
  92. package/dist/install.js.map +1 -0
  93. package/dist/links.d.ts +45 -0
  94. package/dist/links.d.ts.map +1 -0
  95. package/dist/links.js +45 -0
  96. package/dist/links.js.map +1 -0
  97. package/dist/mask.d.ts +85 -0
  98. package/dist/mask.d.ts.map +1 -0
  99. package/dist/mask.js +98 -0
  100. package/dist/mask.js.map +1 -0
  101. package/dist/observe.d.ts +51 -0
  102. package/dist/observe.d.ts.map +1 -0
  103. package/dist/observe.js +233 -0
  104. package/dist/observe.js.map +1 -0
  105. package/dist/options.d.ts +249 -0
  106. package/dist/options.d.ts.map +1 -0
  107. package/dist/options.js +185 -0
  108. package/dist/options.js.map +1 -0
  109. package/dist/output.d.ts +113 -0
  110. package/dist/output.d.ts.map +1 -0
  111. package/dist/output.js +231 -0
  112. package/dist/output.js.map +1 -0
  113. package/dist/perf.d.ts +11 -0
  114. package/dist/perf.d.ts.map +1 -0
  115. package/dist/perf.js +150 -0
  116. package/dist/perf.js.map +1 -0
  117. package/dist/reading-level.d.ts +73 -0
  118. package/dist/reading-level.d.ts.map +1 -0
  119. package/dist/reading-level.js +189 -0
  120. package/dist/reading-level.js.map +1 -0
  121. package/dist/reporter.d.ts +94 -0
  122. package/dist/reporter.d.ts.map +1 -0
  123. package/dist/reporter.js +709 -0
  124. package/dist/reporter.js.map +1 -0
  125. package/dist/rules.d.ts +55 -0
  126. package/dist/rules.d.ts.map +1 -0
  127. package/dist/rules.js +77 -0
  128. package/dist/rules.js.map +1 -0
  129. package/dist/runtime.d.ts +32 -0
  130. package/dist/runtime.d.ts.map +1 -0
  131. package/dist/runtime.js +67 -0
  132. package/dist/runtime.js.map +1 -0
  133. package/dist/sanitise.d.ts +116 -0
  134. package/dist/sanitise.d.ts.map +1 -0
  135. package/dist/sanitise.js +247 -0
  136. package/dist/sanitise.js.map +1 -0
  137. package/dist/scan.d.ts +60 -0
  138. package/dist/scan.d.ts.map +1 -0
  139. package/dist/scan.js +246 -0
  140. package/dist/scan.js.map +1 -0
  141. package/dist/screens.d.ts +98 -0
  142. package/dist/screens.d.ts.map +1 -0
  143. package/dist/screens.js +301 -0
  144. package/dist/screens.js.map +1 -0
  145. package/dist/settle.d.ts +65 -0
  146. package/dist/settle.d.ts.map +1 -0
  147. package/dist/settle.js +162 -0
  148. package/dist/settle.js.map +1 -0
  149. package/dist/shots.d.ts +109 -0
  150. package/dist/shots.d.ts.map +1 -0
  151. package/dist/shots.js +113 -0
  152. package/dist/shots.js.map +1 -0
  153. package/dist/style.d.ts +47 -0
  154. package/dist/style.d.ts.map +1 -0
  155. package/dist/style.js +154 -0
  156. package/dist/style.js.map +1 -0
  157. package/dist/suite.d.ts +218 -0
  158. package/dist/suite.d.ts.map +1 -0
  159. package/dist/suite.js +219 -0
  160. package/dist/suite.js.map +1 -0
  161. package/dist/summary.d.ts +268 -0
  162. package/dist/summary.d.ts.map +1 -0
  163. package/dist/summary.js +1308 -0
  164. package/dist/summary.js.map +1 -0
  165. package/dist/unscannable.d.ts +75 -0
  166. package/dist/unscannable.d.ts.map +1 -0
  167. package/dist/unscannable.js +111 -0
  168. package/dist/unscannable.js.map +1 -0
  169. package/dist/versions.d.ts +13 -0
  170. package/dist/versions.d.ts.map +1 -0
  171. package/dist/versions.js +39 -0
  172. package/dist/versions.js.map +1 -0
  173. package/dist/wcag-map.d.ts +19 -0
  174. package/dist/wcag-map.d.ts.map +1 -0
  175. package/dist/wcag-map.js +52 -0
  176. package/dist/wcag-map.js.map +1 -0
  177. package/dist/with-setsquare.d.ts +26 -0
  178. package/dist/with-setsquare.d.ts.map +1 -0
  179. package/dist/with-setsquare.js +70 -0
  180. package/dist/with-setsquare.js.map +1 -0
  181. package/package.json +81 -0
@@ -0,0 +1,649 @@
1
+ import { BRAND_CSS, MASTHEAD_CSS, masthead } from './brand.js';
2
+ import { esc } from './escape.js';
3
+ import { humanInstant, shortCommit } from './human.js';
4
+ import { insightBlocks } from './insights.js';
5
+ import { renderScreensSection, SCREENS_CSS } from './screens.js';
6
+ import { conformanceGrid, conformanceSummary, ruleSurfaceShare, stateCoverage, } from '@setsquare/schema/conformance';
7
+ import { evidenceQuality, groupFindings, perfRows, surfaceRows, testOutcomes, totals, } from './summary.js';
8
+ const STATE_WORD = {
9
+ failed: 'FAIL',
10
+ cantTell: 'CHECK',
11
+ rulesPassed: 'OK',
12
+ inapplicable: 'N/A',
13
+ untested: '—',
14
+ };
15
+ const STATE_LABEL = {
16
+ failed: 'failed',
17
+ cantTell: 'needs a person',
18
+ rulesPassed: 'every automated rule passed',
19
+ inapplicable: 'checks ran; nothing to check on this surface',
20
+ untested: 'untested',
21
+ };
22
+ function n(value) {
23
+ return value.toLocaleString('en-GB');
24
+ }
25
+ function percent(affected, total) {
26
+ if (total === 0 || affected === 0)
27
+ return '0%';
28
+ const rounded = Math.round((affected / total) * 100);
29
+ return rounded === 0 ? '<1%' : `${rounded}%`;
30
+ }
31
+ /** Why a merged group is partial, in one plain sentence. */
32
+ function partialReason(group) {
33
+ if (group.missing.length > 0) {
34
+ return `${group.missing.length} invocation${group.missing.length === 1 ? '' : 's'} of run group “${group.id}” began but ${group.missing.length === 1 ? 'was' : 'were'} never merged.`;
35
+ }
36
+ if (group.stale.length > 0) {
37
+ return `${group.stale.length} invocation${group.stale.length === 1 ? '' : 's'} of run group “${group.id}” ${group.stale.length === 1 ? 'was' : 'were'} excluded as stale.`;
38
+ }
39
+ if (group.expectedParts !== null && group.completed !== group.expectedParts) {
40
+ return `${group.completed} of ${group.expectedParts} declared invocations of run group “${group.id}” have merged.`;
41
+ }
42
+ return `An invocation of run group “${group.id}” was cut short before it finished.`;
43
+ }
44
+ /** `href` is allowed only for URLs that came out of the envelope itself. */
45
+ function helpLink(url) {
46
+ if (!url || !url.startsWith('https://'))
47
+ return '';
48
+ return ` <a href="${esc(url)}" rel="noopener">rule guidance</a>`;
49
+ }
50
+ export function renderHtmlReport(input) {
51
+ /*
52
+ * The P1b blocks, from the same wording functions the terminal uses.
53
+ *
54
+ * A definition list rather than a table: these are figure/label pairs with
55
+ * prose attached, not tabular data, and a table would promise columns that do
56
+ * not line up across blocks. Each block's notes carry the caveats that stop a
57
+ * reader drawing the wrong conclusion — "counts are per engine", "time spent
58
+ * not time added" — so they are rendered, never dropped as decoration.
59
+ */
60
+ const insightSections = insightBlocks(input.envelope, {
61
+ ...(input.declaredAxes ? { declaredAxes: input.declaredAxes } : {}),
62
+ ...(input.unmeasuredAxes ? { unmeasuredAxes: input.unmeasuredAxes } : {}),
63
+ ...(input.suite ? { suite: input.suite } : {}),
64
+ ...(input.overhead ? { overhead: input.overhead } : {}),
65
+ })
66
+ .map((block, index) => {
67
+ const id = `h-insight-${index}`;
68
+ const rows = block.figures
69
+ .map((figure) => ` <div><dt>${esc(String(figure.value))}</dt>` +
70
+ `<dd>${esc(figure.label)}` +
71
+ (figure.notes && figure.notes.length > 0
72
+ ? `<p class="muted">${figure.notes.map((note) => esc(note)).join('<br />')}</p>`
73
+ : '') +
74
+ `</dd></div>`)
75
+ .join('\n');
76
+ const notes = block.notes.map((note) => ` <p class="muted">${esc(note)}</p>`).join('\n');
77
+ return `
78
+ <section aria-labelledby="${id}">
79
+ <h2 id="${id}">${esc(block.heading)}</h2>
80
+ <dl class="insight">
81
+ ${rows}
82
+ </dl>
83
+ ${notes}
84
+ </section>`;
85
+ })
86
+ .join('\n');
87
+ const { envelope } = input;
88
+ const summary = conformanceSummary(envelope, {
89
+ ...(input.criterionRules ? { criterionRules: input.criterionRules } : {}),
90
+ });
91
+ const grid = conformanceGrid(envelope);
92
+ const count = totals(envelope);
93
+ const tests = testOutcomes(envelope);
94
+ const quality = evidenceQuality(envelope);
95
+ const groups = groupFindings(envelope);
96
+ const rows = surfaceRows(envelope);
97
+ const perf = perfRows(envelope);
98
+ const failingRules = new Set(envelope.scans.flatMap((scan) => scan.findings.filter((f) => f.status === 'violation').map((f) => f.ruleId))).size;
99
+ const verdictLine = () => {
100
+ if (count.scans === 0) {
101
+ return 'Nothing was scanned. This is not a clean result — it means nothing was looked at.';
102
+ }
103
+ const { verdict } = summary;
104
+ if (verdict.failuresDetectedAt) {
105
+ const fixes = verdict.nextFixes.map((r) => `${r.criterion} ${r.name}`).join(', ');
106
+ return (`Failures detected at Level ${verdict.failuresDetectedAt}. ` +
107
+ `To clear them, fix: ${esc(fixes)}. Automated checks cover only part of WCAG.`);
108
+ }
109
+ if (verdict.noFailuresDetectedAt) {
110
+ return (`No Level ${verdict.noFailuresDetectedAt} violations detected across ` +
111
+ `${n(summary.surfacesScanned)} surface${summary.surfacesScanned === 1 ? '' : 's'} (automated checks only).`);
112
+ }
113
+ return 'No conformance evidence in this run.';
114
+ };
115
+ const ladder = summary.levels
116
+ .map((level) => {
117
+ let state;
118
+ let cls;
119
+ if (!level.assessed) {
120
+ state = 'not assessed';
121
+ cls = 'muted';
122
+ }
123
+ else if (level.failing.length > 0) {
124
+ state = `failures detected — ${level.failing.length === 1 ? 'one criterion' : `${level.failing.length} criteria`}`;
125
+ cls = 'bad';
126
+ }
127
+ else {
128
+ state = 'no failures detected in what was scanned';
129
+ cls = 'good';
130
+ }
131
+ const scans = level.assessedScans
132
+ ? ` <span class="muted">(assessed on ${level.assessedScans.aaa} of ${level.assessedScans.total} scans)</span>`
133
+ : '';
134
+ return `<li><span class="level">${level.level}</span> <span class="${cls}">${state}</span>${scans}</li>`;
135
+ })
136
+ .join('\n ');
137
+ /*
138
+ * The WCAG-EM-shaped scope statement: what was scanned, in which states, when,
139
+ * by what. The first page of any audit, and what lets a person staple this
140
+ * behind a manual sample instead of arguing with it.
141
+ */
142
+ const coverageRows = stateCoverage(envelope);
143
+ const scopeRows = coverageRows
144
+ .map((row) => `<tr><th scope="row">${esc(row.surface)}</th><td>${row.states.map(esc).join(', ')}${row.defaultOnly ? ' <span class="muted">(default state only)</span>' : ''}</td></tr>`)
145
+ .join('\n ');
146
+ const defaultOnly = coverageRows.filter((row) => row.defaultOnly).length;
147
+ const stateNote = defaultOnly > 0 && coverageRows.length > 0
148
+ ? `<p class="muted">${defaultOnly} of ${coverageRows.length} surfaces have evidence in their default
149
+ state only. Error, loading and empty states are where regressions hide —
150
+ <code>setsquare.scan(page, surface, { state })</code> captures them.</p>`
151
+ : '';
152
+ const targets = [...new Set(envelope.scans.map((scan) => scan.wcagTarget))].sort().join(', ') || 'AA';
153
+ /* The grid: criteria × surfaces, real table, state words in every cell. */
154
+ const gridHead = grid.surfaces
155
+ .map((surface, i) => `<th scope="col"><span class="idx">${i + 1}</span> ${esc(surface)}</th>`)
156
+ .join('');
157
+ const gridBody = grid.rows
158
+ .map((row) => {
159
+ const cells = row.cells
160
+ .map((cell) => `<td class="s-${cell.state}">${STATE_WORD[cell.state]}</td>`)
161
+ .join('');
162
+ const removed = row.removedIn22
163
+ ? ' <span class="muted">(removed in WCAG 2.2 — counts toward no level)</span>'
164
+ : '';
165
+ return `<tr><th scope="row">${row.criterion} ${esc(row.name)} <span class="level">${row.removedIn22 ? '—' : row.level}</span>${removed}</th>${cells}</tr>`;
166
+ })
167
+ .join('\n ');
168
+ const rollupDetail = (rollup) => {
169
+ const parts = [];
170
+ if (rollup.failedSurfaces.length > 0) {
171
+ parts.push(`<p><strong>Failed</strong> on ${rollup.failedSurfaces.length} of ${rollup.totalSurfaces} surfaces ` +
172
+ `(${percent(rollup.failedSurfaces.length, rollup.totalSurfaces)}): ${rollup.failedSurfaces.map(esc).join(', ')}</p>`);
173
+ }
174
+ if (rollup.cantTellSurfaces.length > 0) {
175
+ parts.push(`<p><strong>Needs a person</strong> on: ${rollup.cantTellSurfaces.map(esc).join(', ')}</p>`);
176
+ }
177
+ if (rollup.rulesPassedSurfaces.length > 0) {
178
+ parts.push(`<p><strong>Every automated rule passed</strong> on: ${rollup.rulesPassedSurfaces.map(esc).join(', ')}</p>`);
179
+ }
180
+ if (rollup.inapplicableSurfaces.length > 0) {
181
+ parts.push(`<p><strong>Nothing to check</strong> (checks ran, no such content) on: ${rollup.inapplicableSurfaces.map(esc).join(', ')}</p>`);
182
+ }
183
+ if (rollup.failingRules.length > 0) {
184
+ parts.push(`<p>Failing rules: <code>${rollup.failingRules.map(esc).join('</code>, <code>')}</code></p>`);
185
+ }
186
+ const passing = rollup.rules.filter((rule) => !rollup.failingRules.includes(rule));
187
+ if (passing.length > 0) {
188
+ parts.push(`<p class="muted">Other rules with evidence here: <code>${passing.map(esc).join('</code>, <code>')}</code></p>`);
189
+ }
190
+ return parts.join('\n ');
191
+ };
192
+ /*
193
+ * Grouped by outcome, not by criterion number.
194
+ *
195
+ * Numeric order put a failure at 1.4.3 twenty rows below a pass at 1.1.1, so
196
+ * reading the failures meant reading everything. Nobody does that. The order
197
+ * below is the order people actually care in — what is broken, what needs a
198
+ * human, what held, what did not apply — with numeric order kept INSIDE each
199
+ * group so a known criterion is still findable.
200
+ *
201
+ * Criteria removed in 2.2 come last: they count toward no level, so they are
202
+ * the least actionable thing on the page.
203
+ */
204
+ const byNumber = (a, b) => a.criterion.localeCompare(b.criterion, 'en', { numeric: true });
205
+ const outcomeGroups = [
206
+ {
207
+ heading: 'Failures',
208
+ note: 'An automated check failed on at least one surface.',
209
+ rollups: summary.levels.flatMap((level) => level.failing).sort(byNumber),
210
+ },
211
+ {
212
+ heading: 'Needs a person',
213
+ note: 'A check ran and could not decide. These are not passes.',
214
+ rollups: summary.levels.flatMap((level) => level.cantTell).sort(byNumber),
215
+ },
216
+ {
217
+ heading: 'Every automated rule passed',
218
+ note: 'Evidence that the automated rules held here — not criterion conformance.',
219
+ rollups: summary.levels.flatMap((level) => level.rulesPassed).sort(byNumber),
220
+ },
221
+ {
222
+ heading: 'Nothing to check',
223
+ note: 'The checks ran and these pages contain nothing the criterion regulates.',
224
+ rollups: summary.levels.flatMap((level) => level.inapplicable).sort(byNumber),
225
+ },
226
+ {
227
+ heading: 'Removed in WCAG 2.2',
228
+ note: 'Kept for the record. These count toward no level.',
229
+ rollups: [...summary.removed].sort(byNumber),
230
+ },
231
+ ];
232
+ const criterionDetail = (rollup) => `
233
+ <details${rollup.state === 'failed' ? ' open' : ''}>
234
+ <summary><span class="s-${rollup.state} pill">${STATE_WORD[rollup.state]}</span> ${rollup.criterion} ${esc(rollup.name)} <span class="level">${rollup.removedIn22 ? 'removed in 2.2' : `Level ${rollup.level}`}</span></summary>
235
+ <div>
236
+ ${rollupDetail(rollup)}
237
+ </div>
238
+ </details>`;
239
+ const criteriaDetails = outcomeGroups
240
+ .filter((group) => group.rollups.length > 0)
241
+ .map((group) => `
242
+ <h3 class="outcome">${group.heading} <span class="count">${n(group.rollups.length)}</span></h3>
243
+ <p class="muted outcome-note">${group.note}</p>
244
+ ${group.rollups.map(criterionDetail).join('\n')}`)
245
+ .join('\n');
246
+ /* Coverage ledger, with the notRun/notAutomatable split when the map is present. */
247
+ const countedLevels = summary.levels.filter((level) => level.level !== 'AAA' || level.assessed);
248
+ const rulesPassedCount = countedLevels.reduce((sum, level) => sum + level.rulesPassed.length, 0);
249
+ const cantTellCount = countedLevels.reduce((sum, level) => sum + level.cantTell.length, 0);
250
+ const inapplicableRollups = countedLevels.flatMap((level) => level.inapplicable);
251
+ const untested = countedLevels.flatMap((level) => level.untested);
252
+ const coverage = [];
253
+ if (rulesPassedCount > 0) {
254
+ coverage.push(`<li><strong>${rulesPassedCount}</strong> criteria: every automated rule passed — evidence, not criterion conformance</li>`);
255
+ }
256
+ if (cantTellCount > 0)
257
+ coverage.push(`<li><strong>${cantTellCount}</strong> criteria need a person</li>`);
258
+ if (inapplicableRollups.length > 0) {
259
+ coverage.push(`<li><strong>${inapplicableRollups.length}</strong> criteria: checks ran, nothing to check on these pages — ` +
260
+ `${inapplicableRollups.map((r) => r.criterion).join(', ')}</li>`);
261
+ }
262
+ if (untested.length > 0) {
263
+ if (input.criterionRules) {
264
+ const noCheck = untested.filter((r) => r.untestedReason === 'notAutomatable');
265
+ const notRun = untested.filter((r) => r.untestedReason === 'notRun');
266
+ if (noCheck.length > 0) {
267
+ coverage.push(`<li><strong>${noCheck.length}</strong> criteria have no automated check in this engine: ` +
268
+ `${noCheck.map((r) => r.criterion).join(', ')}</li>`);
269
+ }
270
+ if (notRun.length > 0) {
271
+ coverage.push(`<li><strong>${notRun.length}</strong> criteria have checks that did not run here: ` +
272
+ `${notRun.map((r) => r.criterion).join(', ')}</li>`);
273
+ }
274
+ }
275
+ else {
276
+ coverage.push(`<li><strong>${untested.length}</strong> criteria not automatically checked in this run</li>`);
277
+ }
278
+ }
279
+ const surfacesBody = rows
280
+ .map((row) => {
281
+ const state = row.violations > 0 ? 'failed' : row.incomplete > 0 ? 'cantTell' : 'rulesPassed';
282
+ const word = row.violations > 0 ? 'FAIL' : row.incomplete > 0 ? 'CHECK' : 'pass';
283
+ return (`<tr><th scope="row">${esc(row.label)}</th>` +
284
+ `<td class="s-${state}">${word}</td>` +
285
+ `<td class="num">${n(row.violations)}</td>` +
286
+ `<td class="num">${n(row.incomplete)}</td>` +
287
+ `<td>${row.rules.map((rule) => `<code>${esc(rule)}</code>`).join(', ')}</td></tr>`);
288
+ })
289
+ .join('\n ');
290
+ const ruleSections = (status) => groups
291
+ .filter((group) => group.status === status)
292
+ .map((group) => {
293
+ const share = ruleSurfaceShare(envelope, group.ruleId);
294
+ const spread = status === 'violation'
295
+ ? `on ${share.affected} of ${share.total} surfaces (${percent(share.affected, share.total)})`
296
+ : `on ${group.contexts.length} surface${group.contexts.length === 1 ? '' : 's'}`;
297
+ return `
298
+ <details>
299
+ <summary><code>${esc(group.ruleId)}</code> — ${esc(group.impact)}, ${n(group.nodes)} element${group.nodes === 1 ? '' : 's'} ${spread}${group.criteria.length > 0 ? ` — WCAG ${group.criteria.map(esc).join(', ')}` : ''}</summary>
300
+ <div>
301
+ ${group.failureSummary ? `<p>${esc(group.failureSummary)}</p>` : ''}
302
+ ${group.selectors.length > 0 ? `<p>Where: <code>${group.selectors.map(esc).join('</code>, <code>')}</code>${group.selectorsTruncated ? ' <span class="muted">(sample — every selector is in the embedded run data)</span>' : ''}</p>` : ''}
303
+ <p>${esc(group.contexts.join(', '))}${helpLink(group.helpUrl)}</p>
304
+ </div>
305
+ </details>`;
306
+ })
307
+ .join('\n');
308
+ const perfBody = perf
309
+ .map((row) => `<tr><th scope="row">${esc(row.label)}</th>` +
310
+ `<td class="num">${row.lcpMs !== undefined ? `${n(Math.round(row.lcpMs))}ms` : '—'}</td>` +
311
+ `<td class="num">${row.cls !== undefined ? row.cls.toFixed(2) : '—'}</td>` +
312
+ `<td class="num">${row.loadMs !== undefined ? `${n(Math.round(row.loadMs))}ms` : '—'}</td>` +
313
+ `<td class="num">${row.ttfbMs !== undefined ? `${n(Math.round(row.ttfbMs))}ms` : '—'}</td></tr>`)
314
+ .join('\n ');
315
+ const testsLine = envelope.results.length > 0
316
+ ? `Playwright: ${tests.passed} passed` +
317
+ (tests.failed > 0 ? `, ${tests.failed} failed` : '') +
318
+ (tests.flaky > 0 ? `, ${tests.flaky} flaky` : '') +
319
+ (tests.skipped > 0 ? `, ${tests.skipped} skipped` : '') +
320
+ '.'
321
+ : '';
322
+ /*
323
+ * The group banner comes before every number in the document: a report over
324
+ * a half-merged group that leads with its conformance verdict is a report
325
+ * that will be quoted without the half that changes what it means.
326
+ */
327
+ const group = input.group;
328
+ const groupBanner = group
329
+ ? group.coverage === 'complete'
330
+ ? ''
331
+ : `
332
+ <p class="verdict ${group.coverage === 'partial' ? 'bad' : ''}">${group.coverage === 'partial'
333
+ ? `<strong>Partial run.</strong> ${esc(partialReason(group))} Evidence from what is missing is absent, not clean — do not treat this artifact as a baseline.`
334
+ : `<strong>Coverage unconfirmed.</strong> ${n(group.completed)} invocation${group.completed === 1 ? '' : 's'} of run group “${esc(group.id)}” merged; whether more were intended was not declared (set <code>SETSQUARE_RUN_PARTS</code>). Treat this artifact as current, not necessarily final.`}</p>`
335
+ : '';
336
+ const groupSection = group
337
+ ? `
338
+ <section aria-labelledby="h-group">
339
+ <h2 id="h-group">Run group</h2>
340
+ <p class="muted">This is the merged evidence of run group “${esc(group.id)}” — one logical run spanning
341
+ ${n(group.completed)} Playwright invocation${group.completed === 1 ? '' : 's'}${group.expectedParts !== null ? ` of ${n(group.expectedParts)} declared` : ''}. Test ids are prefixed with each invocation's label.</p>
342
+ <div class="tablewrap">
343
+ <table>
344
+ <thead><tr><th scope="col">Invocation</th><th scope="col">Lane</th><th scope="col">Started</th><th scope="col">Duration</th><th scope="col">Playwright</th><th scope="col">Tests</th><th scope="col">Scans</th></tr></thead>
345
+ <tbody>
346
+ ${group.parts
347
+ .map((part) => `<tr><th scope="row">${esc(part.label)}</th><td>${esc(part.lane ?? '—')}</td><td>${esc(part.startedAt)}</td><td class="num">${n(Math.round(part.durationMs / 1000))}s</td><td>${esc(part.playwrightStatus)}</td><td class="num">${n(part.tests)}</td><td class="num">${n(part.scans)}</td></tr>`)
348
+ .join('\n ')}
349
+ </tbody>
350
+ </table>
351
+ </div>${group.missing.length > 0
352
+ ? `
353
+ <p class="bad">${n(group.missing.length)} invocation${group.missing.length === 1 ? '' : 's'} began but ${group.missing.length === 1 ? 'was' : 'were'} never merged: <code>${group.missing.map(esc).join('</code>, <code>')}</code>.</p>`
354
+ : ''}
355
+ </section>`
356
+ : '';
357
+ /*
358
+ * The screens block sits after the evidence and before the provenance: it is
359
+ * the part of this document a reviewer scans rather than reads, and putting it
360
+ * above the conformance verdict would let a page of pictures answer a question
361
+ * about WCAG that pictures cannot answer.
362
+ */
363
+ const screensSection = input.screens && input.screens.rows.length > 0
364
+ ? `\n${renderScreensSection(input.screens)}`
365
+ : '';
366
+ const warningsBlock = input.warnings.length > 0
367
+ ? `
368
+ <section aria-labelledby="h-warnings">
369
+ <h2 id="h-warnings">Worth knowing</h2>
370
+ <ul>
371
+ ${input.warnings.map((warning) => `<li>${esc(warning)}</li>`).join('\n ')}
372
+ </ul>
373
+ </section>`
374
+ : '';
375
+ // `</script>`-proofing: `<` never appears raw inside the data block.
376
+ const embedded = JSON.stringify(envelope).replaceAll('<', '\\u003c');
377
+ return `<!doctype html>
378
+ <html lang="en">
379
+ <head>
380
+ <meta charset="utf-8">
381
+ <meta name="viewport" content="width=device-width, initial-scale=1">
382
+ <meta name="color-scheme" content="light dark">
383
+ <title>Setsquare — accessibility evidence, ${esc(humanInstant(envelope.generatedAt))}</title>
384
+ <style>
385
+ ${BRAND_CSS}
386
+ /*
387
+ * The page's own names, mapped onto brand roles rather than renaming every
388
+ * rule below: one block to read, one place to change.
389
+ *
390
+ * The status roles lost their tinted BACKGROUNDS in the process, deliberately.
391
+ * tokens.css verifies each status colour as text on a surface and as a fill
392
+ * under its own ink pair; it has no tints, and inventing a pale red would mean
393
+ * shipping a contrast ratio nobody measured into an accessibility report. So a
394
+ * failing cell is bold danger-coloured TEXT, and the solid fill is kept for the
395
+ * pills, where role-under-its-own-ink is a measured pair.
396
+ *
397
+ * The faint --none-fg collapses into --ss-ink-muted: the palette has no second,
398
+ * fainter grey that clears 7:1, and the untested glyph is already distinct by
399
+ * content.
400
+ *
401
+ * (No backticks in this comment. It sits inside a template literal, so one
402
+ * would end the string and the parse error would surface pages away.)
403
+ */
404
+ :root {
405
+ --bg: var(--ss-surface); --fg: var(--ss-ink); --muted: var(--ss-ink-muted);
406
+ --line: var(--ss-border); --code-bg: var(--ss-surface-raised);
407
+ --bad-fg: var(--ss-danger); --warn-fg: var(--ss-warning); --good-fg: var(--ss-success);
408
+ --none-fg: var(--ss-ink-muted);
409
+ }
410
+ ${MASTHEAD_CSS}
411
+ * { box-sizing: border-box; }
412
+ body {
413
+ margin: 0 auto; padding: 2rem 1.5rem 4rem; max-width: 72rem;
414
+ background: var(--bg); color: var(--fg);
415
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
416
+ }
417
+ h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
418
+ h2 { font-size: 1.2rem; margin: 2.5rem 0 .75rem; }
419
+ a { color: inherit; }
420
+ :focus-visible { outline: 3px solid var(--fg); outline-offset: 2px; }
421
+ code { background: var(--code-bg); padding: .1em .35em; border-radius: 4px; font-size: .875em; overflow-wrap: anywhere; }
422
+ .muted { color: var(--muted); }
423
+ .verdict { font-size: 1.1rem; margin: 1rem 0; padding: 1rem; border: 1px solid var(--line); border-radius: 8px; }
424
+ ul.ladder { list-style: none; padding: 0; margin: .5rem 0; }
425
+ ul.ladder li { padding: .2rem 0; }
426
+ .level { display: inline-block; min-width: 3ch; font-weight: 700; }
427
+ .good { color: var(--good-fg); }
428
+ .bad { color: var(--bad-fg); font-weight: 600; }
429
+ .figures { display: flex; flex-wrap: wrap; gap: 1.5rem; margin: 1.5rem 0; }
430
+ .figures div { border: 1px solid var(--line); border-radius: 8px; padding: .75rem 1rem; }
431
+ .figures strong { display: block; font-size: 1.4rem; }
432
+ /*
433
+ * The P1b insight blocks. A definition list, not the .figures stat strip:
434
+ * these rows carry prose under each figure, and a flex row of boxes would
435
+ * squeeze the caveats into columns nobody reads. The caveats are the point.
436
+ */
437
+ .insight { margin: 1rem 0; }
438
+ .insight > div { display: grid; grid-template-columns: 5rem 1fr; gap: .25rem 1rem; padding: .5rem 0; border-top: 1px solid var(--line); }
439
+ .insight > div:first-child { border-top: 0; }
440
+ .insight dt { font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
441
+ .insight dd { margin: 0; }
442
+ .insight dd p { margin: .25rem 0 0; }
443
+ @media (max-width: 34rem) {
444
+ /* One column below the fold width: a 5rem gutter beside wrapping prose stops
445
+ being a gutter and starts being a squeeze. */
446
+ .insight > div { grid-template-columns: 1fr; }
447
+ .insight dt { text-align: left; }
448
+ }
449
+ .tablewrap { overflow-x: auto; }
450
+ table { border-collapse: collapse; width: 100%; margin: .75rem 0; }
451
+ th, td { border: 1px solid var(--line); padding: .4rem .6rem; text-align: left; vertical-align: top; }
452
+
453
+ /*
454
+ * Row banding, and the sticky criterion column.
455
+ *
456
+ * Neither is required by WCAG. Rows are already delineated by a 1px border in
457
+ * --ss-border, which tokens.css measures at 3.00:1 and is therefore at the
458
+ * 1.4.11 non-text-contrast threshold; and state is carried by the WORD in each
459
+ * cell, not by its colour, which is what 1.4.1 actually asks. Both of these are
460
+ * readability, and they are worth doing anyway.
461
+ *
462
+ * The bands are the two surfaces the palette already verifies, alternating —
463
+ * no new colour, so nothing new to measure. Light bands #ffffff against
464
+ * #f2f2f2, dark #0a0a0a against #1a1a1a, and tokens.css records each theme's
465
+ * ratios against its WORSE case, so the better surface cannot fail: ink is
466
+ * 18.76:1 / 17.40:1 and every status colour clears 7:1 on both.
467
+ *
468
+ * NB: no backticks anywhere in this style block — it is inside a template
469
+ * literal, so one ends the string and the parse error lands pages away.
470
+ *
471
+ * The band travels as a custom property rather than a background on the row,
472
+ * because the sticky cell has to paint the SAME band or it shows the wrong
473
+ * colour the moment it detaches.
474
+ */
475
+ tr { --row-bg: var(--ss-surface); }
476
+ tbody tr:nth-child(even) { --row-bg: var(--ss-surface-raised); }
477
+ th, td { background: var(--row-bg); }
478
+
479
+ /*
480
+ * The grid is sixteen surfaces wide inside a horizontal scroller, and what you
481
+ * lose scrolling right is not which row you are on — the borders handle that —
482
+ * it is WHICH CRITERION, because the row header has left the viewport. Pinning
483
+ * it is the difference between a readable table and a wall of FAIL.
484
+ *
485
+ * A box-shadow draws the right edge rather than border-right: under
486
+ * border-collapse: collapse the border belongs to the table, not the cell, so
487
+ * it does not travel with a sticky cell and the pinned column loses its edge.
488
+ */
489
+ .grid th[scope="row"], .grid thead th:first-child {
490
+ position: sticky; left: 0; z-index: 1;
491
+ box-shadow: 1px 0 0 var(--line);
492
+ }
493
+ /*
494
+ * The header cell sits above the body cells it shares a column with, so that
495
+ * when the table is ALSO scrolled down the two never fight over the corner.
496
+ * Pinning the row headers without pinning this one was the first version, and
497
+ * it looked broken in exactly the way you would expect: the surface headers
498
+ * slid underneath the criterion labels.
499
+ */
500
+ .grid thead th:first-child { z-index: 2; }
501
+ th[scope="col"] .idx { color: var(--muted); font-weight: 400; }
502
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
503
+ td.s-failed { color: var(--bad-fg); font-weight: 700; }
504
+ td.s-cantTell { color: var(--warn-fg); font-weight: 600; }
505
+ td.s-rulesPassed { color: var(--good-fg); }
506
+ td.s-inapplicable { color: var(--muted); }
507
+ /* Pills are the one solid fill: a role under its own -ink is a measured pair. */
508
+ .pill.s-failed { background: var(--ss-danger); color: var(--ss-danger-ink); font-weight: 700; }
509
+ .pill.s-cantTell { background: var(--ss-warning); color: var(--ss-warning-ink); font-weight: 600; }
510
+ .pill.s-rulesPassed { background: var(--ss-success); color: var(--ss-success-ink); }
511
+ .pill.s-inapplicable { background: var(--ss-surface-raised); color: var(--muted); border: 1px solid var(--line); }
512
+ td.s-untested { color: var(--none-fg); }
513
+ h3.outcome { font-size: 1rem; margin: 1.75rem 0 .1rem; display: flex; align-items: baseline; gap: .5rem; }
514
+ h3.outcome .count { font-weight: 400; color: var(--muted); font-size: .875rem; }
515
+ .outcome-note { margin: 0 0 .5rem; font-size: .875rem; }
516
+ .pill { display: inline-block; min-width: 4.5ch; text-align: center; padding: 0 .4em; border-radius: 4px; }
517
+ details { border: 1px solid var(--line); border-radius: 8px; padding: .5rem .75rem; margin: .5rem 0; }
518
+ details > div { margin-top: .5rem; }
519
+ summary { cursor: pointer; }
520
+ footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .875rem; }
521
+ footer code { font-size: .8rem; }
522
+ .ok { color: var(--good-fg); }
523
+ ${SCREENS_CSS}
524
+ </style>
525
+ </head>
526
+ <body>
527
+ ${masthead('Setsquare — accessibility evidence',
528
+ /*
529
+ * The friendly instant is visible; the exact one stays in the `datetime`
530
+ * attribute, so a machine reading this page still gets milliseconds and a
531
+ * person reading it gets a date. The commit is shortened for display only —
532
+ * the full SHA is in run.json, which is what anything automated reads.
533
+ */
534
+ `<time datetime="${esc(envelope.generatedAt)}">${esc(humanInstant(envelope.generatedAt))}</time>` +
535
+ ` · ${esc(envelope.source.branch)} @ ${esc(shortCommit(envelope.source.commit))}` +
536
+ `${testsLine ? ` · ${esc(testsLine)}` : ''}`)}${groupBanner}
537
+ <main>
538
+ <section aria-labelledby="h-verdict">
539
+ <h2 id="h-verdict">Conformance — WCAG 2.2 <span class="muted">(includes every 2.0 and 2.1 criterion)</span></h2>
540
+ <p class="verdict">${verdictLine()}</p>
541
+ <ul class="ladder">
542
+ ${ladder}
543
+ </ul>
544
+ <ul>
545
+ ${coverage.join('\n ')}
546
+ </ul>
547
+ </section>
548
+
549
+ <section aria-labelledby="h-figures">
550
+ <h2 id="h-figures">This run</h2>
551
+ <div class="figures">
552
+ <div><strong>${n(count.surfaces)}</strong> surfaces scanned${count.themes > 0 ? ` in ${count.themes} theme${count.themes === 1 ? '' : 's'}` : ''}</div>
553
+ <div><strong>${n(failingRules)}</strong> rules failing, on ${n(count.violations)} elements</div>
554
+ <div><strong>${n(count.incomplete)}</strong> elements need a person</div>
555
+ <div><strong>${n(count.passes)}</strong> checks passed across ${n(quality.rulesAssessed)} rules</div>
556
+ </div>
557
+ </section>
558
+
559
+ <section aria-labelledby="h-grid">
560
+ <h2 id="h-grid">Conformance grid</h2>
561
+ <p class="muted">Criteria with evidence, per surface. A surface counts as failing if it fails in any scanned
562
+ state or theme. <strong>OK</strong> means every automated rule for the criterion passed there — evidence,
563
+ not criterion conformance; <strong>CHECK</strong> means automation could not decide; <strong>N/A</strong> means
564
+ the checks ran and the surface contains nothing the criterion regulates; <strong>—</strong> means
565
+ nothing looked.</p>
566
+ <div class="tablewrap">
567
+ <table class="grid">
568
+ <caption class="muted">WCAG success criteria × surfaces</caption>
569
+ <thead><tr><th scope="col">Criterion</th>${gridHead}</tr></thead>
570
+ <tbody>
571
+ ${gridBody}
572
+ </tbody>
573
+ </table>
574
+ </div>
575
+ ${criteriaDetails}
576
+ </section>
577
+
578
+ <section aria-labelledby="h-scope">
579
+ <h2 id="h-scope">Scope</h2>
580
+ <p>Assessed against WCAG 2.2 ${esc(targets)} with automated checks only, on the surfaces and states below.
581
+ Anything not listed was not looked at.</p>
582
+ <div class="tablewrap">
583
+ <table>
584
+ <thead><tr><th scope="col">Surface</th><th scope="col">States scanned</th></tr></thead>
585
+ <tbody>
586
+ ${scopeRows}
587
+ </tbody>
588
+ </table>
589
+ </div>
590
+ ${stateNote}
591
+ </section>
592
+
593
+ <section aria-labelledby="h-surfaces">
594
+ <h2 id="h-surfaces">Surfaces</h2>
595
+ <div class="tablewrap">
596
+ <table>
597
+ <thead><tr><th scope="col">Surface</th><th scope="col">State</th><th scope="col">Failing elements</th><th scope="col">Needs review</th><th scope="col">Rules failing</th></tr></thead>
598
+ <tbody>
599
+ ${surfacesBody}
600
+ </tbody>
601
+ </table>
602
+ </div>
603
+ </section>
604
+
605
+ <section aria-labelledby="h-rules">
606
+ <h2 id="h-rules">Rules failing</h2>
607
+ ${ruleSections('violation') || '<p class="muted">None.</p>'}
608
+ </section>
609
+
610
+ <section aria-labelledby="h-review">
611
+ <h2 id="h-review">Needs review</h2>
612
+ <p class="muted">Automation could not decide these — usually contrast over an image or a gradient.
613
+ Not passes and not failures; a person has to look.</p>
614
+ ${ruleSections('incomplete') || '<p class="muted">None.</p>'}
615
+ </section>
616
+ ${perf.length > 0
617
+ ? `
618
+ <section aria-labelledby="h-perf">
619
+ <h2 id="h-perf">Performance <span class="muted">(CI lab, not real users)</span></h2>
620
+ <div class="tablewrap">
621
+ <table>
622
+ <thead><tr><th scope="col">Page load</th><th scope="col">LCP</th><th scope="col">CLS</th><th scope="col">Load</th><th scope="col">TTFB</th></tr></thead>
623
+ <tbody>
624
+ ${perfBody}
625
+ </tbody>
626
+ </table>
627
+ </div>
628
+ <p class="muted">— means the engine did not report it: Firefox and WebKit implement neither LCP nor CLS.
629
+ Absent is not zero.</p>
630
+ </section>`
631
+ : ''}
632
+ ${insightSections}
633
+ ${screensSection}
634
+ ${groupSection}
635
+ ${warningsBlock}
636
+ </main>
637
+ <footer>
638
+ <p>Generated by Setsquare SDK ${esc(envelope.source.sdkVersion)} · axe-core ${esc(envelope.source.axeVersion)} ·
639
+ ruleset <code>${esc(envelope.source.rulesetDigest)}</code> · ${esc(envelope.source.runSource)} run.</p>
640
+ <p>This report summarises automated rule results per scanned surface. It is not a WCAG conformance claim:
641
+ WCAG conformance is defined per full page and complete process and includes requirements (non-interference,
642
+ accessibility support, conforming alternate versions) that automated scanning cannot evaluate.</p>
643
+ </footer>
644
+ <script type="application/json" id="setsquare-run">${embedded}</script>
645
+ </body>
646
+ </html>
647
+ `;
648
+ }
649
+ //# sourceMappingURL=html-report.js.map