@setsquare/review-sdk 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/NOTICE +3 -2
  2. package/README.md +220 -0
  3. package/dist/autoscan.d.ts +15 -1
  4. package/dist/autoscan.d.ts.map +1 -1
  5. package/dist/autoscan.js +168 -13
  6. package/dist/autoscan.js.map +1 -1
  7. package/dist/brand.d.ts +51 -2
  8. package/dist/brand.d.ts.map +1 -1
  9. package/dist/brand.js +560 -5
  10. package/dist/brand.js.map +1 -1
  11. package/dist/checks/context-change.d.ts +96 -0
  12. package/dist/checks/context-change.d.ts.map +1 -0
  13. package/dist/checks/context-change.js +655 -0
  14. package/dist/checks/context-change.js.map +1 -0
  15. package/dist/checks/focus.d.ts +154 -0
  16. package/dist/checks/focus.d.ts.map +1 -0
  17. package/dist/checks/focus.js +1123 -0
  18. package/dist/checks/focus.js.map +1 -0
  19. package/dist/checks/heuristics.d.ts +116 -0
  20. package/dist/checks/heuristics.d.ts.map +1 -0
  21. package/dist/checks/heuristics.js +931 -0
  22. package/dist/checks/heuristics.js.map +1 -0
  23. package/dist/checks/keyboard.d.ts +106 -0
  24. package/dist/checks/keyboard.d.ts.map +1 -0
  25. package/dist/checks/keyboard.js +452 -0
  26. package/dist/checks/keyboard.js.map +1 -0
  27. package/dist/checks/reflow.d.ts +75 -0
  28. package/dist/checks/reflow.d.ts.map +1 -0
  29. package/dist/checks/reflow.js +285 -0
  30. package/dist/checks/reflow.js.map +1 -0
  31. package/dist/checks/rule-pack.d.ts +1 -1
  32. package/dist/checks/rule-pack.d.ts.map +1 -1
  33. package/dist/checks/rule-pack.js +350 -45
  34. package/dist/checks/rule-pack.js.map +1 -1
  35. package/dist/checks/shortcuts.d.ts +71 -0
  36. package/dist/checks/shortcuts.d.ts.map +1 -0
  37. package/dist/checks/shortcuts.js +196 -0
  38. package/dist/checks/shortcuts.js.map +1 -0
  39. package/dist/checks/versions.d.ts.map +1 -1
  40. package/dist/checks/versions.js +33 -1
  41. package/dist/checks/versions.js.map +1 -1
  42. package/dist/contract.d.ts +18 -0
  43. package/dist/contract.d.ts.map +1 -1
  44. package/dist/covers.d.ts +56 -0
  45. package/dist/covers.d.ts.map +1 -0
  46. package/dist/covers.js +135 -0
  47. package/dist/covers.js.map +1 -0
  48. package/dist/dedupe.d.ts +23 -0
  49. package/dist/dedupe.d.ts.map +1 -1
  50. package/dist/dedupe.js +96 -2
  51. package/dist/dedupe.js.map +1 -1
  52. package/dist/document.d.ts.map +1 -1
  53. package/dist/document.js +144 -0
  54. package/dist/document.js.map +1 -1
  55. package/dist/envelope.d.ts +8 -0
  56. package/dist/envelope.d.ts.map +1 -1
  57. package/dist/envelope.js +28 -0
  58. package/dist/envelope.js.map +1 -1
  59. package/dist/fixture.d.ts.map +1 -1
  60. package/dist/fixture.js +12 -0
  61. package/dist/fixture.js.map +1 -1
  62. package/dist/html-report.d.ts +24 -7
  63. package/dist/html-report.d.ts.map +1 -1
  64. package/dist/html-report.js +608 -136
  65. package/dist/html-report.js.map +1 -1
  66. package/dist/index.d.ts +13 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +13 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/links.d.ts +43 -0
  71. package/dist/links.d.ts.map +1 -1
  72. package/dist/links.js +43 -0
  73. package/dist/links.js.map +1 -1
  74. package/dist/observe.d.ts +8 -0
  75. package/dist/observe.d.ts.map +1 -1
  76. package/dist/observe.js +45 -1
  77. package/dist/observe.js.map +1 -1
  78. package/dist/options.d.ts +53 -0
  79. package/dist/options.d.ts.map +1 -1
  80. package/dist/options.js +3 -0
  81. package/dist/options.js.map +1 -1
  82. package/dist/reading-level.d.ts +22 -1
  83. package/dist/reading-level.d.ts.map +1 -1
  84. package/dist/reading-level.js +122 -19
  85. package/dist/reading-level.js.map +1 -1
  86. package/dist/reporter.d.ts.map +1 -1
  87. package/dist/reporter.js +31 -15
  88. package/dist/reporter.js.map +1 -1
  89. package/dist/scan.d.ts +36 -1
  90. package/dist/scan.d.ts.map +1 -1
  91. package/dist/scan.js +16 -2
  92. package/dist/scan.js.map +1 -1
  93. package/dist/screens.d.ts +55 -4
  94. package/dist/screens.d.ts.map +1 -1
  95. package/dist/screens.js +243 -43
  96. package/dist/screens.js.map +1 -1
  97. package/dist/summary.d.ts +22 -0
  98. package/dist/summary.d.ts.map +1 -1
  99. package/dist/summary.js +197 -22
  100. package/dist/summary.js.map +1 -1
  101. package/dist/viewports.d.ts +105 -0
  102. package/dist/viewports.d.ts.map +1 -0
  103. package/dist/viewports.js +134 -0
  104. package/dist/viewports.js.map +1 -0
  105. package/package.json +5 -4
@@ -1,22 +1,50 @@
1
- import { BRAND_CSS, MASTHEAD_CSS, masthead } from './brand.js';
1
+ import { BRAND_CSS, MASTHEAD_CSS, SYSTEM_CSS, masthead, viewportLegend } from './brand.js';
2
2
  import { esc } from './escape.js';
3
+ import { REFLOW_RULE_ID } from './checks/reflow.js';
4
+ import { VIEWPORT_BAND_NOTE, groupByBand, viewportSize, } from './viewports.js';
5
+ /**
6
+ * One conformance level, one treatment, everywhere it appears.
7
+ *
8
+ * Three call sites in this file rendered `<span class="level">` and got bold
9
+ * mono text; the platform rendered the same thing four other ways. A reader
10
+ * cannot learn what a level looks like if it does not look like anything.
11
+ *
12
+ * The visible text stays short because it repeats on every row of the grid;
13
+ * the accessible name says "Level AA", because a bare "AA" read aloud in a
14
+ * list of criterion numbers is ambiguous with the numbers around it.
15
+ */
16
+ function levelTag(level) {
17
+ const slug = level.toLowerCase().replace(/[^a-z]/g, '');
18
+ return `<span class="level level-${slug}"><span class="sr-only">Level </span>${esc(level)}</span>`;
19
+ }
3
20
  import { humanInstant, shortCommit } from './human.js';
4
21
  import { insightBlocks } from './insights.js';
5
- import { renderScreensSection, SCREENS_CSS } from './screens.js';
22
+ import { screenBands } from './screens.js';
6
23
  import { conformanceGrid, conformanceSummary, ruleSurfaceShare, stateCoverage, } from '@setsquare/schema/conformance';
7
24
  import { evidenceQuality, groupFindings, perfRows, surfaceRows, testOutcomes, totals, } from './summary.js';
25
+ /*
26
+ * Two states here are the SUITE's claim rather than ours, and the wording has
27
+ * to keep that visible at a glance — "we ran a rule and it passed" and "you
28
+ * told us your test covers this" are not paraphrases of each other. Hence
29
+ * YOURS and YOURS✗ rather than a second OK and a second FAIL: a reader
30
+ * skimming the column must not be able to mistake one for the other.
31
+ */
8
32
  const STATE_WORD = {
9
33
  failed: 'FAIL',
34
+ assertedFailing: 'YOURS✗',
10
35
  cantTell: 'CHECK',
11
36
  rulesPassed: 'OK',
12
37
  inapplicable: 'N/A',
38
+ assertedBySuite: 'YOURS',
13
39
  untested: '—',
14
40
  };
15
41
  const STATE_LABEL = {
16
42
  failed: 'failed',
43
+ assertedFailing: 'your own test for this criterion did not pass',
17
44
  cantTell: 'needs a person',
18
45
  rulesPassed: 'every automated rule passed',
19
46
  inapplicable: 'checks ran; nothing to check on this surface',
47
+ assertedBySuite: 'covered by your own test; no rule ran',
20
48
  untested: 'untested',
21
49
  };
22
50
  function n(value) {
@@ -41,6 +69,201 @@ function partialReason(group) {
41
69
  }
42
70
  return `An invocation of run group “${group.id}” was cut short before it finished.`;
43
71
  }
72
+ /**
73
+ * What the run can say about viewport coverage, which is a count and not a
74
+ * verdict.
75
+ *
76
+ * A RULE ONLY EVER SEES THE VIEWPORT IT RAN IN. That is not a limitation of
77
+ * axe-core, it is what an in-page analyser is: a menu that collapses into a
78
+ * hamburger at 767px is a different DOM, and a scan at 1280 has no opinion
79
+ * about it whatsoever. A conformance grid built entirely from desktop scans
80
+ * therefore reads as far more coverage than it is, and this block is what stops
81
+ * that inference — it is a mechanical statement about the customer's own
82
+ * configuration, which is squarely the free layer's job.
83
+ *
84
+ * It states what was observed and never a denominator. There is no "3 of 5
85
+ * viewports covered", because nothing declares how many viewports an
86
+ * application has; `CoverageAxis` carries surface, theme, persona and state for
87
+ * exactly that reason, and viewport is deliberately not one of them.
88
+ *
89
+ * ONE EXCEPTION IS NAMED WHEN IT APPLIES. `setsquare-reflow` measures WCAG
90
+ * 1.4.10 by resizing the page to 320px and putting the viewport back, so a
91
+ * single-viewport run does carry real narrow-width evidence — for that one
92
+ * criterion. Saying "nothing here is about a phone" without that caveat would
93
+ * be untrue of our own tool, so the sentence appears only when the rule
94
+ * actually ran, detected in the run rather than assumed from the config.
95
+ *
96
+ * Silent when no scan recorded a viewport at all: a run driving a real device
97
+ * controls none, and an absence device about a measurement nobody took is
98
+ * noise.
99
+ */
100
+ function viewportScope(envelope) {
101
+ const widths = new Map();
102
+ for (const scan of envelope.scans) {
103
+ const viewport = scan.context.viewport;
104
+ if (viewport)
105
+ widths.set(viewportSize(viewport), viewport.width);
106
+ }
107
+ if (widths.size === 0)
108
+ return '';
109
+ const labels = [...widths]
110
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
111
+ .map(([label]) => label);
112
+ if (labels.length > 1) {
113
+ /*
114
+ * Grouped into bands and named, because "1280×720, 768×1024, 640×512,
115
+ * 390×844" is four numbers a reader has to classify themselves. The legend
116
+ * is what keeps the names honest — they are widths, not devices.
117
+ */
118
+ const banded = groupByBand(envelope.scans, (scan) => scan.context.viewport).groups.map((group) => ({ band: group.band, sizes: group.sizes }));
119
+ const lines = banded
120
+ .map((group) => ` <li><strong>${esc(group.band.label)}</strong> — ` +
121
+ `${group.sizes.map(esc).join(', ')}</li>`)
122
+ .join('\n');
123
+ return `<p>Scanned at ${labels.length} viewports, in ${banded.length} ${banded.length === 1 ? 'band' : 'bands'}. A rule only sees the viewport it ran in, so a criterion is evidenced at
124
+ the widths listed here and nowhere else.</p>
125
+ <ul>
126
+ ${lines}
127
+ </ul>
128
+ ${viewportLegend(bandLegend(banded.map((group) => group.band)), VIEWPORT_BAND_NOTE)}`;
129
+ }
130
+ const reflowRan = envelope.scans.some((scan) => scan.passes.some((pass) => pass.ruleId === REFLOW_RULE_ID) ||
131
+ scan.findings.some((finding) => finding.ruleId === REFLOW_RULE_ID) ||
132
+ (scan.inapplicable ?? []).some((rule) => rule.ruleId === REFLOW_RULE_ID));
133
+ const reflowNote = reflowRan
134
+ ? ` One criterion is the exception: <code>${REFLOW_RULE_ID}</code> measures 1.4.10 Reflow by
135
+ resizing to 320&thinsp;px and putting the viewport back, so that row does carry narrow-width
136
+ evidence.`
137
+ : '';
138
+ return `<div class="absence">
139
+ <p class="absence__label">One viewport only</p>
140
+ <p>Every scan in this run ran at <strong>${esc(labels[0])}</strong>. A rule only
141
+ sees the viewport it ran in — a navigation that collapses below a breakpoint is a different
142
+ document, and nothing above looked at it — so this grid is not evidence about a tablet or a
143
+ phone.${reflowNote}</p>
144
+ <p>Setsquare cannot widen this for you: it scans the pages your suite drives, at the sizes
145
+ your suite drives them. A Playwright project per viewport is what does, and it needs no test
146
+ changes:</p>
147
+ <p><code>{ name: 'tablet', use: { ...devices['Desktop Chrome'], viewport: { width: 768, height: 1024 } } }</code></p>
148
+ </div>`;
149
+ }
150
+ /**
151
+ * A table body grouped into one row group per band, or one plain body when the
152
+ * run only ever used one.
153
+ *
154
+ * Shared by the surfaces table and the performance table: they had the same
155
+ * shape and the same traps, and a second copy is a second place for the column
156
+ * arithmetic to drift.
157
+ *
158
+ * THE HEADER CELL DOES NOT SPAN THE TABLE, AND THAT IS NOT A STYLING CHOICE.
159
+ * It did, and axe cannot resolve it: a `th` whose colspan covers every column
160
+ * sits in a row with no data cells at all, so `th-has-data-cells` cannot work
161
+ * out which cells it heads and reports `incomplete` — "we could not check".
162
+ * Verified by putting it back on a real table and re-running, not assumed.
163
+ *
164
+ * THE CELLS MUST ALSO ADD UP. The same grouping on this repository's public
165
+ * evidence page was written with seven cells in an eight-column table, which
166
+ * left the last column header with rows holding no cell under it — the same
167
+ * rule, a different cause, and the one that was actually biting. axe names the
168
+ * orphaned header in its related nodes, which is what points at the arithmetic
169
+ * rather than at the pattern. Hence `columns - 1` rather than a literal, and
170
+ * hence this function rather than two of them.
171
+ *
172
+ * One table per band with a caption also resolves cleanly and was rejected:
173
+ * several tables would need their columns pinned to stay aligned, and
174
+ * `scope="rowgroup"` is the semantic that makes the grouping reachable rather
175
+ * than decorative.
176
+ *
177
+ * A single-band run is rendered flat. One heading reading LAPTOP above every
178
+ * row is a label, not a grouping, and it would push the first row down for
179
+ * nothing.
180
+ *
181
+ * The size is dropped from rows inside a band that holds only one, because the
182
+ * band's heading already says it — that redundancy is what the grouping was
183
+ * for. A band holding two keeps it on every row, where it is the only thing
184
+ * telling two rows apart.
185
+ */
186
+ function bandedBody(all, grouped, columns, renderRow) {
187
+ /*
188
+ * The name and its sizes are in the SAME cell, and the second cell is empty.
189
+ *
190
+ * Putting the sizes in the spanning cell read badly: the first column of both
191
+ * these tables is wide, so the size landed halfway across the bar with a gap
192
+ * between it and the band it belongs to. The filler exists to stop the header
193
+ * spanning every column and to make the row add up — not to hold anything.
194
+ * Both shapes were put in front of axe on the real report and both come back
195
+ * clean, so this is a layout choice and not a constrained one.
196
+ */
197
+ const header = (label, sizes) => `<tr class="band-row"><th scope="rowgroup">${esc(label)}` +
198
+ (sizes.length > 0 ? `<span class="sizes"> · ${sizes.map(esc).join(', ')}</span>` : '') +
199
+ `</th><td colspan="${columns - 1}"></td></tr>`;
200
+ if (grouped.groups.length <= 1) {
201
+ return `<tbody>
202
+ ${all.map((row) => renderRow(row, true)).join('\n ')}
203
+ </tbody>`;
204
+ }
205
+ return [
206
+ ...grouped.groups.map((group) => `<tbody>
207
+ ${header(group.band.label, group.sizes)}
208
+ ${group.items.map((row) => renderRow(row, group.sizes.length > 1)).join('\n ')}
209
+ </tbody>`),
210
+ // Rows the run recorded no viewport for are kept and labelled, never
211
+ // dropped to tidy the table: a run driving a real device controls none.
212
+ ...(grouped.unbanded.length > 0
213
+ ? [
214
+ `<tbody>
215
+ ${header('No viewport recorded', [])}
216
+ ${grouped.unbanded.map((row) => renderRow(row, false)).join('\n ')}
217
+ </tbody>`,
218
+ ]
219
+ : []),
220
+ ].join('\n ');
221
+ }
222
+ /**
223
+ * Links into the contact sheet, one per viewport band.
224
+ *
225
+ * "Open the contact sheet" answers a question nobody has. A reader who comes to
226
+ * this section has already decided which width they want to look at — it is
227
+ * almost always the narrow one, because that is the one they have never seen —
228
+ * and making them open a long page and scroll for the heading is the friction
229
+ * this whole grouping exists to remove.
230
+ *
231
+ * The fragments come from `screenBands`, which is also what writes the ids onto
232
+ * that page's headings. Deriving them twice is how a link ends up pointing at a
233
+ * heading that is not there.
234
+ *
235
+ * The plain link stays alongside, and is the only link when a run used one
236
+ * band: three links to one page, two of which go to the same place, is worse
237
+ * than one honest link.
238
+ */
239
+ function screensLinks(rows, href) {
240
+ const bands = screenBands(rows);
241
+ const open = ` <p><a href="${esc(href)}">Open the contact sheet</a></p>`;
242
+ if (bands.length <= 1)
243
+ return open;
244
+ const links = bands
245
+ .map((band) => ` <li><a href="${esc(href)}#${esc(band.anchor)}">${esc(band.band.label)}</a>` +
246
+ ` <span class="muted">${band.captures} capture${band.captures === 1 ? '' : 's'}` +
247
+ ` at ${band.sizes.map(esc).join(', ')}</span></li>`)
248
+ .join('\n');
249
+ return ` <ul class="band-links">
250
+ ${links}
251
+ </ul>
252
+ ${open}`;
253
+ }
254
+ /**
255
+ * The bands to define for a reader, which is only the ones on the page.
256
+ *
257
+ * A run that never went below 1024 has no business telling anybody where
258
+ * "mobile" starts, and a legend listing bands that are not in the evidence
259
+ * reads as a list of things we looked at.
260
+ */
261
+ function bandLegend(bands) {
262
+ const seen = new Map();
263
+ for (const band of bands)
264
+ seen.set(band.id, band);
265
+ return [...seen.values()];
266
+ }
44
267
  /** `href` is allowed only for URLs that came out of the envelope itself. */
45
268
  function helpLink(url) {
46
269
  if (!url || !url.startsWith('https://'))
@@ -76,6 +299,7 @@ export function renderHtmlReport(input) {
76
299
  const notes = block.notes.map((note) => ` <p class="muted">${esc(note)}</p>`).join('\n');
77
300
  return `
78
301
  <section aria-labelledby="${id}">
302
+ <p class="eyebrow">Local evidence</p>
79
303
  <h2 id="${id}">${esc(block.heading)}</h2>
80
304
  <dl class="insight">
81
305
  ${rows}
@@ -124,6 +348,14 @@ ${notes}
124
348
  state = `failures detected — ${level.failing.length === 1 ? 'one criterion' : `${level.failing.length} criteria`}`;
125
349
  cls = 'bad';
126
350
  }
351
+ else if (level.assertedFailing.length > 0) {
352
+ // Neither good nor bad, and the ladder must not round it to either: we
353
+ // detected nothing, and the suite's own check for a criterion at this
354
+ // level is red. A green rung over the top of that is the failure mode
355
+ // this whole state exists to prevent.
356
+ state = `no failures detected, but ${level.assertedFailing.length === 1 ? 'a criterion your suite covers' : `${level.assertedFailing.length} criteria your suite covers`} did not pass`;
357
+ cls = 'check';
358
+ }
127
359
  else {
128
360
  state = 'no failures detected in what was scanned';
129
361
  cls = 'good';
@@ -131,7 +363,7 @@ ${notes}
131
363
  const scans = level.assessedScans
132
364
  ? ` <span class="muted">(assessed on ${level.assessedScans.aaa} of ${level.assessedScans.total} scans)</span>`
133
365
  : '';
134
- return `<li><span class="level">${level.level}</span> <span class="${cls}">${state}</span>${scans}</li>`;
366
+ return `<li>${levelTag(level.level)} <span class="${cls}">${state}</span>${scans}</li>`;
135
367
  })
136
368
  .join('\n ');
137
369
  /*
@@ -141,13 +373,26 @@ ${notes}
141
373
  */
142
374
  const coverageRows = stateCoverage(envelope);
143
375
  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>`)
376
+ .map((row) =>
377
+ /*
378
+ * The sublabel is NOT muted, and that is a contrast decision made on
379
+ * the element rather than in the stylesheet. This cell sits on the
380
+ * data-table zebra, where the palette's muted grey measures 5.53:1 in
381
+ * light and 5.31:1 in dark — under 1.4.6's 7:1, in the default state.
382
+ * The hierarchy it was buying is bought instead by size and case,
383
+ * which cost no contrast at all. The public site made the same fix, on
384
+ * the same table pattern, for the same reason.
385
+ */
386
+ `<tr><th scope="row">${esc(row.surface)}</th><td>${row.states.map(esc).join(', ')}${row.defaultOnly ? ' <span class="sublabel">(default state only)</span>' : ''}</td></tr>`)
145
387
  .join('\n ');
146
388
  const defaultOnly = coverageRows.filter((row) => row.defaultOnly).length;
147
389
  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>`
390
+ ? `<div class="absence">
391
+ <p class="absence__label">Only the default state</p>
392
+ <p>${defaultOnly} of ${coverageRows.length} surfaces have evidence in their default
393
+ state only. Error, loading and empty states are where regressions hide —
394
+ <code>setsquare.scan(page, surface, { state })</code> captures them.</p>
395
+ </div>`
151
396
  : '';
152
397
  const targets = [...new Set(envelope.scans.map((scan) => scan.wcagTarget))].sort().join(', ') || 'AA';
153
398
  /* The grid: criteria × surfaces, real table, state words in every cell. */
@@ -162,7 +407,7 @@ ${notes}
162
407
  const removed = row.removedIn22
163
408
  ? ' <span class="muted">(removed in WCAG 2.2 — counts toward no level)</span>'
164
409
  : '';
165
- return `<tr><th scope="row">${row.criterion} ${esc(row.name)} <span class="level">${row.removedIn22 ? '—' : row.level}</span>${removed}</th>${cells}</tr>`;
410
+ return `<tr><th scope="row">${row.criterion} ${esc(row.name)} ${row.removedIn22 ? '<span class="level">—</span>' : levelTag(row.level)}${removed}</th>${cells}</tr>`;
166
411
  })
167
412
  .join('\n ');
168
413
  const rollupDetail = (rollup) => {
@@ -180,6 +425,69 @@ ${notes}
180
425
  if (rollup.inapplicableSurfaces.length > 0) {
181
426
  parts.push(`<p><strong>Nothing to check</strong> (checks ran, no such content) on: ${rollup.inapplicableSurfaces.map(esc).join(', ')}</p>`);
182
427
  }
428
+ /*
429
+ * The declarations, named as the suite's own and attributed to the tests
430
+ * that made them. Test ids rather than a count, because the only useful
431
+ * next action is opening the file — "three tests cover 1.4.10" tells
432
+ * nobody which three.
433
+ *
434
+ * The surface list is stated only when there is one. A declaring test
435
+ * often has no scans at all (a reflow test measures `scrollWidth` and never
436
+ * calls `setsquare.scan`), and an empty list means "we cannot say which
437
+ * surfaces", never "none" — so it is omitted rather than rendered as zero.
438
+ */
439
+ /*
440
+ * A cross-surface disagreement names BOTH sides, because that is the only
441
+ * form of this finding anyone can act on: "the navigation is inconsistent"
442
+ * without saying between which two pages sends the reader to look at
443
+ * everything.
444
+ */
445
+ if (rollup.consistency && rollup.consistency.disagreements.length > 0) {
446
+ for (const row of rollup.consistency.disagreements) {
447
+ parts.push(`<p><strong>${esc(row.surfaces[0])} and ${esc(row.surfaces[1])} disagree</strong>: ` +
448
+ `${esc(row.detail)}</p>`);
449
+ }
450
+ }
451
+ else if (rollup.consistency?.note) {
452
+ parts.push(`<p><strong>Across surfaces</strong>: ${esc(rollup.consistency.note)}</p>`);
453
+ }
454
+ else if (rollup.consistency && rollup.consistency.state === 'rulesPassed') {
455
+ parts.push(`<p><strong>Compared across ${rollup.consistency.surfaces.length} surfaces</strong> and ` +
456
+ `they agreed: ${rollup.consistency.surfaces.map(esc).join(', ')}.</p>`);
457
+ }
458
+ /*
459
+ * The curated position, rendered beside the criterion whatever state it is
460
+ * in. A criterion whose PRESENCE half we check still has an adequacy half
461
+ * nobody can, and saying so next to the finding is the point of the list —
462
+ * it is the concrete floor under "automated evidence is not conformance".
463
+ */
464
+ if (rollup.automationLimit) {
465
+ const limit = rollup.automationLimit;
466
+ parts.push(`<p class="muted"><strong>${limit.covered === 'presence'
467
+ ? 'Presence is checkable; adequacy is not'
468
+ : 'No automated rule can decide this'}</strong>: ${esc(limit.because)}. This would change if ${esc(limit.wouldChangeIf)}.</p>`);
469
+ }
470
+ const asserted = rollup.assertions;
471
+ if (asserted) {
472
+ const where = asserted.surfaces.length > 0
473
+ ? ` while scanning: ${asserted.surfaces.map(esc).join(', ')}`
474
+ : '';
475
+ if (asserted.notUpheld.length > 0) {
476
+ parts.push(`<p><strong>Your own test for this criterion did not pass</strong>: ` +
477
+ `<code>${asserted.notUpheld.map(esc).join('</code>, <code>')}</code>${where}. ` +
478
+ `Setsquare did not detect this — your suite declared it, and we do not verify the declaration.</p>`);
479
+ }
480
+ if (asserted.upheld.length > 0) {
481
+ parts.push(`<p><strong>Covered by your own test</strong>: ` +
482
+ `<code>${asserted.upheld.map(esc).join('</code>, <code>')}</code>${where}. ` +
483
+ `Your assertion that the test covers this criterion, and it passed. No rule ran; we did not check what the test checks.</p>`);
484
+ }
485
+ if (asserted.skipped.length > 0) {
486
+ parts.push(`<p><strong>Declared but skipped</strong>: ` +
487
+ `<code>${asserted.skipped.map(esc).join('</code>, <code>')}</code>. ` +
488
+ `The test that covers this criterion did not run.</p>`);
489
+ }
490
+ }
183
491
  if (rollup.failingRules.length > 0) {
184
492
  parts.push(`<p>Failing rules: <code>${rollup.failingRules.map(esc).join('</code>, <code>')}</code></p>`);
185
493
  }
@@ -223,6 +531,18 @@ ${notes}
223
531
  note: 'The checks ran and these pages contain nothing the criterion regulates.',
224
532
  rollups: summary.levels.flatMap((level) => level.inapplicable).sort(byNumber),
225
533
  },
534
+ {
535
+ heading: 'Your own test did not pass',
536
+ note: 'Your suite declared it covers these criteria and the test is red. This is your ' +
537
+ 'assertion, not our finding — we did not observe a failure and cannot point at an element.',
538
+ rollups: summary.levels.flatMap((level) => level.assertedFailing).sort(byNumber),
539
+ },
540
+ {
541
+ heading: 'Covered by your own tests',
542
+ note: 'No automated rule reached these criteria; a passing test of yours declared it covers ' +
543
+ 'them. We do not verify that it does — this is your claim, recorded as yours.',
544
+ rollups: summary.levels.flatMap((level) => level.assertedBySuite).sort(byNumber),
545
+ },
226
546
  {
227
547
  heading: 'Removed in WCAG 2.2',
228
548
  note: 'Kept for the record. These count toward no level.',
@@ -231,7 +551,7 @@ ${notes}
231
551
  ];
232
552
  const criterionDetail = (rollup) => `
233
553
  <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>
554
+ <summary><span class="s-${rollup.state} pill">${STATE_WORD[rollup.state]}</span> ${rollup.criterion} ${esc(rollup.name)} ${rollup.removedIn22 ? '<span class="level">removed in 2.2</span>' : levelTag(rollup.level)}</summary>
235
555
  <div>
236
556
  ${rollupDetail(rollup)}
237
557
  </div>
@@ -259,10 +579,34 @@ ${notes}
259
579
  coverage.push(`<li><strong>${inapplicableRollups.length}</strong> criteria: checks ran, nothing to check on these pages — ` +
260
580
  `${inapplicableRollups.map((r) => r.criterion).join(', ')}</li>`);
261
581
  }
262
- if (untested.length > 0) {
582
+ const assertedRollups = countedLevels.flatMap((level) => level.assertedBySuite);
583
+ if (assertedRollups.length > 0) {
584
+ coverage.push(`<li><strong>${assertedRollups.length}</strong> criteria covered by your own tests — your assertion, no rule ran: ` +
585
+ `${assertedRollups.map((r) => r.criterion).join(', ')}</li>`);
586
+ }
587
+ const noRule = untested.filter((r) => r.untestedReason === 'noRuleCanDecide');
588
+ if (noRule.length > 0) {
589
+ coverage.push(`<li><strong>${noRule.length}</strong> criteria no automated rule can decide, whatever ` +
590
+ `anyone builds — these are what you still owe an auditor: ` +
591
+ `${noRule.map((r) => r.criterion).join(', ')}</li>`);
592
+ }
593
+ const singleSurface = untested.filter((r) => r.untestedReason === 'singleSurface');
594
+ if (singleSurface.length > 0) {
595
+ coverage.push(`<li><strong>${singleSurface.length}</strong> criteria are about a SET of pages and this run ` +
596
+ `had fewer than two surfaces to compare: ${singleSurface.map((r) => r.criterion).join(', ')}</li>`);
597
+ }
598
+ const skippedDeclarations = untested.filter((r) => r.untestedReason === 'declarationSkipped');
599
+ if (skippedDeclarations.length > 0) {
600
+ coverage.push(`<li><strong>${skippedDeclarations.length}</strong> criteria your suite covers were skipped and did not run: ` +
601
+ `${skippedDeclarations.map((r) => r.criterion).join(', ')}</li>`);
602
+ }
603
+ const untestedRest = untested.filter((r) => r.untestedReason !== 'declarationSkipped' &&
604
+ r.untestedReason !== 'singleSurface' &&
605
+ r.untestedReason !== 'noRuleCanDecide');
606
+ if (untestedRest.length > 0) {
263
607
  if (input.criterionRules) {
264
- const noCheck = untested.filter((r) => r.untestedReason === 'notAutomatable');
265
- const notRun = untested.filter((r) => r.untestedReason === 'notRun');
608
+ const noCheck = untestedRest.filter((r) => r.untestedReason === 'notAutomatable');
609
+ const notRun = untestedRest.filter((r) => r.untestedReason === 'notRun');
266
610
  if (noCheck.length > 0) {
267
611
  coverage.push(`<li><strong>${noCheck.length}</strong> criteria have no automated check in this engine: ` +
268
612
  `${noCheck.map((r) => r.criterion).join(', ')}</li>`);
@@ -273,20 +617,52 @@ ${notes}
273
617
  }
274
618
  }
275
619
  else {
276
- coverage.push(`<li><strong>${untested.length}</strong> criteria not automatically checked in this run</li>`);
620
+ coverage.push(`<li><strong>${untestedRest.length}</strong> criteria not automatically checked in this run</li>`);
277
621
  }
278
622
  }
279
- const surfacesBody = rows
280
- .map((row) => {
623
+ /*
624
+ * The size is dropped from a row inside a band that has only one, because the
625
+ * band's own heading already says it — that redundancy is exactly what the
626
+ * grouping was for. A band holding two sizes keeps it on every row, because
627
+ * there it is the only thing telling two rows apart.
628
+ */
629
+ const surfaceRow = (row, showSize = true) => {
630
+ const label = showSize ? row.label : row.shortLabel;
281
631
  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>` +
632
+ const word = row.violations > 0 ? 'FAIL' : row.incomplete > 0 ? 'CHECK' : 'PASS';
633
+ /*
634
+ * A pill, not coloured text. This table carries the data-table zebra, and
635
+ * a status colour as TEXT on that stripe measures about 5.3:1 in both
636
+ * themes — under the floor. A pill's fill is a role under its own -ink,
637
+ * which is a pair the palette measured, and it paints its own ground so
638
+ * the stripe underneath stops mattering.
639
+ */
640
+ return (`<tr><th scope="row">${esc(label)}</th>` +
641
+ `<td><span class="pill s-${state}">${word}</span></td>` +
285
642
  `<td class="num">${n(row.violations)}</td>` +
286
643
  `<td class="num">${n(row.incomplete)}</td>` +
287
644
  `<td>${row.rules.map((rule) => `<code>${esc(rule)}</code>`).join(', ')}</td></tr>`);
288
- })
289
- .join('\n ');
645
+ };
646
+ /*
647
+ * ONE ROW GROUP PER BAND, and real table structure rather than a visual break.
648
+ *
649
+ * Each band is its own `tbody` opened by a header cell scoped to the row
650
+ * group, so a screen reader announces which band a row belongs to the same way
651
+ * it announces which column. A styled row with no header semantics would
652
+ * group the rows for sighted readers only, which on an accessibility report
653
+ * is a poor joke.
654
+ *
655
+ * Order inside a band is untouched: `surfaceRows` already leads with the
656
+ * worst, and regrouping must not quietly overrule that.
657
+ *
658
+ * A single-band run gets no grouping at all — one heading reading LAPTOP over
659
+ * every row of the table is a label, not a grouping, and it would push the
660
+ * first row down for nothing.
661
+ */
662
+ const SURFACE_COLUMNS = 5;
663
+ const surfaceBands = groupByBand(rows, (row) => row.viewport);
664
+ const bandedSurfaces = surfaceBands.groups.length > 1;
665
+ const surfacesBody = bandedBody(rows, surfaceBands, SURFACE_COLUMNS, surfaceRow);
290
666
  const ruleSections = (status) => groups
291
667
  .filter((group) => group.status === status)
292
668
  .map((group) => {
@@ -305,13 +681,21 @@ ${notes}
305
681
  </details>`;
306
682
  })
307
683
  .join('\n');
308
- const perfBody = perf
309
- .map((row) => `<tr><th scope="row">${esc(row.label)}</th>` +
684
+ /*
685
+ * The performance table groups by band for the same reason the surfaces table
686
+ * does, and with more at stake: LCP at 390 and LCP at 1280 are not the same
687
+ * measurement, and a column of times with no width beside them invites a
688
+ * reader to average things that cannot be averaged. A narrow viewport renders
689
+ * a different layout and often a different set of images.
690
+ */
691
+ const PERF_COLUMNS = 5;
692
+ const perfRow = (row, showSize = true) => `<tr><th scope="row">${esc(showSize ? row.label : row.shortLabel)}</th>` +
310
693
  `<td class="num">${row.lcpMs !== undefined ? `${n(Math.round(row.lcpMs))}ms` : '—'}</td>` +
311
694
  `<td class="num">${row.cls !== undefined ? row.cls.toFixed(2) : '—'}</td>` +
312
695
  `<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 ');
696
+ `<td class="num">${row.ttfbMs !== undefined ? `${n(Math.round(row.ttfbMs))}ms` : '—'}</td></tr>`;
697
+ const perfGrouped = groupByBand(perf, (row) => row.viewport);
698
+ const perfBody = bandedBody(perf, perfGrouped, PERF_COLUMNS, perfRow);
315
699
  const testsLine = envelope.results.length > 0
316
700
  ? `Playwright: ${tests.passed} passed` +
317
701
  (tests.failed > 0 ? `, ${tests.failed} failed` : '') +
@@ -336,11 +720,12 @@ ${notes}
336
720
  const groupSection = group
337
721
  ? `
338
722
  <section aria-labelledby="h-group">
723
+ <p class="eyebrow">Provenance</p>
339
724
  <h2 id="h-group">Run group</h2>
340
725
  <p class="muted">This is the merged evidence of run group “${esc(group.id)}” — one logical run spanning
341
726
  ${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>
727
+ <div class="tablewrap" role="region" aria-label="Invocations merged into this run group" tabindex="0">
728
+ <table class="data-table">
344
729
  <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
730
  <tbody>
346
731
  ${group.parts
@@ -360,16 +745,29 @@ ${notes}
360
745
  * above the conformance verdict would let a page of pictures answer a question
361
746
  * about WCAG that pictures cannot answer.
362
747
  */
363
- const screensSection = input.screens && input.screens.rows.length > 0
364
- ? `\n${renderScreensSection(input.screens)}`
748
+ const shotCount = input.screens?.rows.reduce((total, row) => total + row.groups.reduce((n, group) => n + group.shots.length, 0), 0) ?? 0;
749
+ const screensSection = input.screens && input.screens.rows.length > 0 && input.screensHref
750
+ ? `
751
+ <section aria-labelledby="h-screens">
752
+ <p class="eyebrow">Captures</p>
753
+ <h2 id="h-screens">Screenshots</h2>
754
+ <p>${shotCount} ${shotCount === 1 ? 'capture' : 'captures'} across ${input.screens.rows.length} ${input.screens.rows.length === 1 ? 'test' : 'tests'}${input.screens.testsWithoutShots > 0
755
+ ? `, and ${input.screens.testsWithoutShots} ${input.screens.testsWithoutShots === 1 ? 'test that produced' : 'tests that produced'} none`
756
+ : ''}. Playwright took them; Setsquare indexed them and compared nothing.</p>
757
+ ${screensLinks(input.screens.rows, input.screensHref)}
758
+ </section>`
365
759
  : '';
366
760
  const warningsBlock = input.warnings.length > 0
367
761
  ? `
368
762
  <section aria-labelledby="h-warnings">
763
+ <p class="eyebrow">Caveats</p>
369
764
  <h2 id="h-warnings">Worth knowing</h2>
370
- <ul>
371
- ${input.warnings.map((warning) => `<li>${esc(warning)}</li>`).join('\n ')}
372
- </ul>
765
+ <div class="absence">
766
+ <p class="absence__label">Not everything could be shown</p>
767
+ <ul>
768
+ ${input.warnings.map((warning) => `<li>${esc(warning)}</li>`).join('\n ')}
769
+ </ul>
770
+ </div>
373
771
  </section>`
374
772
  : '';
375
773
  // `</script>`-proofing: `<` never appears raw inside the data block.
@@ -383,111 +781,142 @@ ${notes}
383
781
  <title>Setsquare — accessibility evidence, ${esc(humanInstant(envelope.generatedAt))}</title>
384
782
  <style>
385
783
  ${BRAND_CSS}
784
+ ${SYSTEM_CSS}
785
+ ${MASTHEAD_CSS}
386
786
  /*
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.
787
+ * WHAT IS LEFT IN THIS FILE, NOW THAT brand.ts CARRIES THE SYSTEM.
396
788
  *
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.
789
+ * The type ladder, the section spine, the absence device, the data-table
790
+ * treatment, the level tag, the pills and the base reset are all in
791
+ * SYSTEM_CSS, shared with the contact sheet and copied from the public site.
792
+ * What remains here is what only a conformance report has: the grid's sticky
793
+ * column, the stat strip, the insight list, and the drill-down disclosures.
400
794
  *
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.)
795
+ * (No backticks in this block or its comments. It is inside a template
796
+ * literal, so one would end the string and the parse error would surface
797
+ * pages away.)
403
798
  */
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);
799
+ :root { --doc-width: 76rem; }
800
+
801
+ /* --------------------------------------------------------------- verdict */
802
+
803
+ /*
804
+ * The one sentence this document exists to deliver, so it is given the
805
+ * card radius and the raised ground rather than being a paragraph among
806
+ * paragraphs.
807
+ */
808
+ .verdict {
809
+ max-width: var(--ss-measure);
810
+ margin: 0 0 var(--ss-block);
811
+ padding: var(--ss-block);
812
+ background: var(--ss-surface-raised);
813
+ border: 1px solid var(--line);
814
+ border-radius: var(--ss-radius-card);
815
+ font-size: 1.05rem;
409
816
  }
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;
817
+
818
+ ul.ladder { list-style: none; padding: 0; margin: 0 0 var(--ss-block); max-width: none; }
819
+ ul.ladder li { display: flex; align-items: baseline; gap: 10px; padding: .3rem 0; }
820
+
821
+ /* ----------------------------------------------------------- stat strip */
822
+
823
+ /*
824
+ * The run's four headline counts. Cards on the ladder rather than the loose
825
+ * bordered boxes they were: same radius as the verdict, same raised ground,
826
+ * and the figure set in the h3 step so a number reads as a number.
827
+ */
828
+ .figures {
829
+ display: grid;
830
+ grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
831
+ gap: var(--ss-block);
832
+ margin: 0 0 var(--ss-block);
833
+ }
834
+ .figures div {
835
+ padding: var(--ss-block);
836
+ background: var(--ss-surface-raised);
837
+ border: 1px solid var(--line);
838
+ border-radius: var(--ss-radius-card);
839
+ font-size: .9375rem;
840
+ }
841
+ .figures strong {
842
+ display: block; margin-bottom: 6px;
843
+ font-size: var(--ss-h3); line-height: 1.1; letter-spacing: -.02em;
844
+ font-variant-numeric: tabular-nums;
416
845
  }
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; }
846
+
847
+ /* ------------------------------------------------------ insight blocks */
848
+
432
849
  /*
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.
850
+ * The P1b insight blocks. A definition list, not the .figures strip: these
851
+ * rows carry prose under each figure, and a row of boxes would squeeze the
852
+ * caveats into columns nobody reads. The caveats are the point.
436
853
  */
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); }
854
+ .insight { margin: 0 0 var(--ss-block); }
855
+ .insight > div {
856
+ display: grid; grid-template-columns: 5rem 1fr; gap: .25rem 1rem;
857
+ padding: .6rem 0; border-top: 1px solid var(--line);
858
+ }
439
859
  .insight > div:first-child { border-top: 0; }
440
- .insight dt { font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
860
+ .insight dt {
861
+ font-family: var(--ss-mono); font-weight: 600;
862
+ text-align: right; font-variant-numeric: tabular-nums;
863
+ }
441
864
  .insight dd { margin: 0; }
442
- .insight dd p { margin: .25rem 0 0; }
865
+ .insight dd p { margin: .25rem 0 0; max-width: 60ch; }
443
866
  @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. */
867
+ /* One column below the fold width: a 5rem gutter beside wrapping prose
868
+ stops being a gutter and starts being a squeeze. */
446
869
  .insight > div { grid-template-columns: 1fr; }
447
870
  .insight dt { text-align: left; }
448
871
  }
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; }
872
+
873
+ /* ------------------------------------------------------ conformance grid */
452
874
 
453
875
  /*
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.
876
+ * THE GRID IS NOT A .data-table, AND THE REASON IS A MEASUREMENT.
461
877
  *
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.
878
+ * It takes the filled ink header, because that is the site's language and it
879
+ * measures 21.00:1 in light and 19.80:1 in dark. It does NOT take the
880
+ * grid-line zebra, and this is the one place the two designs part company.
467
881
  *
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.
882
+ * The site's stripe works because every cell in those tables is ink. This
883
+ * grid's cells are not: a state is carried by its WORD, which is what 1.4.1
884
+ * asks, and the colour on top of the word is how a reader finds a column of
885
+ * failures in sixty rows without reading any of them. On grid-line, danger
886
+ * measures 5.33:1 in light and 5.83:1 in dark, and the muted grey that
887
+ * fifty N/A cells depend on measures 5.53:1 and 5.31:1 — all of them under
888
+ * 1.4.6's 7:1. Two ways out were available and both are worse: full ink on
889
+ * every cell turns a grid that is mostly N/A into a wall of black, and a pill
890
+ * per cell puts nine hundred filled chips on one page.
470
891
  *
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.
892
+ * So the grid keeps its colour and loses its stripe. What it lost with the
893
+ * stripe was nothing: the old banding was surface against surface-raised,
894
+ * which measures 1.12:1 in light and 1.14:1 in dark, and a stripe nobody can
895
+ * see is not a stripe. Row tracking is the 1px border, which tokens.css
896
+ * measures at 3.00:1, plus the pinned criterion column below.
474
897
  */
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); }
898
+ .grid thead th {
899
+ background: var(--ss-ink); color: var(--ss-surface); border-bottom: none;
900
+ }
901
+ .grid thead th:not(:first-child) { border-left-color: var(--ss-surface); }
902
+ .grid th[scope="row"] { font-weight: 600; }
478
903
 
479
904
  /*
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.
905
+ * The grid is sixteen surfaces wide inside a horizontal scroller, and what
906
+ * you lose scrolling right is not which row you are on — the borders handle
907
+ * that — it is WHICH CRITERION, because the row header has left the viewport.
908
+ * Pinning it is the difference between a readable table and a wall of FAIL.
484
909
  *
485
910
  * A box-shadow draws the right edge rather than border-right: under
486
911
  * border-collapse: collapse the border belongs to the table, not the cell, so
487
912
  * it does not travel with a sticky cell and the pinned column loses its edge.
913
+ *
914
+ * The pinned cells must paint an opaque ground or the columns they detach
915
+ * over show through them.
488
916
  */
489
917
  .grid th[scope="row"], .grid thead th:first-child {
490
918
  position: sticky; left: 0; z-index: 1;
919
+ background: var(--ss-surface);
491
920
  box-shadow: 1px 0 0 var(--line);
492
921
  }
493
922
  /*
@@ -497,30 +926,61 @@ ${MASTHEAD_CSS}
497
926
  * it looked broken in exactly the way you would expect: the surface headers
498
927
  * slid underneath the criterion labels.
499
928
  */
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; }
929
+ .grid thead th:first-child {
930
+ z-index: 2; background: var(--ss-ink); color: var(--ss-surface);
931
+ }
932
+ .grid th[scope="col"] .idx {
933
+ display: block; font-family: var(--ss-mono); font-size: 11px;
934
+ letter-spacing: .08em; font-weight: 400; opacity: .72;
935
+ }
936
+ /*
937
+ * State as a word plus a colour, never a colour alone. Every one of these is
938
+ * measured on the flat ground it sits on: in light, danger 7.90:1, warning
939
+ * 7.87:1, success 7.87:1 and the muted grey 8.19:1; in dark, 9.14:1, 9.11:1,
940
+ * 9.15:1 and 8.33:1.
941
+ */
942
+ .grid td {
943
+ font-family: var(--ss-mono); font-size: .8125rem; letter-spacing: .04em;
944
+ }
503
945
  td.s-failed { color: var(--bad-fg); font-weight: 700; }
504
946
  td.s-cantTell { color: var(--warn-fg); font-weight: 600; }
505
947
  td.s-rulesPassed { color: var(--good-fg); }
506
948
  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
949
  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; }
950
+ /*
951
+ * The two DECLARED states borrow no new colour. Danger as text on the page
952
+ * ground is already measured and already used by td.s-failed, so a failing
953
+ * declaration reuses it rather than inventing a hue for a fifth state; the
954
+ * hollow pill elsewhere on the page is what says it is a claim rather than an
955
+ * observation.
956
+ */
957
+ td.s-assertedFailing { color: var(--bad-fg); font-weight: 700; }
958
+ td.s-assertedBySuite { color: var(--fg); }
959
+
960
+ /* ------------------------------------------------------- rule drill-downs */
961
+
962
+ /*
963
+ * A second line inside a data cell: smaller and letter-spaced, never a
964
+ * different colour. See the note beside the Scope rows for why.
965
+ */
966
+ .sublabel { font-size: .8125em; letter-spacing: .02em; white-space: nowrap; }
967
+
968
+ h3.outcome {
969
+ margin: var(--ss-block) 0 .1rem;
970
+ display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap;
971
+ }
514
972
  h3.outcome .count { font-weight: 400; color: var(--muted); font-size: .875rem; }
515
973
  .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; }
974
+
975
+ details {
976
+ max-width: var(--ss-measure);
977
+ border: 1px solid var(--line); border-radius: var(--ss-radius-block);
978
+ padding: .6rem .85rem; margin: 0 0 .5rem;
979
+ background: var(--ss-surface-raised);
980
+ }
518
981
  details > div { margin-top: .5rem; }
982
+ details p { max-width: none; }
519
983
  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
984
  </style>
525
985
  </head>
526
986
  <body>
@@ -536,6 +996,7 @@ ${masthead('Setsquare — accessibility evidence',
536
996
  `${testsLine ? ` · ${esc(testsLine)}` : ''}`)}${groupBanner}
537
997
  <main>
538
998
  <section aria-labelledby="h-verdict">
999
+ <p class="eyebrow">Verdict</p>
539
1000
  <h2 id="h-verdict">Conformance — WCAG 2.2 <span class="muted">(includes every 2.0 and 2.1 criterion)</span></h2>
540
1001
  <p class="verdict">${verdictLine()}</p>
541
1002
  <ul class="ladder">
@@ -547,6 +1008,7 @@ ${masthead('Setsquare — accessibility evidence',
547
1008
  </section>
548
1009
 
549
1010
  <section aria-labelledby="h-figures">
1011
+ <p class="eyebrow">Counts</p>
550
1012
  <h2 id="h-figures">This run</h2>
551
1013
  <div class="figures">
552
1014
  <div><strong>${n(count.surfaces)}</strong> surfaces scanned${count.themes > 0 ? ` in ${count.themes} theme${count.themes === 1 ? '' : 's'}` : ''}</div>
@@ -557,13 +1019,14 @@ ${masthead('Setsquare — accessibility evidence',
557
1019
  </section>
558
1020
 
559
1021
  <section aria-labelledby="h-grid">
1022
+ <p class="eyebrow">Criteria &times; surfaces</p>
560
1023
  <h2 id="h-grid">Conformance grid</h2>
561
1024
  <p class="muted">Criteria with evidence, per surface. A surface counts as failing if it fails in any scanned
562
1025
  state or theme. <strong>OK</strong> means every automated rule for the criterion passed there — evidence,
563
1026
  not criterion conformance; <strong>CHECK</strong> means automation could not decide; <strong>N/A</strong> means
564
1027
  the checks ran and the surface contains nothing the criterion regulates; <strong>—</strong> means
565
1028
  nothing looked.</p>
566
- <div class="tablewrap">
1029
+ <div class="tablewrap" role="region" aria-label="WCAG success criteria by surface" tabindex="0">
567
1030
  <table class="grid">
568
1031
  <caption class="muted">WCAG success criteria × surfaces</caption>
569
1032
  <thead><tr><th scope="col">Criterion</th>${gridHead}</tr></thead>
@@ -576,11 +1039,13 @@ ${masthead('Setsquare — accessibility evidence',
576
1039
  </section>
577
1040
 
578
1041
  <section aria-labelledby="h-scope">
1042
+ <p class="eyebrow">What was looked at</p>
579
1043
  <h2 id="h-scope">Scope</h2>
580
1044
  <p>Assessed against WCAG 2.2 ${esc(targets)} with automated checks only, on the surfaces and states below.
581
1045
  Anything not listed was not looked at.</p>
582
- <div class="tablewrap">
583
- <table>
1046
+ ${viewportScope(envelope)}
1047
+ <div class="tablewrap" role="region" aria-label="Surfaces and the states scanned on each" tabindex="0">
1048
+ <table class="data-table">
584
1049
  <thead><tr><th scope="col">Surface</th><th scope="col">States scanned</th></tr></thead>
585
1050
  <tbody>
586
1051
  ${scopeRows}
@@ -591,38 +1056,45 @@ ${masthead('Setsquare — accessibility evidence',
591
1056
  </section>
592
1057
 
593
1058
  <section aria-labelledby="h-surfaces">
594
- <h2 id="h-surfaces">Surfaces</h2>
595
- <div class="tablewrap">
596
- <table>
1059
+ <p class="eyebrow">Per surface</p>
1060
+ <h2 id="h-surfaces">Surfaces</h2>${bandedSurfaces
1061
+ ? `
1062
+ <p class="muted">Grouped by viewport band. The legend under Scope says what each name covers —
1063
+ it is a width, not a device.</p>`
1064
+ : ''}
1065
+ <div class="tablewrap" role="region" aria-label="Results per surface" tabindex="0">
1066
+ <table class="data-table">
597
1067
  <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
1068
  ${surfacesBody}
600
- </tbody>
601
1069
  </table>
602
1070
  </div>
603
1071
  </section>
604
1072
 
605
1073
  <section aria-labelledby="h-rules">
1074
+ <p class="eyebrow">Failing rules</p>
606
1075
  <h2 id="h-rules">Rules failing</h2>
607
1076
  ${ruleSections('violation') || '<p class="muted">None.</p>'}
608
1077
  </section>
609
1078
 
610
1079
  <section aria-labelledby="h-review">
1080
+ <p class="eyebrow">Could not check</p>
611
1081
  <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>
1082
+ <div class="absence">
1083
+ <p class="absence__label">We could not check</p>
1084
+ <p>Automation could not decide these — usually contrast over an image or a gradient.
1085
+ Not passes and not failures; a person has to look.</p>
1086
+ </div>
614
1087
  ${ruleSections('incomplete') || '<p class="muted">None.</p>'}
615
1088
  </section>
616
1089
  ${perf.length > 0
617
1090
  ? `
618
1091
  <section aria-labelledby="h-perf">
1092
+ <p class="eyebrow">Timing</p>
619
1093
  <h2 id="h-perf">Performance <span class="muted">(CI lab, not real users)</span></h2>
620
- <div class="tablewrap">
621
- <table>
1094
+ <div class="tablewrap" role="region" aria-label="Page load timings" tabindex="0">
1095
+ <table class="data-table">
622
1096
  <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
1097
  ${perfBody}
625
- </tbody>
626
1098
  </table>
627
1099
  </div>
628
1100
  <p class="muted">— means the engine did not report it: Firefox and WebKit implement neither LCP nor CLS.