tuffgal 0.1.0-alpha.8 → 0.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/README.md +82 -4
  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 +19 -2
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/config.d.ts +121 -10
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +114 -9
  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/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/reporter/assets/report.css +330 -50
  24. package/dist/reporter/assets/report.js +361 -55
  25. package/dist/reporter/template.d.ts +8 -1
  26. package/dist/reporter/template.d.ts.map +1 -1
  27. package/dist/reporter/template.js +447 -78
  28. package/dist/reporter/template.js.map +1 -1
  29. package/dist/reporter/writeReport.d.ts +2 -2
  30. package/dist/reporter/writeReport.d.ts.map +1 -1
  31. package/dist/reporter/writeReport.js +3 -3
  32. package/dist/reporter/writeReport.js.map +1 -1
  33. package/dist/runner/approve.d.ts +34 -9
  34. package/dist/runner/approve.d.ts.map +1 -1
  35. package/dist/runner/approve.js +54 -10
  36. package/dist/runner/approve.js.map +1 -1
  37. package/dist/runner/approveFrom.d.ts +58 -0
  38. package/dist/runner/approveFrom.d.ts.map +1 -0
  39. package/dist/runner/approveFrom.js +384 -0
  40. package/dist/runner/approveFrom.js.map +1 -0
  41. package/dist/runner/breakpointPasses.d.ts +39 -0
  42. package/dist/runner/breakpointPasses.d.ts.map +1 -0
  43. package/dist/runner/breakpointPasses.js +107 -0
  44. package/dist/runner/breakpointPasses.js.map +1 -0
  45. package/dist/runner/bridges/database.d.ts +4 -3
  46. package/dist/runner/bridges/database.d.ts.map +1 -1
  47. package/dist/runner/bridges/database.js +4 -3
  48. package/dist/runner/bridges/database.js.map +1 -1
  49. package/dist/runner/exitCode.d.ts +34 -0
  50. package/dist/runner/exitCode.d.ts.map +1 -0
  51. package/dist/runner/exitCode.js +41 -0
  52. package/dist/runner/exitCode.js.map +1 -0
  53. package/dist/runner/manifest.d.ts +149 -0
  54. package/dist/runner/manifest.d.ts.map +1 -0
  55. package/dist/runner/manifest.js +229 -0
  56. package/dist/runner/manifest.js.map +1 -0
  57. package/dist/runner/mode.d.ts +41 -0
  58. package/dist/runner/mode.d.ts.map +1 -0
  59. package/dist/runner/mode.js +43 -0
  60. package/dist/runner/mode.js.map +1 -0
  61. package/dist/runner/orphanScan.d.ts +68 -0
  62. package/dist/runner/orphanScan.d.ts.map +1 -0
  63. package/dist/runner/orphanScan.js +150 -0
  64. package/dist/runner/orphanScan.js.map +1 -0
  65. package/dist/runner/run.d.ts +85 -1
  66. package/dist/runner/run.d.ts.map +1 -1
  67. package/dist/runner/run.js +267 -41
  68. package/dist/runner/run.js.map +1 -1
  69. package/dist/runner/runAction.d.ts +22 -0
  70. package/dist/runner/runAction.d.ts.map +1 -1
  71. package/dist/runner/runAction.js +146 -24
  72. package/dist/runner/runAction.js.map +1 -1
  73. package/dist/runner/runStory.d.ts +40 -2
  74. package/dist/runner/runStory.d.ts.map +1 -1
  75. package/dist/runner/runStory.js +195 -62
  76. package/dist/runner/runStory.js.map +1 -1
  77. package/dist/runner/storyFilter.d.ts +8 -0
  78. package/dist/runner/storyFilter.d.ts.map +1 -1
  79. package/dist/runner/storyFilter.js +11 -0
  80. package/dist/runner/storyFilter.js.map +1 -1
  81. package/dist/schema/action.d.ts +11 -11
  82. package/dist/schema/result.d.ts +124 -6
  83. package/dist/schema/result.d.ts.map +1 -1
  84. package/dist/schema/result.js.map +1 -1
  85. package/dist/schema/story.d.ts +15 -4
  86. package/dist/schema/story.d.ts.map +1 -1
  87. package/dist/schema/story.js +35 -11
  88. package/dist/schema/story.js.map +1 -1
  89. package/dist/screenshots/baselineStore.d.ts +111 -5
  90. package/dist/screenshots/baselineStore.d.ts.map +1 -1
  91. package/dist/screenshots/baselineStore.js +132 -20
  92. package/dist/screenshots/baselineStore.js.map +1 -1
  93. package/dist/screenshots/capture.d.ts +10 -5
  94. package/dist/screenshots/capture.d.ts.map +1 -1
  95. package/dist/screenshots/capture.js +31 -18
  96. package/dist/screenshots/capture.js.map +1 -1
  97. package/package.json +1 -1
@@ -59,6 +59,20 @@
59
59
  var statusRegion = createStatusRegion('.story-filter-status');
60
60
  var bulkRegion = createStatusRegion('.bulk-toggle-status');
61
61
 
62
+ // The visible label word for a filter button, used to echo the active filter
63
+ // in the bulk-toggle announcement (e.g. "Expanded passed in 3 stories"). The
64
+ // button's `data-filter` carries the matcher TOKEN ("pass"); the visible
65
+ // `.indicator` span carries the human LABEL ("passed"). The "all" total maps to
66
+ // "all" regardless of its visible "stories" label. A missing button → "all".
67
+ function filterLabel(button) {
68
+ if (!button) return 'all';
69
+ if (button.getAttribute('data-filter') === 'all') return 'all';
70
+ var indicator = button.querySelector('.indicator');
71
+ return indicator
72
+ ? indicator.textContent.trim()
73
+ : button.getAttribute('data-filter');
74
+ }
75
+
62
76
  // Reveals the matching <div.shot-panel> for the radio the user picks. The
63
77
  // radio group already handles keyboard navigation natively (arrow keys move
64
78
  // the selection across the visible labels); this script just translates the
@@ -79,17 +93,20 @@
79
93
  }
80
94
 
81
95
  var defaultTab = container.getAttribute('data-default-tab');
82
- var initialRadio =
83
- radios.find(function (radio) {
84
- return radio.value === defaultTab && !radio.disabled;
85
- }) ||
86
- radios.find(function (radio) {
87
- return !radio.disabled;
88
- });
89
- if (initialRadio) {
90
- initialRadio.checked = true;
91
- activate(initialRadio.value);
96
+ function reset() {
97
+ var initialRadio =
98
+ radios.find(function (radio) {
99
+ return radio.value === defaultTab && !radio.disabled;
100
+ }) ||
101
+ radios.find(function (radio) {
102
+ return !radio.disabled;
103
+ });
104
+ if (initialRadio) {
105
+ initialRadio.checked = true;
106
+ activate(initialRadio.value);
107
+ }
92
108
  }
109
+ reset();
93
110
 
94
111
  radios.forEach(function (radio) {
95
112
  radio.addEventListener('change', function () {
@@ -97,6 +114,21 @@
97
114
  activate(radio.value);
98
115
  });
99
116
  });
117
+
118
+ // Selection lives in the DOM, so a collapse only HIDES it — a prior
119
+ // Baseline/Diff pick would otherwise still be checked on the next expand.
120
+ // Reset on COLLAPSE, not expand: the `toggle` event fires AFTER the state
121
+ // change, so resetting on open flashes a visible baseline→actual jump.
122
+ // Resetting on close does the swap while the panel is hidden, so the next
123
+ // expand is already on the default. <details> fires `toggle` on user clicks
124
+ // AND the bulk-toggle's programmatic `open` change, so this covers
125
+ // Collapse-all too. First-ever open is covered by the initial reset() above.
126
+ var details = container.closest && container.closest('details.shots');
127
+ if (details) {
128
+ details.addEventListener('toggle', function () {
129
+ if (!details.open) reset();
130
+ });
131
+ }
100
132
  }
101
133
 
102
134
  document.querySelectorAll('.shot-radio').forEach(function (container) {
@@ -104,65 +136,262 @@
104
136
  });
105
137
  })();
106
138
 
107
- // Status filter for the stories list. Toggles `hidden` on each <li.story>
108
- // whose `data-status` doesn't match the chosen radio value ("all" disables
109
- // the filter). Live-region updates are debounced by ~150ms so arrow-key
110
- // traversal across the radio group doesn't flood the polite region with
111
- // every intermediate state; the visual hide/show is applied immediately.
139
+ // Interactive screenshot viewer (rendered when the report is built with
140
+ // interactiveMode). Each action shows ONE shared <img>. A native radio group
141
+ // is the committed-state source of truth — keyboard, touch, and AT operate it
142
+ // exactly like the radio-tab viewer. On top of that, the mouse gesture is a
143
+ // STATELESS visual preview layered on the same <img>:
144
+ // hover (mouseenter/mousemove) → baseline src
145
+ // press (mousedown) → diff src (no-op when there is no diff)
146
+ // release/leave (mouseup/mouseleave) → revert to the checked radio's variant
147
+ // The preview ONLY rewrites img.src. It never changes the checked radio, the
148
+ // img alt, any ARIA attribute, or any live region — so hovering announces
149
+ // nothing by construction. The mouse listeners live on the .shot-stage wrapper
150
+ // rather than the <img>, keeping the image itself handler-free and
151
+ // non-focusable. Non-interactive reports have no .shot-interactive nodes, so
152
+ // this block is a no-op there (and setupShots is a no-op on interactive ones).
112
153
  (function () {
113
- var fieldset = document.querySelector('.story-filter');
114
- if (!fieldset) return;
154
+ var VARIANT_LABELS = {
155
+ baseline: 'Baseline',
156
+ actual: 'Actual',
157
+ diff: 'Diff',
158
+ };
159
+
160
+ function setupInteractiveShots(fieldset) {
161
+ var root = fieldset.parentElement;
162
+ if (!root) return;
163
+ var stage = root.querySelector('.shot-stage');
164
+ var image = root.querySelector('.shot-image');
165
+ if (!stage || !image) return;
166
+ var captionVariant = fieldset.querySelector('.shot-caption-variant');
167
+ var radios = Array.prototype.slice.call(
168
+ fieldset.querySelectorAll('input[type="radio"]'),
169
+ );
170
+
171
+ // Map each AVAILABLE variant to its src, read off the shared <img>'s
172
+ // data-src-* attributes. Absent variants (e.g. diff on a clean pass) have
173
+ // no attribute, so they never enter the map and every lookup is a no-op.
174
+ var sources = {};
175
+ ['baseline', 'actual', 'diff'].forEach(function (variant) {
176
+ var src = image.getAttribute('data-src-' + variant);
177
+ if (src !== null) sources[variant] = src;
178
+ });
179
+
180
+ // `committed` mirrors the checked radio — the ONLY state the keyboard /
181
+ // touch / AT path mutates. Mouse preview reverts here on release/leave.
182
+ var committed = null;
183
+ var pressed = false;
184
+
185
+ function commit(variant) {
186
+ if (!(variant in sources)) return;
187
+ committed = variant;
188
+ image.src = sources[variant];
189
+ if (captionVariant) {
190
+ captionVariant.textContent = VARIANT_LABELS[variant] || variant;
191
+ }
192
+ }
193
+
194
+ // Show a variant WITHOUT committing (mouse preview). No-op when the variant
195
+ // has no src, so press-with-no-diff leaves the displayed image untouched.
196
+ function preview(variant) {
197
+ if (!(variant in sources)) return;
198
+ image.src = sources[variant];
199
+ }
200
+
201
+ function revert() {
202
+ if (committed && committed in sources) {
203
+ image.src = sources[committed];
204
+ }
205
+ }
206
+
207
+ var initial =
208
+ radios.find(function (radio) {
209
+ return radio.checked;
210
+ }) || radios[0];
211
+ if (initial) commit(initial.value);
212
+
213
+ radios.forEach(function (radio) {
214
+ radio.addEventListener('change', function () {
215
+ commit(radio.value);
216
+ });
217
+ });
218
+
219
+ function hoverPreview() {
220
+ // While not pressed, hovering previews the baseline for an in-place
221
+ // compare. The pressed guard keeps a held mousedown pinned to diff even
222
+ // as the pointer moves across the image.
223
+ if (!pressed) preview('baseline');
224
+ }
225
+
226
+ stage.addEventListener('mouseenter', hoverPreview);
227
+ stage.addEventListener('mousemove', hoverPreview);
228
+ stage.addEventListener('mousedown', function () {
229
+ pressed = true;
230
+ preview('diff');
231
+ });
232
+ stage.addEventListener('mouseup', function () {
233
+ pressed = false;
234
+ revert();
235
+ });
236
+ stage.addEventListener('mouseleave', function () {
237
+ pressed = false;
238
+ revert();
239
+ });
240
+ }
241
+
242
+ document
243
+ .querySelectorAll('.shot-interactive')
244
+ .forEach(function (fieldset) {
245
+ setupInteractiveShots(fieldset);
246
+ });
247
+ })();
248
+
249
+ // Status filter for the stories list. Each status total in the summary row is
250
+ // a native <button aria-pressed> single-select filter; clicking one toggles
251
+ // `hidden` on every <li.story> whose `data-status` doesn't match its
252
+ // `data-filter` token ("all" clears the filter). Exactly one button is pressed
253
+ // at all times (default: the "all/stories" button). Re-clicking the active
254
+ // non-"all" filter reverts to "all". Live-region updates are debounced by
255
+ // ~150ms; the visual hide/show is applied immediately.
256
+ (function () {
257
+ var filterButtons = Array.prototype.slice.call(
258
+ document.querySelectorAll('.summary-filter'),
259
+ );
260
+ if (filterButtons.length === 0) return;
115
261
  var list = document.querySelector('.stories');
116
262
  var empty = document.querySelector('.stories-empty');
117
263
  if (!list || !empty) return;
118
264
 
119
265
  var stories = Array.prototype.slice.call(list.querySelectorAll('.story'));
120
- var radios = Array.prototype.slice.call(
121
- fieldset.querySelectorAll('input[type="radio"]'),
122
- );
123
266
  var total = stories.length;
267
+ var allButton =
268
+ filterButtons.find(function (button) {
269
+ return button.getAttribute('data-filter') === 'all';
270
+ }) || filterButtons[0];
124
271
 
125
- // Bulk-toggle buttons re-labelled per active filter so sighted users know
126
- // the toggle is scoped to the filtered subset: "Expand all" / "Collapse
127
- // all" with no filter, "Expand passed" / "Collapse passed" when filtered.
128
- // This is a synchronous visible-text swap only — no aria-label (would risk
129
- // WCAG 2.5.3 Label in Name) and no live-region write (the filter
130
- // announcement already covers the context change).
272
+ // Cached for the zero-match disable + focus-rescue logic in apply(). The
273
+ // scope word is now static markup ("screenshots" visible, " all screenshots"
274
+ // sr-only), so the buttons are never relabelled per filter — the filter's own
275
+ // live-region announcement already covers the context change.
131
276
  var expandButton = document.querySelector('[data-bulk-toggle="expand"]');
132
277
  var collapseButton = document.querySelector(
133
278
  '[data-bulk-toggle="collapse"]',
134
279
  );
135
280
 
136
- function relabelBulkToggle(name) {
137
- var scope = name && name !== 'all' ? name : 'all';
138
- if (expandButton) {
139
- expandButton.textContent = 'Expand ' + scope;
281
+ // Maintain the single-pressed invariant: exactly one filter button carries
282
+ // aria-pressed="true".
283
+ function setPressed(active) {
284
+ filterButtons.forEach(function (button) {
285
+ button.setAttribute(
286
+ 'aria-pressed',
287
+ button === active ? 'true' : 'false',
288
+ );
289
+ });
290
+ }
291
+
292
+ function show(el) {
293
+ el.hidden = false;
294
+ }
295
+
296
+ // True when `container` holds at least one `.action` that is not hidden.
297
+ // Drives whether a breakpoint group / actions list earns its place under an
298
+ // active filter.
299
+ function hasVisibleAction(container) {
300
+ return Array.prototype.some.call(
301
+ container.querySelectorAll('.action'),
302
+ function (action) {
303
+ return !action.hidden;
304
+ },
305
+ );
306
+ }
307
+
308
+ // Apply the active filter top-down: story → breakpoint group → action. A
309
+ // non-"all" filter does more than hide whole stories — inside a matching
310
+ // story it also prunes the actions (and the now-empty breakpoint groups /
311
+ // actions lists) that don't match, so e.g. the "changed" view shows ONLY the
312
+ // changed rows of a changed story, never the pass rows that happen to share
313
+ // it. Expand-all then opens only those surviving rows.
314
+ function applyToStory(story, value) {
315
+ var actions = Array.prototype.slice.call(
316
+ story.querySelectorAll('.action'),
317
+ );
318
+ var groups = Array.prototype.slice.call(
319
+ story.querySelectorAll('.breakpoint-group'),
320
+ );
321
+ var lists = Array.prototype.slice.call(
322
+ story.querySelectorAll('ol.actions'),
323
+ );
324
+
325
+ // Story visibility keeps the worst-wins rollup semantics: a story shows
326
+ // only when its own status matches (or the filter is "all").
327
+ var storyMatches =
328
+ value === 'all' || story.getAttribute('data-status') === value;
329
+ if (!storyMatches) {
330
+ story.hidden = true;
331
+ // Clear inner pruning so switching back to a matching filter — or to
332
+ // "all" — starts from a clean slate rather than inheriting stale hides.
333
+ actions.forEach(show);
334
+ groups.forEach(show);
335
+ lists.forEach(show);
336
+ return false;
140
337
  }
141
- if (collapseButton) {
142
- collapseButton.textContent = 'Collapse ' + scope;
338
+
339
+ if (value === 'all') {
340
+ story.hidden = false;
341
+ actions.forEach(show);
342
+ groups.forEach(show);
343
+ lists.forEach(show);
344
+ return true;
143
345
  }
346
+
347
+ // Matching story under a specific filter: compare each action's
348
+ // data-status against the button's data-filter TOKEN ("pass"), not its
349
+ // visible label ("passed"), so the "passed" filter matches the `pass`
350
+ // actions instead of silently emptying every story.
351
+ actions.forEach(function (action) {
352
+ action.hidden = action.getAttribute('data-status') !== value;
353
+ });
354
+ groups.forEach(function (group) {
355
+ group.hidden = !hasVisibleAction(group);
356
+ });
357
+ lists.forEach(function (listEl) {
358
+ listEl.hidden = !hasVisibleAction(listEl);
359
+ });
360
+
361
+ // A worst-wins rollup guarantees at least one matching action, but guard
362
+ // the invariant: a story that pruned to nothing is hidden and uncounted
363
+ // so the "N of M" announcement stays truthful.
364
+ var storyVisible = actions.some(function (action) {
365
+ return !action.hidden;
366
+ });
367
+ story.hidden = !storyVisible;
368
+ return storyVisible;
144
369
  }
145
370
 
146
- function apply(radio) {
147
- var value = radio.value;
148
- var name = radio.getAttribute('data-filter-name') || value;
149
- relabelBulkToggle(name);
371
+ function apply(value, trigger) {
150
372
  var visible = 0;
151
373
  stories.forEach(function (story) {
152
- var match =
153
- value === 'all' || story.getAttribute('data-status') === value;
154
- story.hidden = !match;
155
- if (match) visible += 1;
374
+ if (applyToStory(story, value)) visible += 1;
156
375
  });
157
376
  var hasNone = visible === 0;
158
377
  list.hidden = hasNone;
159
378
  empty.hidden = !hasNone;
160
379
 
380
+ // Disable-while-focused guard: when a zero-match filter is about to
381
+ // disable a bulk-toggle button that currently holds focus, move focus to
382
+ // the triggering filter control FIRST, so focus is never stranded on a
383
+ // disabled (unfocusable) button.
384
+ if (
385
+ hasNone &&
386
+ trigger &&
387
+ (document.activeElement === expandButton ||
388
+ document.activeElement === collapseButton)
389
+ ) {
390
+ trigger.focus();
391
+ }
161
392
  // Disable both bulk-toggle buttons when the active filter matches zero
162
393
  // stories — there is nothing to expand/collapse. Runs on EVERY apply()
163
394
  // call (including "all"), so selecting a matching filter re-enables them.
164
- // `disabled` on a standalone <button> removes it from the tab order
165
- // without trapping focus (focus stays on the radio that triggered apply).
166
395
  if (expandButton) expandButton.disabled = hasNone;
167
396
  if (collapseButton) collapseButton.disabled = hasNone;
168
397
 
@@ -172,11 +401,37 @@
172
401
  : 'Showing ' + visible + ' of ' + total + ' stories';
173
402
 
174
403
  statusRegion.writeDebounced(message, 150);
404
+
405
+ // Post-apply focus-loss guard: if filtering left focus on nothing, the
406
+ // body, or inside a now-[hidden] subtree, return it to the control that
407
+ // triggered this apply so keyboard users keep their place.
408
+ var active = document.activeElement;
409
+ if (
410
+ trigger &&
411
+ (!active ||
412
+ active === document.body ||
413
+ (active.closest && active.closest('[hidden]')))
414
+ ) {
415
+ trigger.focus();
416
+ }
175
417
  }
176
418
 
177
- radios.forEach(function (radio) {
178
- radio.addEventListener('change', function () {
179
- apply(radio);
419
+ filterButtons.forEach(function (button) {
420
+ button.addEventListener('click', function () {
421
+ var token = button.getAttribute('data-filter');
422
+ var isActive = button.getAttribute('aria-pressed') === 'true';
423
+ // Re-clicking the active, non-"all" filter reverts to "all". Focus stays
424
+ // on the clicked button — it is in the summary row and never hidden by
425
+ // its own action.
426
+ if (isActive && token !== 'all') {
427
+ setPressed(allButton);
428
+ apply('all', button);
429
+ return;
430
+ }
431
+ // Re-clicking the already-active "all" button is a no-op.
432
+ if (isActive) return;
433
+ setPressed(button);
434
+ apply(token, button);
180
435
  });
181
436
  });
182
437
  })();
@@ -210,12 +465,30 @@
210
465
 
211
466
  function apply(mode) {
212
467
  var shouldOpen = mode === 'expand';
468
+ // Collapse-all focus rescue: if focus sits inside a disclosure that is
469
+ // about to collapse, move it to that disclosure's <summary> first — the
470
+ // summary stays visible after collapse, so focus is never dropped into a
471
+ // hidden subtree. Expand-all reveals content, so it needs no rescue.
472
+ if (!shouldOpen) {
473
+ var active = document.activeElement;
474
+ var openDetails =
475
+ active &&
476
+ active.closest &&
477
+ active.closest('details.shots[open]');
478
+ if (openDetails) {
479
+ var summary = openDetails.querySelector('summary');
480
+ if (summary) summary.focus();
481
+ }
482
+ }
213
483
  var visibleStories = Array.prototype.slice.call(
214
484
  document.querySelectorAll('.story:not([hidden])'),
215
485
  );
216
486
  visibleStories.forEach(function (story) {
487
+ // Only the rows surviving the active filter — `.action:not([hidden])` —
488
+ // get toggled, so "Expand all" under e.g. the changed filter opens just
489
+ // the changed screenshots, not the pass rows sharing the same story.
217
490
  var panels = Array.prototype.slice.call(
218
- story.querySelectorAll('details.shots'),
491
+ story.querySelectorAll('.action:not([hidden]) details.shots'),
219
492
  );
220
493
  panels.forEach(function (panel) {
221
494
  panel.open = shouldOpen;
@@ -227,15 +500,14 @@
227
500
  // keeps the logic simple.
228
501
  var count = visibleStories.length;
229
502
  var verb = shouldOpen ? 'Expanded' : 'Collapsed';
230
- // Echo the active filter scope so the announcement matches the
231
- // filter-aware button label (e.g. "Expanded passed in 3 stories"). Read
232
- // the checked radio at click time; use the same data-filter-name || value
233
- // fallback relabelBulkToggle uses. No checked radio falls back to "all".
234
- var checked = document.querySelector('.story-filter input:checked');
235
- var name =
236
- (checked &&
237
- (checked.getAttribute('data-filter-name') || checked.value)) ||
238
- 'all';
503
+ // Echo the active filter scope so the announcement carries context for
504
+ // what was toggled (e.g. "Expanded passed in 3 stories"). The button
505
+ // label itself is static ("Expand all screenshots"); only this live
506
+ // announcement reflects the filter. Read the pressed filter button at
507
+ // click time via the shared filterLabel helper. No pressed button falls
508
+ // back to "all".
509
+ var pressed = document.querySelector('.summary-filter[aria-pressed="true"]');
510
+ var name = filterLabel(pressed);
239
511
  bulkRegion.write(verb + ' ' + name + ' in ' + count + ' stories');
240
512
  }
241
513
 
@@ -245,4 +517,38 @@
245
517
  });
246
518
  });
247
519
  })();
520
+
521
+ // Scroll a user-opened screenshot disclosure to the top of the viewport so the
522
+ // tall screenshot below the summary row is immediately visible. Fires ONLY on a
523
+ // user single-open:
524
+ // - The bulk-toggle sets `details.open` directly, which fires `toggle` but NO
525
+ // `click`, so a summary `click` listener never catches a bulk op (no
526
+ // scroll-thrash to the last-opened panel).
527
+ // - Native <summary> activation by mouse, Enter, or Space all dispatch one
528
+ // `click`, so a single listener covers every input mode.
529
+ // - The open-state flip is the click's DEFAULT action, run AFTER this handler,
530
+ // so `details.open` here is the PRE-toggle state. `if (open) return` means
531
+ // this click is closing the panel: never scroll on close.
532
+ // - Deferred to rAF so it runs after the flip + content layout. scrollIntoView
533
+ // does not move focus, so focus stays on the <summary>, which sits flush at
534
+ // the viewport top with its focus indicator visible.
535
+ (function () {
536
+ var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
537
+ document.addEventListener('click', function (event) {
538
+ var target = event.target;
539
+ var summary = target && target.closest ? target.closest('summary') : null;
540
+ if (!summary) return;
541
+ var details = summary.parentElement;
542
+ if (!details || !details.matches('details.shots')) return;
543
+ // Pre-toggle state: open === "this click will close it" → do not scroll.
544
+ if (details.open) return;
545
+ requestAnimationFrame(function () {
546
+ details.scrollIntoView({
547
+ behavior: reduce.matches ? 'instant' : 'smooth',
548
+ block: 'start',
549
+ inline: 'nearest',
550
+ });
551
+ });
552
+ });
553
+ })();
248
554
  })();
@@ -3,6 +3,13 @@ import type { RunResult } from '../schema/result.ts';
3
3
  * Static HTML report. Rocks a terminal, dev-tool aesthetic. Dark mode by
4
4
  * default, monospace font for data, sharp borders, and minimal but
5
5
  * evocative icons + neon colors to indicate statuses.
6
+ *
7
+ * `<main>` reading order: an optional environment-mismatch banner (first, only
8
+ * when `environment.mismatch`), then the summary, the stories, and an optional
9
+ * deleted (orphaned-baseline) section last. The banner and deleted section are
10
+ * peer `<h2>` landmarks; the two supplementary per-action notes (candidate /
11
+ * a11y-drift) contribute no heading levels, so the outline stays
12
+ * H1 → summary → stories → deleted.
6
13
  */
7
- export declare function renderReport(result: RunResult, reportDir: string): string;
14
+ export declare function renderReport(result: RunResult, reportDir: string, interactiveMode?: boolean): string;
8
15
  //# sourceMappingURL=template.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../src/reporter/template.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAIV,SAAS,EAEV,MAAM,qBAAqB,CAAC;AAkB7B;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CA6BzE"}
1
+ {"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../src/reporter/template.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAIV,SAAS,EAEV,MAAM,qBAAqB,CAAC;AAkB7B;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,EACjB,SAAS,EAAE,MAAM,EACjB,eAAe,UAAQ,GACtB,MAAM,CA+BR"}