tuffgal 0.1.0-alpha.9 → 0.2.0-alpha.2

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 (89) hide show
  1. package/CHANGELOG.md +149 -2
  2. package/README.md +78 -3
  3. package/dist/cli.d.ts +50 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +204 -18
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/init.d.ts +1 -1
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +11 -1
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/config.d.ts +38 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +20 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/coverage/screens.d.ts +11 -6
  16. package/dist/coverage/screens.d.ts.map +1 -1
  17. package/dist/coverage/screens.js +12 -7
  18. package/dist/coverage/screens.js.map +1 -1
  19. package/dist/reporter/assets/report.css +415 -51
  20. package/dist/reporter/assets/report.js +502 -68
  21. package/dist/reporter/template.d.ts +8 -1
  22. package/dist/reporter/template.d.ts.map +1 -1
  23. package/dist/reporter/template.js +517 -85
  24. package/dist/reporter/template.js.map +1 -1
  25. package/dist/reporter/writeReport.d.ts +2 -2
  26. package/dist/reporter/writeReport.d.ts.map +1 -1
  27. package/dist/reporter/writeReport.js +3 -3
  28. package/dist/reporter/writeReport.js.map +1 -1
  29. package/dist/runner/approve.d.ts +34 -9
  30. package/dist/runner/approve.d.ts.map +1 -1
  31. package/dist/runner/approve.js +54 -10
  32. package/dist/runner/approve.js.map +1 -1
  33. package/dist/runner/approveFrom.d.ts +58 -0
  34. package/dist/runner/approveFrom.d.ts.map +1 -0
  35. package/dist/runner/approveFrom.js +384 -0
  36. package/dist/runner/approveFrom.js.map +1 -0
  37. package/dist/runner/breakpointPasses.d.ts +39 -0
  38. package/dist/runner/breakpointPasses.d.ts.map +1 -0
  39. package/dist/runner/breakpointPasses.js +107 -0
  40. package/dist/runner/breakpointPasses.js.map +1 -0
  41. package/dist/runner/bridges/database.d.ts +4 -3
  42. package/dist/runner/bridges/database.d.ts.map +1 -1
  43. package/dist/runner/bridges/database.js +4 -3
  44. package/dist/runner/bridges/database.js.map +1 -1
  45. package/dist/runner/exitCode.d.ts +34 -0
  46. package/dist/runner/exitCode.d.ts.map +1 -0
  47. package/dist/runner/exitCode.js +41 -0
  48. package/dist/runner/exitCode.js.map +1 -0
  49. package/dist/runner/manifest.d.ts +149 -0
  50. package/dist/runner/manifest.d.ts.map +1 -0
  51. package/dist/runner/manifest.js +229 -0
  52. package/dist/runner/manifest.js.map +1 -0
  53. package/dist/runner/mode.d.ts +41 -0
  54. package/dist/runner/mode.d.ts.map +1 -0
  55. package/dist/runner/mode.js +43 -0
  56. package/dist/runner/mode.js.map +1 -0
  57. package/dist/runner/orphanScan.d.ts +68 -0
  58. package/dist/runner/orphanScan.d.ts.map +1 -0
  59. package/dist/runner/orphanScan.js +150 -0
  60. package/dist/runner/orphanScan.js.map +1 -0
  61. package/dist/runner/run.d.ts +85 -1
  62. package/dist/runner/run.d.ts.map +1 -1
  63. package/dist/runner/run.js +267 -41
  64. package/dist/runner/run.js.map +1 -1
  65. package/dist/runner/runAction.d.ts +16 -0
  66. package/dist/runner/runAction.d.ts.map +1 -1
  67. package/dist/runner/runAction.js +112 -13
  68. package/dist/runner/runAction.js.map +1 -1
  69. package/dist/runner/runStory.d.ts +15 -4
  70. package/dist/runner/runStory.d.ts.map +1 -1
  71. package/dist/runner/runStory.js +41 -19
  72. package/dist/runner/runStory.js.map +1 -1
  73. package/dist/runner/storyFilter.d.ts +8 -0
  74. package/dist/runner/storyFilter.d.ts.map +1 -1
  75. package/dist/runner/storyFilter.js +11 -0
  76. package/dist/runner/storyFilter.js.map +1 -1
  77. package/dist/schema/action.d.ts +11 -11
  78. package/dist/schema/result.d.ts +125 -6
  79. package/dist/schema/result.d.ts.map +1 -1
  80. package/dist/schema/result.js.map +1 -1
  81. package/dist/screenshots/baselineStore.d.ts +92 -10
  82. package/dist/screenshots/baselineStore.d.ts.map +1 -1
  83. package/dist/screenshots/baselineStore.js +102 -18
  84. package/dist/screenshots/baselineStore.js.map +1 -1
  85. package/dist/screenshots/capture.d.ts +10 -5
  86. package/dist/screenshots/capture.d.ts.map +1 -1
  87. package/dist/screenshots/capture.js +31 -18
  88. package/dist/screenshots/capture.js.map +1 -1
  89. package/package.json +1 -1
@@ -6,6 +6,17 @@ const STATUS_LABELS = {
6
6
  skipped: 'skipped',
7
7
  new: 'new baseline',
8
8
  };
9
+ /**
10
+ * The matcher token for a breakpoint bucket key, shared by the filter pills and
11
+ * the `[data-breakpoint]` hooks so a token always matches its container. The
12
+ * empty-string key (the defensive parse-guard bucket where a result carried no
13
+ * breakpoint) maps to the reserved `legacy` token — the same string
14
+ * {@link renderDeletedEntry} uses for the pre-breakpoint layout. Every other key
15
+ * is the real breakpoint name, passed through verbatim.
16
+ */
17
+ function breakpointToken(key) {
18
+ return key === '' ? 'legacy' : key;
19
+ }
9
20
  const STATUS_MARKERS = {
10
21
  pass: '✓',
11
22
  changed: '~',
@@ -17,8 +28,15 @@ const STATUS_MARKERS = {
17
28
  * Static HTML report. Rocks a terminal, dev-tool aesthetic. Dark mode by
18
29
  * default, monospace font for data, sharp borders, and minimal but
19
30
  * evocative icons + neon colors to indicate statuses.
31
+ *
32
+ * `<main>` reading order: an optional environment-mismatch banner (first, only
33
+ * when `environment.mismatch`), then the summary, the stories, and an optional
34
+ * deleted (orphaned-baseline) section last. The banner and deleted section are
35
+ * peer `<h2>` landmarks; the two supplementary per-action notes (candidate /
36
+ * a11y-drift) contribute no heading levels, so the outline stays
37
+ * H1 → summary → stories → deleted.
20
38
  */
21
- export function renderReport(result, reportDir) {
39
+ export function renderReport(result, reportDir, interactiveMode = false) {
22
40
  const dateLabel = formatDate(result.finishedAt);
23
41
  return `
24
42
  <!doctype html>
@@ -40,98 +58,258 @@ export function renderReport(result, reportDir) {
40
58
  </p>
41
59
  </header>
42
60
  <main id="main" tabindex="-1">
61
+ ${renderEnvMismatch(result)}
43
62
  ${renderSummary(result)}
44
- ${renderStories(result, reportDir)}
63
+ ${renderStories(result, reportDir, interactiveMode)}
64
+ ${renderDeleted(result)}
45
65
  </main>
46
66
  <script src="assets/report.js"></script>
47
67
  </body>
48
68
  </html>
49
69
  `;
50
70
  }
71
+ /**
72
+ * The status totals double as the report's filter controls: each is a native
73
+ * `<button aria-pressed>` single-select filter, with the "stories" total acting
74
+ * as the "show all / clear" control (pressed by default). The expand-all /
75
+ * collapse-all pair sits at the right end of the same row — the slot the old
76
+ * coverage stats used to occupy. The `<ul>` stays a plain list (no composite
77
+ * role): a non-filter total and the bulk-toggle group share it, so a
78
+ * radiogroup/fieldset could not cleanly scope just the filters.
79
+ *
80
+ * A second, independent filter dimension — the breakpoint filter — renders
81
+ * BELOW the totals row inside this same summary region (see
82
+ * {@link renderBreakpointFilters}), but only when the run spans two or more
83
+ * distinct breakpoints. The two dimensions are ANDed by report.js.
84
+ */
51
85
  function renderSummary(result) {
52
- const screens = result.customCoverage.screens;
53
- const flows = result.customCoverage.flows;
54
86
  return `
55
87
  <section class="summary" aria-labelledby="summary-heading">
56
88
  <h2 id="summary-heading">summary</h2>
57
89
  <ul class="summary-list" aria-label="Run totals">
58
- ${summaryItem('stories', result.totals.stories)}
59
- ${summaryItem('passed', result.totals.passed, 'pass')}
60
- ${summaryItem('changed', result.totals.changed, 'changed')}
61
- ${summaryItem('failed', result.totals.failed, 'failed')}
62
- ${coverageItem('screens', screens)}
63
- ${coverageItem('flows', flows)}
90
+ ${summaryFilter('stories', result.totals.stories, 'all', true, ' — show all stories')}
91
+ ${summaryFilter('passed', result.totals.passed, 'pass', false, ', show only passed stories')}
92
+ ${summaryFilter('new', result.totals.new, 'new', false, ', show only new stories')}
93
+ ${summaryFilter('changed', result.totals.changed, 'changed', false, ', show only changed stories')}
94
+ ${summaryFilter('failed', result.totals.failed, 'failed', false, ', show only failed stories')}
95
+ <li class="story-bulk-toggle">
96
+ <button type="button" class="story-bulk-toggle-button" data-bulk-toggle="expand"><span class="bulk-verb">Expand</span><span class="bulk-scope-sr sr-only"> all screenshots</span></button>
97
+ <span class="bulk-sep" aria-hidden="true">/</span>
98
+ <button type="button" class="story-bulk-toggle-button" data-bulk-toggle="collapse"><span class="bulk-verb">Collapse</span><span class="bulk-scope-sr sr-only"> all screenshots</span></button>
99
+ <span class="bulk-scope" aria-hidden="true">screenshots</span>
100
+ </li>
64
101
  </ul>
102
+ ${renderBreakpointFilters(result)}
65
103
  </section>
66
104
  `;
67
105
  }
68
- function coverageItem(label, metric) {
69
- const pct = `${(metric.ratio * 100).toFixed(0)}%`;
106
+ /**
107
+ * The distinct breakpoints a run spanned, in first-seen order across every
108
+ * story's flat action array — the SAME order {@link renderStoryActions} buckets
109
+ * a single story's groups in, extended across stories. Each distinct breakpoint
110
+ * carries the capture dimensions of the FIRST action seen at it (the same source
111
+ * {@link renderBreakpointGroup} labels a group with), so the filter pill can
112
+ * echo the size.
113
+ *
114
+ * The bucket KEY is `action.breakpoint ?? ''` — identical to
115
+ * {@link renderStoryActions}, so a filter token always matches a rendered
116
+ * `[data-breakpoint]` container. The empty-string key is a defensive parse-guard
117
+ * only (the runner always tags a real breakpoint name); it maps to the reserved
118
+ * `legacy` token/label. A live `ActionResult` never carries the literal
119
+ * `'legacy'` breakpoint — that synthetic name is confined to orphaned-baseline
120
+ * (`deleted`) entries — so the empty-string bucket and a real `legacy` bucket
121
+ * cannot collide within one run's stories.
122
+ */
123
+ function distinctBreakpoints(result) {
124
+ const seen = new Set();
125
+ const distinct = [];
126
+ for (const story of result.stories) {
127
+ for (const action of story.actions) {
128
+ const key = action.breakpoint ?? '';
129
+ if (seen.has(key))
130
+ continue;
131
+ seen.add(key);
132
+ distinct.push({
133
+ key,
134
+ width: action.breakpointWidth,
135
+ height: action.breakpointHeight,
136
+ });
137
+ }
138
+ }
139
+ return distinct;
140
+ }
141
+ /**
142
+ * The breakpoint filter group: a second single-select filter dimension, sibling
143
+ * to the status totals inside the summary region. Rendered ONLY when the run
144
+ * spans two or more distinct breakpoints — a single-breakpoint run makes the
145
+ * filter a no-op, so the whole group is omitted.
146
+ *
147
+ * Each pill is a native `<button aria-pressed>` toggle (like the status filter),
148
+ * grouped in a `role="group"` with an accessible label so assistive tech reads
149
+ * the two rows as distinct filter axes. A leading "all breakpoints" reset is
150
+ * pressed by default and clears the dimension. The neutral palette (outline
151
+ * default, filled + bold when pressed) carries no status hue, so the two rows
152
+ * read as siblings without implying a status.
153
+ *
154
+ * The visible `.indicator` carries the human LABEL (breakpoint name + decorative
155
+ * dimensions), while `data-breakpoint-filter` carries the MATCHER TOKEN (the
156
+ * exact bucket key). report.js echoes the label, matches on the token against a
157
+ * container's `data-breakpoint`. The blank-bucket token/label is `legacy` (with
158
+ * the shared "(pre-breakpoint layout)" sr-only clarifier); every other token is
159
+ * the real breakpoint name. Dimensions reuse the exact idiom of
160
+ * {@link renderBreakpointGroup}: a decorative aria-hidden `375×667` plus an
161
+ * sr-only `375 by 667 pixels` longhand so the `×` glyph is never read as "x".
162
+ */
163
+ function renderBreakpointFilters(result) {
164
+ const distinct = distinctBreakpoints(result);
165
+ if (distinct.length < 2) {
166
+ return '';
167
+ }
168
+ const pills = distinct.map((bp) => breakpointFilter(bp)).join('\n ');
70
169
  return `
71
- <li class="summary-item coverage">
72
- <span class="count">${pct}</span>
73
- <span class="label">${label}</span>
74
- <span class="coverage-detail" aria-hidden="true">· ${metric.covered}/${metric.total}</span>
75
- <span class="sr-only">${metric.covered} of ${metric.total} ${label} covered</span>
76
- </li>
170
+ <div class="breakpoint-filters" role="group" aria-label="Filter by breakpoint">
171
+ <button type="button" class="breakpoint-filter" data-breakpoint-filter="all" aria-pressed="true" aria-controls="stories-list">
172
+ <span class="indicator label">all breakpoints</span><span class="sr-only">, show results at every breakpoint</span>
173
+ </button>
174
+ ${pills}
175
+ </div>
77
176
  `;
78
177
  }
79
- function summaryItem(label, value, statusKey) {
178
+ /**
179
+ * One breakpoint rendered as an unpressed single-select filter pill. The blank
180
+ * bucket (`key === ''`) reads visibly as "legacy" with an sr-only
181
+ * "(pre-breakpoint layout)" clarifier, mirroring {@link renderDeletedEntry};
182
+ * both the visible name and the `data-breakpoint-filter` token become the
183
+ * reserved `legacy` string so the token still matches the flat list's
184
+ * `data-breakpoint="legacy"` hook. Dimensions render only when the recorded
185
+ * result carried them.
186
+ */
187
+ function breakpointFilter(bp) {
188
+ const isLegacy = bp.key === '';
189
+ const token = breakpointToken(bp.key);
190
+ const name = isLegacy
191
+ ? 'legacy<span class="sr-only"> (pre-breakpoint layout)</span>'
192
+ : `${escapeHtml(bp.key)}`;
193
+ const dimensions = bp.width !== undefined && bp.height !== undefined
194
+ ? ` <span class="breakpoint-dimensions" aria-hidden="true">${bp.width}×${bp.height}</span><span class="sr-only"> ${bp.width} by ${bp.height} pixels</span>`
195
+ : '';
196
+ return `<button type="button" class="breakpoint-filter" data-breakpoint-filter="${escapeHtml(token)}" aria-pressed="false" aria-controls="stories-list">
197
+ <span class="indicator label"><span class="breakpoint-name">${name}</span>${dimensions}</span><span class="sr-only">, show only results at this breakpoint</span>
198
+ </button>`;
199
+ }
200
+ /**
201
+ * One status total rendered as a single-select filter button. The visible count
202
+ * sits OUTSIDE the button as a sibling span and is wired to it via
203
+ * aria-describedby, so only the word ("passed") is the pill button while the
204
+ * count still reads as the button's description. The accessible name is the
205
+ * visible label plus a visually-hidden action suffix (e.g. ", show only passed
206
+ * stories"), composed from contents so the visible text is never dropped (WCAG
207
+ * 2.5.3 — never an aria-label). The `data-filter` token ("pass") is kept
208
+ * distinct from the visible label ("passed") so report.js matches
209
+ * `story[data-status="pass"]`.
210
+ */
211
+ function summaryFilter(label, value, filter, pressed, actionSuffix) {
80
212
  return `
81
- <li class="summary-item"${statusKey ? ` data-status="${statusKey}"` : ''}>
82
- <span class="count">${value}</span>
83
- <span class="indicator label">${label}</span>
213
+ <li class="summary-item" data-status="${filter}">
214
+ <span class="count" id="summary-count-${filter}">${value}</span><button type="button" class="summary-filter" data-filter="${filter}" aria-pressed="${pressed}" aria-controls="stories-list" aria-describedby="summary-count-${filter}"><span class="indicator label">${label}</span><span class="sr-only">${actionSuffix}</span></button>
215
+ <span class="bulk-sep" aria-hidden="true">·</span>
84
216
  </li>
85
217
  `;
86
218
  }
87
- function renderStories(result, reportDir) {
219
+ /**
220
+ * Environment-mismatch banner, rendered first inside `<main>` (above the
221
+ * summary) ONLY when `environment.mismatch === true` — i.e. a CI run whose
222
+ * committed baselines were captured under a different, pixel-affecting
223
+ * environment, or whose committed manifest could not be read at all.
224
+ *
225
+ * `role="alert"` is an explicit acceptance criterion. It does NOT auto-announce
226
+ * on content present at load, so the real discoverability comes from the banner
227
+ * being first in reading order behind a `<h2>` landmark. Kept because it is
228
+ * harmless and specified.
229
+ *
230
+ * The "warning" meaning is carried by the TEXT (the heading word and body copy),
231
+ * never by color alone (WCAG 1.4.1): body text stays `--text` / amber
232
+ * `--changed`, and the red accent lives only on the section's left border (a
233
+ * non-text ≥3:1 cue, mirroring `.action-error`).
234
+ *
235
+ * Two copy branches: the `['manifest']` sentinel (committed manifest unreadable,
236
+ * so the diverging keys can't be enumerated) drops the key list for a plain
237
+ * "can't be verified" message; any other mismatch lists the diverging keys.
238
+ */
239
+ function renderEnvMismatch(result) {
240
+ const environment = result.environment;
241
+ if (environment?.mismatch !== true) {
242
+ return '';
243
+ }
244
+ const isManifestSentinel = environment.mismatchKeys.length === 1 &&
245
+ environment.mismatchKeys[0] === 'manifest';
246
+ const body = isManifestSentinel
247
+ ? `<p>The committed baseline manifest could not be read, so the capture environment can't be verified. Expect a full re-approve.</p>`
248
+ : `<p>Expect a full re-approve — baselines were captured under a different environment.</p>
249
+ <ul aria-label="Changed environment keys">
250
+ ${environment.mismatchKeys
251
+ .map((key) => `<li><code>${escapeHtml(key)}</code></li>`)
252
+ .join('\n ')}
253
+ </ul>`;
254
+ return `
255
+ <section class="env-mismatch" role="alert" aria-labelledby="env-mismatch-heading">
256
+ <h2 id="env-mismatch-heading">capture environment changed</h2>
257
+ ${body}
258
+ </section>
259
+ `;
260
+ }
261
+ /**
262
+ * Deleted (orphaned committed baselines) section — a peer landmark rendered
263
+ * AFTER the stories section, inside `<main>`, ONLY when `deleted` is non-empty.
264
+ * Lists each orphaned baseline (action + breakpoint) that `approve --prune`
265
+ * would remove. A `'legacy'` breakpoint (the pre-breakpoint `<action>/0.png`
266
+ * layout) reads cryptically on its own, so it carries an sr-only clarifier.
267
+ */
268
+ function renderDeleted(result) {
269
+ if (result.deleted.length === 0) {
270
+ return '';
271
+ }
272
+ const items = result.deleted
273
+ .map((entry) => renderDeletedEntry(entry))
274
+ .join('\n ');
275
+ return `
276
+ <section class="deleted" aria-labelledby="deleted-heading">
277
+ <h2 id="deleted-heading">deleted</h2>
278
+ <p>These committed baselines have no matching story this run. <code>tuffgal approve --prune</code> removes them.</p>
279
+ <ul aria-label="Orphaned baselines to be pruned on approve">
280
+ ${items}
281
+ </ul>
282
+ </section>
283
+ `;
284
+ }
285
+ function renderDeletedEntry(entry) {
286
+ const breakpoint = entry.breakpoint === 'legacy'
287
+ ? 'legacy<span class="sr-only"> (pre-breakpoint layout)</span>'
288
+ : escapeHtml(entry.breakpoint);
289
+ return `<li><code>${escapeHtml(entry.action)}</code> ${breakpoint}</li>`;
290
+ }
291
+ function renderStories(result, reportDir, interactiveMode) {
88
292
  const items = result.stories
89
- .map((story, index) => renderStory(story, index, reportDir))
293
+ .map((story, index) => renderStory(story, index, reportDir, interactiveMode))
90
294
  .join('\n');
91
295
  const total = result.stories.length;
296
+ // The interactive controls now live in the summary row; this section keeps
297
+ // only the two live regions (both server-rendered between the heading and the
298
+ // list so a screen reader announces filter/bulk changes reliably) and the
299
+ // list itself, whose id the summary filter buttons target via aria-controls.
92
300
  return `
93
301
  <section aria-labelledby="stories-heading">
94
302
  <h2 id="stories-heading">stories</h2>
95
- <div class="stories-toolbar">
96
- <fieldset class="story-filter">
97
- <legend class="sr-only">filter</legend>
98
- ${storyFilterRadio('all', true)}
99
- ${storyFilterRadio('passed', false)}
100
- ${storyFilterRadio('changed', false)}
101
- ${storyFilterRadio('failed', false)}
102
- </fieldset>
103
- <p class="story-filter-status" role="status" aria-live="polite">Showing all ${total} stories</p>
104
- <div class="story-bulk-toggle">
105
- <button type="button" class="chip story-bulk-toggle-button" data-bulk-toggle="expand">Expand all</button>
106
- <button type="button" class="chip story-bulk-toggle-button" data-bulk-toggle="collapse">Collapse all</button>
107
- </div>
108
- <p class="bulk-toggle-status sr-only" role="status" aria-live="polite"></p>
109
- </div>
110
- <ol class="stories" aria-label="Stories executed in dependency order">
303
+ <p class="story-filter-status" role="status" aria-live="polite" aria-atomic="true">Showing all ${total} stories</p>
304
+ <p class="bulk-toggle-status sr-only" role="status" aria-live="polite" aria-atomic="true"></p>
305
+ <ol class="stories" id="stories-list" aria-label="Stories executed in dependency order">
111
306
  ${items}
112
307
  </ol>
113
308
  <p class="stories-empty" hidden>No matching stories</p>
114
309
  </section>
115
310
  `;
116
311
  }
117
- function storyFilterRadio(status, checked) {
118
- const inputId = `story-filter-${status}`;
119
- const value = status === 'passed' ? 'pass' : status;
120
- return `
121
- <label for="${inputId}" class="chip chip--toggle story-filter-label">
122
- <input
123
- type="radio"
124
- name="story-filter"
125
- id="${inputId}"
126
- value="${value}"
127
- data-filter-name="${status}"
128
- ${checked ? 'checked' : ''}
129
- />
130
- ${status}
131
- </label>
132
- `;
133
- }
134
- function renderStory(story, storyIndex, reportDir) {
312
+ function renderStory(story, storyIndex, reportDir, interactiveMode) {
135
313
  return `
136
314
  <li class="story" data-status="${story.status}">
137
315
  <div class="story-row">
@@ -142,7 +320,7 @@ function renderStory(story, storyIndex, reportDir) {
142
320
  </div>
143
321
  <p class="story-prose">${escapeHtml(story.story)}</p>
144
322
  <br/>
145
- ${renderStoryActions(story, storyIndex, reportDir)}
323
+ ${renderStoryActions(story, storyIndex, reportDir, interactiveMode)}
146
324
  </li>
147
325
  `;
148
326
  }
@@ -165,7 +343,7 @@ function renderStory(story, storyIndex, reportDir) {
165
343
  * shot-panel `id`s stay globally unique across groups and report.js's
166
344
  * `name="<actionId>-shot"` radio grouping never collides between modes.
167
345
  */
168
- function renderStoryActions(story, storyIndex, reportDir) {
346
+ function renderStoryActions(story, storyIndex, reportDir, interactiveMode) {
169
347
  // Bucket actions by breakpoint, preserving first-seen order.
170
348
  const order = [];
171
349
  const buckets = new Map();
@@ -178,17 +356,20 @@ function renderStoryActions(story, storyIndex, reportDir) {
178
356
  buckets.get(key).push({ action, id: `s${storyIndex}-a${actionIndex}` });
179
357
  });
180
358
  // One mode (or untagged) → flat list, no caption. Captions only earn their
181
- // chrome when there is more than one mode to tell apart.
359
+ // chrome when there is more than one mode to tell apart. The flat list still
360
+ // carries the `data-breakpoint` hook (keyed off the sole bucket) so the
361
+ // breakpoint filter can hide the whole single-mode story when it does not
362
+ // match — no visible chrome is added, only the filter hook.
182
363
  if (order.length <= 1) {
183
364
  const actions = story.actions
184
- .map((action, actionIndex) => renderAction(action, `s${storyIndex}-a${actionIndex}`, reportDir))
365
+ .map((action, actionIndex) => renderAction(action, `s${storyIndex}-a${actionIndex}`, reportDir, interactiveMode))
185
366
  .join('\n');
186
- return `<ol class="actions" aria-label="Actions">
367
+ return `<ol class="actions" aria-label="Actions" data-breakpoint="${escapeHtml(breakpointToken(order[0] ?? ''))}">
187
368
  ${actions}
188
369
  </ol>`;
189
370
  }
190
371
  return order
191
- .map((key, groupIndex) => renderBreakpointGroup(key, buckets.get(key), `s${storyIndex}-bp${groupIndex}`, reportDir))
372
+ .map((key, groupIndex) => renderBreakpointGroup(key, buckets.get(key), `s${storyIndex}-bp${groupIndex}`, reportDir, interactiveMode))
192
373
  .join('\n');
193
374
  }
194
375
  /**
@@ -208,8 +389,12 @@ function renderStoryActions(story, storyIndex, reportDir) {
208
389
  * Dimensions render in their own `aria-hidden` span (decorative "375×667")
209
390
  * with an sr-only longhand ("375 by 667 pixels") so screen readers don't read
210
391
  * the `×` glyph as "x" mid-stream.
392
+ *
393
+ * The group `<div>` additionally carries a `data-breakpoint` hook (the bucket
394
+ * key's matcher token) so the breakpoint filter can hide a whole group whose
395
+ * mode is not selected. Purely additive — the caption/label idiom is untouched.
211
396
  */
212
- function renderBreakpointGroup(key, entries, groupId, reportDir) {
397
+ function renderBreakpointGroup(key, entries, groupId, reportDir, interactiveMode) {
213
398
  const labelId = `${groupId}-label`;
214
399
  const name = key;
215
400
  // The capture dimensions recorded on the result reflect per-config and
@@ -220,14 +405,12 @@ function renderBreakpointGroup(key, entries, groupId, reportDir) {
220
405
  recorded.breakpointHeight !== undefined
221
406
  ? { width: recorded.breakpointWidth, height: recorded.breakpointHeight }
222
407
  : undefined;
223
- const dimensionMarkup = dimensions
224
- ? `<span class="breakpoint-dimensions" aria-hidden="true">${dimensions.width}×${dimensions.height}</span><span class="sr-only">${dimensions.width} by ${dimensions.height} pixels</span>`
225
- : '';
408
+ const dimensionMarkup = dimensions ? dimensionPair(dimensions) : '';
226
409
  const actions = entries
227
- .map(({ action, id }) => renderAction(action, id, reportDir))
410
+ .map(({ action, id }) => renderAction(action, id, reportDir, interactiveMode))
228
411
  .join('\n');
229
412
  return `
230
- <div class="breakpoint-group">
413
+ <div class="breakpoint-group" data-breakpoint="${escapeHtml(breakpointToken(key))}">
231
414
  <p class="breakpoint-label" id="${labelId}">
232
415
  <span class="breakpoint-name">${escapeHtml(name)}</span>
233
416
  ${dimensionMarkup}
@@ -239,8 +422,8 @@ function renderBreakpointGroup(key, entries, groupId, reportDir) {
239
422
  </div>
240
423
  `;
241
424
  }
242
- function renderAction(action, actionId, reportDir) {
243
- const screenshots = renderScreenshots(action, actionId, reportDir);
425
+ function renderAction(action, actionId, reportDir, interactiveMode) {
426
+ const screenshots = renderScreenshots(action, actionId, reportDir, interactiveMode);
244
427
  const errorBlock = action.status === 'failed'
245
428
  ? `<pre class="action-error">${escapeHtml(action.failureMessage ?? 'unknown error')}</pre>`
246
429
  : '';
@@ -264,14 +447,56 @@ ${statusBadge(action.status)}
264
447
  ${rowInner}
265
448
  </div>
266
449
  `;
450
+ const notes = renderActionNotes(action, reportDir);
267
451
  return `
268
452
  <li class="action" data-status="${action.status}">
269
453
  ${row}
270
454
  ${parameters}
271
455
  ${errorBlock}
456
+ ${notes}
272
457
  </li>
273
458
  `;
274
459
  }
460
+ /**
461
+ * Supplementary per-action prose, rendered inside `li.action` after the
462
+ * row/screenshots/error block. Both are plain `<p role="note">` — NOT headings:
463
+ * they live in the story `<ol>` subtree, so a heading would orphan under a
464
+ * list-item (same rationale as {@link renderBreakpointGroup}'s caption). Neither
465
+ * is a live region (present at load), so an a11y-only `changed` row can carry
466
+ * BOTH with no double-announce.
467
+ *
468
+ * - candidate note: on `changed`/`new` rows only — supplementary prose flagging
469
+ * "this run's actual screenshot is the proposed new baseline". Names the
470
+ * Actual variant explicitly (the note sits OUTSIDE the <details>, so "this
471
+ * render" would point at whichever tab is selected — or nothing when
472
+ * collapsed). No aria wiring; it does not duplicate the status badge's
473
+ * sr-only label.
474
+ * - a11y-drift note: gated STRICTLY on `a11yChanged === true`, NEVER inferred
475
+ * from a missing `diffPath` (a size-mismatch `changed` row also lacks a
476
+ * diffPath yet deliberately omits `a11yChanged`; keying on `!diffPath` would
477
+ * misfire on it — the load-bearing discriminator, per the ActionResult
478
+ * contract). Renders the recorded `a11yBaselinePath` as plain escaped
479
+ * `<code>` TEXT, not a link (the report is portable / opened over file://),
480
+ * relativized like the other paths when absolute.
481
+ *
482
+ * Order: candidate note first, then a11y-drift note, so an a11y-only changed row
483
+ * reads "proposed new baseline" before the a11y specifics.
484
+ */
485
+ function renderActionNotes(action, reportDir) {
486
+ const notes = [];
487
+ if (action.status === 'changed' || action.status === 'new') {
488
+ notes.push(`<p class="candidate-note" role="note">This run's actual screenshot is the proposed new baseline.</p>`);
489
+ }
490
+ if (action.a11yChanged === true) {
491
+ const path = action.a11yBaselinePath;
492
+ const displayPath = path !== undefined ? toReportRelative(reportDir, path) : undefined;
493
+ const pathMarkup = displayPath !== undefined
494
+ ? ` Proposed a11y baseline written to <code>${escapeHtml(displayPath)}</code>.`
495
+ : '';
496
+ notes.push(`<p class="a11y-drift-note" role="note">Accessibility snapshot changed.${pathMarkup}</p>`);
497
+ }
498
+ return notes.join('\n ');
499
+ }
275
500
  function renderParameters(parameters) {
276
501
  if (!parameters || Object.keys(parameters).length === 0) {
277
502
  return '';
@@ -281,11 +506,43 @@ function renderParameters(parameters) {
281
506
  .join('');
282
507
  return `<dl class="action-parameters">${entries}</dl>`;
283
508
  }
284
- function renderScreenshots(action, actionId, reportDir) {
509
+ /**
510
+ * A "changed" row whose diff could not be computed: baseline and actual differ
511
+ * in dimensions, so pixelmatch throws before producing a diffRatio or diff
512
+ * image, leaving only the recorded failureMessage. Centralizes the predicate
513
+ * shared by the radio fallback, the diff radio's describedby wiring, and
514
+ * renderDiffStats' unavailable note.
515
+ */
516
+ function isDiffUnavailable(action) {
517
+ return (action.diffRatio === undefined &&
518
+ action.status === 'changed' &&
519
+ !!action.failureMessage);
520
+ }
521
+ function renderScreenshots(action, actionId, reportDir, interactiveMode) {
285
522
  if (!action.actualPath && !action.baselinePath) {
286
523
  return '';
287
524
  }
288
- const baseline = action.baselinePath
525
+ // interactiveMode swaps the radio-tab output for the single-image
526
+ // press-and-hold viewer. When it is false the function falls through to the
527
+ // radio-tab output below, byte-identical with the pre-interactiveMode render.
528
+ //
529
+ // When the baseline and actual differ in dimensions the diff is uncomputable
530
+ // (renderDiffStats' unavailable branch — both paths present, `changed` status,
531
+ // a recorded failureMessage, no diffRatio). Press-flipping between two
532
+ // differently-sized captures misaligns everything and reads as "everything
533
+ // changed", so fall back to the radio-tab render: visible panels, baseline +
534
+ // actual, and the disabled diff option carrying the mismatch reason.
535
+ const diffUncomputable = action.baselinePath !== undefined &&
536
+ action.actualPath !== undefined &&
537
+ isDiffUnavailable(action);
538
+ if (interactiveMode && !diffUncomputable) {
539
+ return renderInteractiveScreenshots(action, actionId, reportDir);
540
+ }
541
+ // A `new` baseline writes its baseline straight from this run's actual, so the
542
+ // two paths point at byte-identical PNGs. There is no prior state to compare
543
+ // against, so suppress the baseline variant for display — like the diff, it is
544
+ // not a real artifact here. The path stays in the data model for `approve`.
545
+ const baseline = action.baselinePath && action.status !== 'new'
289
546
  ? toReportRelative(reportDir, action.baselinePath)
290
547
  : undefined;
291
548
  const actual = action.actualPath
@@ -294,30 +551,205 @@ function renderScreenshots(action, actionId, reportDir) {
294
551
  const diff = action.diffPath
295
552
  ? toReportRelative(reportDir, action.diffPath)
296
553
  : undefined;
297
- const defaultTab = diff ? 'diff' : 'actual';
554
+ const defaultTab = 'actual';
298
555
  const available = { baseline, actual, diff };
299
556
  const initialTab = available[defaultTab] !== undefined
300
557
  ? defaultTab
301
558
  : ['baseline', 'actual', 'diff'].find((name) => available[name] !== undefined);
302
559
  const diffStatsId = `${actionId}-diff-stats`;
303
- const diffStats = action.diffRatio !== undefined
304
- ? `<p class="diff-stats" id="${diffStatsId}"><span class="count">${parseFloat((action.diffRatio * 100).toFixed(2))}%</span> <span class="label">differs</span> <span class="coverage-detail">· ${(action.diffPixels ?? 0).toLocaleString('en-US')} pixels</span></p>`
305
- : '';
560
+ const diffStats = renderDiffStats(action, diffStatsId);
561
+ // renderDiffStats emits the `diff-stats--unavailable` note (with this id) when
562
+ // the diff is uncomputable. The diff radio is disabled in that case, so wire
563
+ // its aria-describedby to the note — a keyboard/AT user reaching the disabled
564
+ // diff option then hears why no diff exists. (The diff PANEL only takes the
565
+ // association when a real diff image renders, below.)
566
+ const diffNoteUnavailable = isDiffUnavailable(action);
567
+ // When only one variant is real (the common case for a `new` baseline, whose
568
+ // baseline is suppressed and has no diff), a radio group offering a single
569
+ // choice is noise to AT. Drop the controls and show the image alone; the row's
570
+ // status badge already announces "new baseline". Zero variants — a `new` row
571
+ // whose actual capture is somehow missing — collapses to nothing.
572
+ const soleVariant = ['baseline', 'actual', 'diff'].filter((name) => available[name] !== undefined);
573
+ if (soleVariant.length <= 1) {
574
+ const only = soleVariant[0];
575
+ if (only === undefined) {
576
+ return '';
577
+ }
578
+ const lone = shotPanel(actionId, only, available[only], SHOT_ALT[only](action), true);
579
+ return `${diffStats}${lone}`;
580
+ }
306
581
  return `
307
582
  <fieldset class="shot-radio" data-default-tab="${defaultTab}">
308
583
  <legend class="sr-only">Screenshot to display</legend>
309
584
  ${shotRadio(actionId, 'baseline', baseline === undefined, initialTab === 'baseline')}
310
585
  ${shotRadio(actionId, 'actual', actual === undefined, initialTab === 'actual')}
311
- ${shotRadio(actionId, 'diff', diff === undefined, initialTab === 'diff')}
586
+ ${shotRadio(actionId, 'diff', diff === undefined, initialTab === 'diff', diffNoteUnavailable ? { describedById: diffStatsId } : undefined)}
312
587
  ${diffStats}
313
588
  </fieldset>
314
- ${shotPanel(actionId, 'baseline', baseline, `${action.action} baseline screenshot`, initialTab === 'baseline')}
315
- ${shotPanel(actionId, 'actual', actual, `${action.action} actual screenshot from this run`, initialTab === 'actual')}
316
- ${shotPanel(actionId, 'diff', diff, `Pixel diff overlay for ${action.action}; changed regions are highlighted`, initialTab === 'diff', action.diffRatio !== undefined ? diffStatsId : undefined)}
589
+ ${shotPanel(actionId, 'baseline', baseline, SHOT_ALT.baseline(action), initialTab === 'baseline')}
590
+ ${shotPanel(actionId, 'actual', actual, SHOT_ALT.actual(action), initialTab === 'actual')}
591
+ ${shotPanel(actionId, 'diff', diff, SHOT_ALT.diff(action), initialTab === 'diff', action.diffRatio !== undefined ? diffStatsId : undefined)}
317
592
  `;
318
593
  }
319
- function shotRadio(actionId, name, disabled, checked) {
594
+ /** Alt text per screenshot variant, shared by the radio-tab and collapsed render. */
595
+ const SHOT_ALT = {
596
+ baseline: (action) => `${action.action} baseline screenshot`,
597
+ actual: (action) => `${action.action} actual screenshot from this run`,
598
+ diff: (action) => `Pixel diff overlay for ${action.action}; changed regions are highlighted`,
599
+ };
600
+ /**
601
+ * The pixel-diff overlay only exists when baseline and actual share dimensions.
602
+ * A mismatch (e.g. a fullPage capture whose document grew taller) throws before
603
+ * a diff is computed, so `diffRatio` is absent and there is no diff image to
604
+ * show — surface the recorded reason here, in the slot the "% differs" stat
605
+ * would normally occupy, so a "changed" row never reads as an unexplained no-op.
606
+ *
607
+ * The mismatch note folds baseline and actual sizes into ONE sentence and renders
608
+ * each dimension pair in the file's split idiom (a `breakpoint-dimensions` span
609
+ * carrying the `×` glyph for sight, plus an `sr-only` "W by H pixels" longhand for
610
+ * AT — never a bare `x`). It reads those numbers from the structured
611
+ * `action.sizeMismatch` pair, NOT by parsing `failureMessage`. When that pair is
612
+ * absent (a malformed or older result), it falls back to escaping the recorded
613
+ * `failureMessage` prose rather than risk a wrong parse.
614
+ */
615
+ function renderDiffStats(action, diffStatsId) {
616
+ if (action.diffRatio !== undefined) {
617
+ return `<p class="diff-stats" id="${diffStatsId}"><span class="count">${parseFloat((action.diffRatio * 100).toFixed(2))}%</span> <span class="label">differs</span> <span class="coverage-detail">· ${(action.diffPixels ?? 0).toLocaleString('en-US')} pixels</span></p>`;
618
+ }
619
+ if (action.status !== 'changed' || !action.failureMessage) {
620
+ return '';
621
+ }
622
+ const label = action.sizeMismatch
623
+ ? `No pixel diff — screenshot resized from ${dimensionPair(action.sizeMismatch.baseline)} to ${dimensionPair(action.sizeMismatch.actual)}.`
624
+ : `No pixel diff. ${escapeHtml(action.failureMessage)}`;
625
+ return `<p class="diff-stats diff-stats--unavailable" id="${diffStatsId}"><span class="label">${label}</span></p>`;
626
+ }
627
+ /**
628
+ * A `width`×`height` pair in the file's split idiom: the `×` glyph is sight-only
629
+ * (aria-hidden), and AT hears the spoken "W by H pixels" longhand instead of an
630
+ * ambiguous "x". Shared by {@link renderDiffStats} (the size-mismatch note) and
631
+ * {@link renderBreakpointGroup} (the per-mode caption), which emit byte-identical
632
+ * markup. {@link breakpointFilter} renders the same idiom but keeps its own
633
+ * inline copy: its sr-only longhand carries a leading space (` W by H pixels`)
634
+ * that this helper omits, so routing it through here would change the rendered
635
+ * bytes.
636
+ */
637
+ function dimensionPair(size) {
638
+ return `<span class="breakpoint-dimensions" aria-hidden="true">${size.width}×${size.height}</span><span class="sr-only">${size.width} by ${size.height} pixels</span>`;
639
+ }
640
+ /**
641
+ * Interactive screenshot viewer (interactiveMode). One native radio group per
642
+ * action is the committed-state source of truth — keyboard, touch, and AT all
643
+ * operate the radios (reusing `name="${actionId}-shot"`), and the chips render
644
+ * permanently visible (the mouse/touch path to every variant, including Diff).
645
+ * report.js layers a VISUAL-ONLY press-and-hold blink-compare on a SINGLE
646
+ * shared `<img>`: press shows the committed variant's counterpart (baseline
647
+ * normally, actual when baseline is committed); release/leave/dragstart
648
+ * reverts. No hover behavior. The gesture rewrites only the img src and the
649
+ * aria-hidden "Showing:" caption (which names the DISPLAYED variant, preview
650
+ * included) — never radio state, the img alt, or any ARIA. The controls +
651
+ * caption live OUTSIDE the clipped `.shot-stage` so
652
+ * the focus ring is never clipped or painted over screenshot pixels. The diff
653
+ * variant — and its radio — is omitted entirely (not disabled) when this action
654
+ * produced no diff image; the existing diff-stats association then rides on the
655
+ * diff radio control rather than the image, leaving no dangling describedby.
656
+ * (Distinct from the dimension-mismatch case, where the diff is uncomputable:
657
+ * renderScreenshots never reaches this viewer and instead falls back to the
658
+ * radio-tab render with a disabled — not omitted — diff radio.)
659
+ */
660
+ function renderInteractiveScreenshots(action, actionId, reportDir) {
661
+ // A `new` baseline writes its baseline straight from this run's actual, so the
662
+ // two paths point at byte-identical PNGs. There is no prior state to compare
663
+ // against, so suppress the baseline variant for display — like the diff, it is
664
+ // omitted (no radio, no image). The path stays in the data model for `approve`.
665
+ const baseline = action.baselinePath && action.status !== 'new'
666
+ ? toReportRelative(reportDir, action.baselinePath)
667
+ : undefined;
668
+ const actual = action.actualPath
669
+ ? toReportRelative(reportDir, action.actualPath)
670
+ : undefined;
671
+ const diff = action.diffPath
672
+ ? toReportRelative(reportDir, action.diffPath)
673
+ : undefined;
674
+ // Default committed variant is `actual`; only a baseline-only row (no actual
675
+ // capture this run) commits to baseline instead. renderScreenshots' guard
676
+ // guarantees at least one of the two exists, so `committedSrc` is never empty
677
+ // at runtime — the `?? ''` only satisfies the optional types.
678
+ const committed = actual !== undefined ? 'actual' : 'baseline';
679
+ const committedSrc = (committed === 'actual' ? actual : baseline) ?? '';
680
+ // When only one variant is real (the common case for a `new` baseline, whose
681
+ // baseline is suppressed and has no diff), the press-and-hold flip has nothing
682
+ // to toggle to — a lone radio in a group is noise to AT. Render the image alone;
683
+ // the row's status badge already announces "new baseline". Zero variants — a
684
+ // `new` row whose actual capture is missing — collapses to nothing.
685
+ const variantCount = [baseline, actual, diff].filter((v) => v !== undefined).length;
686
+ if (variantCount === 0) {
687
+ return '';
688
+ }
689
+ if (variantCount === 1) {
690
+ return `
691
+ <div class="shot-stage">
692
+ <img
693
+ class="shot-image"
694
+ src="${escapeHtml(committedSrc)}"
695
+ alt="${escapeHtml(`Screenshot of ${action.action}`)}"
696
+ loading="lazy"
697
+ />
698
+ </div>
699
+ `;
700
+ }
701
+ const diffStatsId = `${actionId}-diff-stats`;
702
+ const diffStats = renderDiffStats(action, diffStatsId);
703
+ const radios = [
704
+ baseline !== undefined
705
+ ? shotRadio(actionId, 'baseline', false, committed === 'baseline', {
706
+ label: 'Baseline',
707
+ })
708
+ : '',
709
+ actual !== undefined
710
+ ? shotRadio(actionId, 'actual', false, committed === 'actual', {
711
+ label: 'Actual',
712
+ })
713
+ : '',
714
+ diff !== undefined
715
+ ? shotRadio(actionId, 'diff', false, false, {
716
+ label: 'Diff',
717
+ describedById: diffStats !== '' ? diffStatsId : undefined,
718
+ })
719
+ : '',
720
+ ].join('');
721
+ const captionLabel = committed === 'actual' ? 'Actual' : 'Baseline';
722
+ const dataSrc = [
723
+ baseline !== undefined
724
+ ? ` data-src-baseline="${escapeHtml(baseline)}"`
725
+ : '',
726
+ actual !== undefined ? ` data-src-actual="${escapeHtml(actual)}"` : '',
727
+ diff !== undefined ? ` data-src-diff="${escapeHtml(diff)}"` : '',
728
+ ].join('');
729
+ return `
730
+ <fieldset class="shot-interactive">
731
+ <legend class="sr-only">${escapeHtml(action.action)} screenshot</legend>
732
+ ${radios}
733
+ <p class="shot-caption" aria-hidden="true">Showing: <span class="shot-caption-variant">${captionLabel}</span></p>
734
+ ${diffStats}
735
+ </fieldset>
736
+ <div class="shot-stage">
737
+ <img
738
+ class="shot-image"
739
+ src="${escapeHtml(committedSrc)}"
740
+ alt="${escapeHtml(`Screenshot of ${action.action}`)}"
741
+ loading="lazy"
742
+ ${dataSrc}
743
+ />
744
+ </div>
745
+ `;
746
+ }
747
+ function shotRadio(actionId, name, disabled, checked, opts) {
320
748
  const inputId = `${actionId}-radio-${name}`;
749
+ const label = opts?.label ?? name;
750
+ const describedBy = opts?.describedById
751
+ ? ` aria-describedby="${opts.describedById}"`
752
+ : '';
321
753
  return `
322
754
  <label for="${inputId}" class="chip chip--toggle shot-radio-label">
323
755
  <input
@@ -325,11 +757,11 @@ function shotRadio(actionId, name, disabled, checked) {
325
757
  name="${actionId}-shot"
326
758
  id="${inputId}"
327
759
  value="${name}"
328
- data-tab="${name}"
760
+ data-tab="${name}"${describedBy}
329
761
  ${disabled ? 'disabled' : ''}
330
762
  ${checked ? 'checked' : ''}
331
763
  />
332
- ${name}
764
+ ${label}
333
765
  </label>
334
766
  `;
335
767
  }