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
@@ -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,79 +136,424 @@
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
+ // (always-visible chips) is the committed-state source of truth — keyboard,
142
+ // touch, and AT operate it exactly like the radio-tab viewer, and the chips
143
+ // are the only path to Diff. On top of that, the mouse gesture is a STATELESS
144
+ // blink-compare layered on the same <img>:
145
+ // press-and-hold (mousedown) → the committed variant's COUNTERPART:
146
+ // baseline normally; actual when baseline is the committed variant
147
+ // ("show me the other one"; committed diff flips to baseline). A missing
148
+ // counterpart is a truthful no-op.
149
+ // release/leave/dragstart → revert to the checked radio's variant
150
+ // There is deliberately NO hover behavior: hover swapped the image the moment
151
+ // the pointer wandered in, so the resting state under the cursor was the OLD
152
+ // screenshot. Rest = committed; press = intentional compare. Diff is off the
153
+ // gesture too (secondary forensic view — its chip covers it).
154
+ // The preview rewrites img.src plus the sight-only (aria-hidden) caption, so
155
+ // the "Showing: {variant}" line always names the DISPLAYED image. It never
156
+ // changes the checked radio, the img alt, any ARIA attribute, or any live
157
+ // region — so pressing announces nothing by construction. The caption and the
158
+ // checked radio are two different truths on purpose: the caption tracks what
159
+ // is displayed (preview included), the checked radio tracks the committed
160
+ // state AT operates on; during a press they diverge briefly — both remain
161
+ // accurate. Do NOT "fix" that by moving radio.checked during preview: checked
162
+ // is the single accessible source of committed state, and press-flapping it
163
+ // would announce to AT and leave revert() nothing stable to revert to.
164
+ //
165
+ // The mouse listeners live on the .shot-stage wrapper rather than the
166
+ // <img>, keeping the image itself handler-free and
167
+ // non-focusable. Non-interactive reports have no .shot-interactive nodes, so
168
+ // this block is a no-op there (and setupShots is a no-op on interactive ones).
112
169
  (function () {
113
- var fieldset = document.querySelector('.story-filter');
114
- if (!fieldset) return;
170
+ var VARIANT_LABELS = {
171
+ baseline: 'Baseline',
172
+ actual: 'Actual',
173
+ diff: 'Diff',
174
+ };
175
+
176
+ function setupInteractiveShots(fieldset) {
177
+ var root = fieldset.parentElement;
178
+ if (!root) return;
179
+ var stage = root.querySelector('.shot-stage');
180
+ var image = root.querySelector('.shot-image');
181
+ if (!stage || !image) return;
182
+ var captionVariant = fieldset.querySelector('.shot-caption-variant');
183
+ var radios = Array.prototype.slice.call(
184
+ fieldset.querySelectorAll('input[type="radio"]'),
185
+ );
186
+
187
+ // Map each AVAILABLE variant to its src, read off the shared <img>'s
188
+ // data-src-* attributes. Absent variants (e.g. diff on a clean pass) have
189
+ // no attribute, so they never enter the map and every lookup is a no-op.
190
+ var sources = {};
191
+ ['baseline', 'actual', 'diff'].forEach(function (variant) {
192
+ var src = image.getAttribute('data-src-' + variant);
193
+ if (src !== null) sources[variant] = src;
194
+ });
195
+
196
+ // `committed` mirrors the checked radio — the ONLY state the keyboard /
197
+ // touch / AT path mutates. Mouse preview reverts here on release, leave,
198
+ // and dragstart.
199
+ var committed = null;
200
+
201
+ // Caption always names the DISPLAYED variant — committed or previewed.
202
+ // The equality check is a cheap idempotence guard: writes are skipped
203
+ // when the label is already showing (e.g. revert after a no-op press).
204
+ function setCaption(variant) {
205
+ if (!captionVariant) return;
206
+ var label = VARIANT_LABELS[variant] || variant;
207
+ if (captionVariant.textContent !== label) {
208
+ captionVariant.textContent = label;
209
+ }
210
+ }
211
+
212
+ function commit(variant) {
213
+ if (!(variant in sources)) return;
214
+ committed = variant;
215
+ image.src = sources[variant];
216
+ setCaption(variant);
217
+ }
218
+
219
+ // Show a variant WITHOUT committing (mouse preview). No-op when the variant
220
+ // has no src, so a press whose counterpart is absent leaves the displayed
221
+ // image AND the caption untouched (the caption stays truthful).
222
+ function preview(variant) {
223
+ if (!(variant in sources)) return;
224
+ image.src = sources[variant];
225
+ setCaption(variant);
226
+ }
227
+
228
+ function revert() {
229
+ if (committed && committed in sources) {
230
+ image.src = sources[committed];
231
+ setCaption(committed);
232
+ }
233
+ }
234
+
235
+ var initial =
236
+ radios.find(function (radio) {
237
+ return radio.checked;
238
+ }) || radios[0];
239
+ if (initial) commit(initial.value);
240
+
241
+ radios.forEach(function (radio) {
242
+ radio.addEventListener('change', function () {
243
+ commit(radio.value);
244
+ });
245
+ });
246
+
247
+ stage.addEventListener('mousedown', function () {
248
+ preview(committed === 'baseline' ? 'actual' : 'baseline');
249
+ });
250
+ stage.addEventListener('mouseup', revert);
251
+ stage.addEventListener('mouseleave', revert);
252
+ // A press that turns into a native image drag can swallow the mouseup
253
+ // (the pointer never "leaves" the stage), which would leave the preview
254
+ // stuck. Native drag itself stays enabled — save-image is a platform
255
+ // feature, not ours to suppress.
256
+ stage.addEventListener('dragstart', revert);
257
+ }
258
+
259
+ document.querySelectorAll('.shot-interactive').forEach(function (fieldset) {
260
+ setupInteractiveShots(fieldset);
261
+ });
262
+ })();
263
+
264
+ // Two-dimension filter for the stories list, with a DIFFERENT granularity per
265
+ // axis:
266
+ //
267
+ // STATUS axis (the summary totals) works at TWO levels. Story ELIGIBILITY
268
+ // is the per-story ROLLUP (`totals.passed` = stories whose rollup status is
269
+ // `pass`): a story is status-eligible iff its own rollup `data-status`
270
+ // (emitted on the .story element) matches the active status, or the status
271
+ // filter is "all". Within an eligible story, non-matching actions are then
272
+ // PRUNED (hidden) so e.g. the `changed` view shows only the changed rows —
273
+ // and expand-all opens only those rows' screenshots. `skipped` actions are
274
+ // exempt from pruning: `skipped` exists on actions but not in the story
275
+ // rollup vocabulary, so pruning it could empty a story the pill counts (a
276
+ // dependency-blocked story is rollup `failed` with only `skipped` actions),
277
+ // and skipped rows are context for the failure, not mismatches. The
278
+ // exemption keeps the live-region visible-story count in agreement with the
279
+ // pill total on a status-only filter.
280
+ //
281
+ // BREAKPOINT axis (the neutral pill row below the totals) is CONTAINER-level.
282
+ // A story is breakpoint-eligible iff it holds a [data-breakpoint] container
283
+ // matching the active breakpoint, or the bp filter is "all". Within a shown
284
+ // story, the non-matching [data-breakpoint] containers are hidden.
285
+ //
286
+ // Each axis is a set of native <button aria-pressed> single-select toggles
287
+ // keyed to a `data-filter` / `data-breakpoint-filter` TOKEN, with its own
288
+ // default-pressed "all" reset and its own single-pressed invariant.
289
+ //
290
+ // The two axes are ANDed: a story shows iff it is status-eligible (story
291
+ // rollup) AND breakpoint-eligible (has a matching container). apply() is a
292
+ // single top-down pass — story → [data-breakpoint] container — that reads BOTH
293
+ // active tokens, so re-running it after either axis changes yields the current
294
+ // intersection (no stale hides carried between axes). The breakpoint pills
295
+ // render only for multi-breakpoint runs, so on a single-breakpoint run this
296
+ // collapses to the status-only behavior with the bpFilter pinned at "all".
297
+ //
298
+ // Live-region updates are debounced by ~150ms; the visual hide/show is applied
299
+ // immediately.
300
+ (function () {
301
+ var statusButtons = Array.prototype.slice.call(
302
+ document.querySelectorAll('.summary-filter'),
303
+ );
304
+ if (statusButtons.length === 0) return;
305
+ var bpButtons = Array.prototype.slice.call(
306
+ document.querySelectorAll('.breakpoint-filter'),
307
+ );
115
308
  var list = document.querySelector('.stories');
116
309
  var empty = document.querySelector('.stories-empty');
117
310
  if (!list || !empty) return;
118
311
 
119
312
  var stories = Array.prototype.slice.call(list.querySelectorAll('.story'));
120
- var radios = Array.prototype.slice.call(
121
- fieldset.querySelectorAll('input[type="radio"]'),
122
- );
123
313
  var total = stories.length;
124
314
 
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).
315
+ // Active token per axis; "all" means the axis imposes no constraint.
316
+ var statusFilter = 'all';
317
+ var bpFilter = 'all';
318
+
319
+ var statusAllButton =
320
+ statusButtons.find(function (button) {
321
+ return button.getAttribute('data-filter') === 'all';
322
+ }) || statusButtons[0];
323
+ var bpAllButton = bpButtons.find(function (button) {
324
+ return button.getAttribute('data-breakpoint-filter') === 'all';
325
+ });
326
+
327
+ // Cached for the zero-match disable + focus-rescue logic in apply(). The
328
+ // scope word is now static markup ("screenshots" visible, " all screenshots"
329
+ // sr-only), so the buttons are never relabelled per filter — the filter's own
330
+ // live-region announcement already covers the context change.
131
331
  var expandButton = document.querySelector('[data-bulk-toggle="expand"]');
132
332
  var collapseButton = document.querySelector(
133
333
  '[data-bulk-toggle="collapse"]',
134
334
  );
135
335
 
136
- function relabelBulkToggle(name) {
137
- var scope = name && name !== 'all' ? name : 'all';
138
- if (expandButton) {
139
- expandButton.textContent = 'Expand ' + scope;
140
- }
141
- if (collapseButton) {
142
- collapseButton.textContent = 'Collapse ' + scope;
336
+ // Maintain a single-pressed invariant WITHIN one axis: exactly one button in
337
+ // `buttons` carries aria-pressed="true".
338
+ function setPressed(buttons, active) {
339
+ buttons.forEach(function (button) {
340
+ button.setAttribute(
341
+ 'aria-pressed',
342
+ button === active ? 'true' : 'false',
343
+ );
344
+ });
345
+ }
346
+
347
+ // Apply BOTH active axes to one story:
348
+ // 1. STATUS eligibility is story-level. The story is status-eligible when
349
+ // statusFilter is "all" or the story's own rollup data-status (the
350
+ // worst-across-breakpoints status emitted on the .story element) equals
351
+ // the TOKEN ("pass"), not a visible label. A status-ineligible story is
352
+ // hidden whole; its containers are never inspected, so they may carry
353
+ // stale hidden state (see the recompute invariant below).
354
+ // 2. BREAKPOINT axis is container-level. Within a status-eligible story,
355
+ // each [data-breakpoint] container (a .breakpoint-group div OR the flat
356
+ // <ol class="actions">) is breakpoint-eligible when bpFilter is "all"
357
+ // or its data-breakpoint equals bpFilter. A non-matching container is
358
+ // hidden whole and its inner action prunes are CLEARED so a later
359
+ // reveal starts from a clean slate.
360
+ // 3. STATUS pruning is action-level. Inside each breakpoint-eligible
361
+ // container, an action is hidden when statusFilter is active and the
362
+ // action's own data-status differs — EXCEPT `skipped` actions, which
363
+ // are never pruned. `skipped` is not a story-rollup status, so pruning
364
+ // it could empty a story the pill counts (a dependency-blocked story
365
+ // is rollup `failed` with only `skipped` actions); skipped rows are
366
+ // context for the failure, not mismatches.
367
+ // 4. A container pruned to zero visible actions is hidden — a captioned
368
+ // empty list is a dead end. The story shows iff at least one container
369
+ // survives with a visible action.
370
+ // Deliberate gate asymmetry under combined filters: the ROLLUP gates the
371
+ // story, the exact match prunes actions. Under "changed at mobile", a story
372
+ // whose rollup is `failed` stays hidden even if its mobile container holds
373
+ // a `changed` action — that matches the pill's per-story rollup semantics.
374
+ //
375
+ // Recompute invariant: apply() re-runs this for EVERY story on every filter
376
+ // change, unconditionally recomputing action.hidden in every breakpoint-
377
+ // eligible container. Hidden stories and containers may carry stale inner
378
+ // state by design; keep this pass memoization-free, or step 2's
379
+ // clear-on-hide and the stale state inside status-hidden stories become
380
+ // bugs.
381
+ function applyToStory(story) {
382
+ var statusMatches =
383
+ statusFilter === 'all' ||
384
+ story.getAttribute('data-status') === statusFilter;
385
+ if (!statusMatches) {
386
+ story.hidden = true;
387
+ return false;
143
388
  }
389
+
390
+ var containers = Array.prototype.slice.call(
391
+ story.querySelectorAll('[data-breakpoint]'),
392
+ );
393
+
394
+ var storyVisible = false;
395
+ containers.forEach(function (container) {
396
+ var actions = Array.prototype.slice.call(
397
+ container.querySelectorAll('.action'),
398
+ );
399
+ var bpMatches =
400
+ bpFilter === 'all' ||
401
+ container.getAttribute('data-breakpoint') === bpFilter;
402
+ if (!bpMatches) {
403
+ container.hidden = true;
404
+ actions.forEach(function (action) {
405
+ action.hidden = false;
406
+ });
407
+ return;
408
+ }
409
+ var containerVisible = false;
410
+ actions.forEach(function (action) {
411
+ var status = action.getAttribute('data-status');
412
+ var pruned =
413
+ statusFilter !== 'all' &&
414
+ status !== statusFilter &&
415
+ status !== 'skipped';
416
+ action.hidden = pruned;
417
+ if (!pruned) containerVisible = true;
418
+ });
419
+ container.hidden = !containerVisible;
420
+ if (containerVisible) storyVisible = true;
421
+ });
422
+
423
+ story.hidden = !storyVisible;
424
+ return storyVisible;
425
+ }
426
+
427
+ // The short "name width" label for a breakpoint pill's live-region echo
428
+ // (e.g. "mobile 375"), read from the pressed pill's visible name + width.
429
+ // The status pill's echo reuses the shared filterLabel helper. A missing or
430
+ // "all" pill contributes nothing.
431
+ function breakpointLabel(button) {
432
+ if (!button) return '';
433
+ if (button.getAttribute('data-breakpoint-filter') === 'all') return '';
434
+ var nameEl = button.querySelector('.breakpoint-name');
435
+ var name = nameEl ? nameEl.textContent.trim() : '';
436
+ var dimsEl = button.querySelector('.breakpoint-dimensions');
437
+ // The decorative "375×667" carries the width before the × glyph; take just
438
+ // the width for the short form. Absent dimensions → bare name.
439
+ var width = dimsEl ? dimsEl.textContent.split('×')[0].trim() : '';
440
+ return width ? name + ' ' + width : name;
144
441
  }
145
442
 
146
- function apply(radio) {
147
- var value = radio.value;
148
- var name = radio.getAttribute('data-filter-name') || value;
149
- relabelBulkToggle(name);
443
+ function apply(trigger) {
150
444
  var visible = 0;
151
445
  stories.forEach(function (story) {
152
- var match =
153
- value === 'all' || story.getAttribute('data-status') === value;
154
- story.hidden = !match;
155
- if (match) visible += 1;
446
+ if (applyToStory(story)) visible += 1;
156
447
  });
157
448
  var hasNone = visible === 0;
158
449
  list.hidden = hasNone;
159
450
  empty.hidden = !hasNone;
160
451
 
161
- // Disable both bulk-toggle buttons when the active filter matches zero
452
+ // Disable-while-focused guard: when a zero-match filter is about to
453
+ // disable a bulk-toggle button that currently holds focus, move focus to
454
+ // the triggering filter control FIRST, so focus is never stranded on a
455
+ // disabled (unfocusable) button.
456
+ if (
457
+ hasNone &&
458
+ trigger &&
459
+ (document.activeElement === expandButton ||
460
+ document.activeElement === collapseButton)
461
+ ) {
462
+ trigger.focus();
463
+ }
464
+ // Disable both bulk-toggle buttons when the intersection matches zero
162
465
  // stories — there is nothing to expand/collapse. Runs on EVERY apply()
163
- // 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).
466
+ // call, so a filter change that re-reveals stories re-enables them.
166
467
  if (expandButton) expandButton.disabled = hasNone;
167
468
  if (collapseButton) collapseButton.disabled = hasNone;
168
469
 
169
- var message =
170
- value === 'all'
171
- ? 'Showing all ' + total + ' stories'
172
- : 'Showing ' + visible + ' of ' + total + ' stories';
470
+ statusRegion.writeDebounced(filterMessage(visible), 150);
173
471
 
174
- statusRegion.writeDebounced(message, 150);
472
+ // Post-apply focus-loss guard: if filtering left focus on nothing, the
473
+ // body, or inside a now-[hidden] subtree, return it to the control that
474
+ // triggered this apply so keyboard users keep their place.
475
+ var active = document.activeElement;
476
+ if (
477
+ trigger &&
478
+ (!active ||
479
+ active === document.body ||
480
+ active === document.documentElement ||
481
+ (active.closest && active.closest('[hidden]')))
482
+ ) {
483
+ trigger.focus();
484
+ }
175
485
  }
176
486
 
177
- radios.forEach(function (radio) {
178
- radio.addEventListener('change', function () {
179
- apply(radio);
487
+ // Compose the live-region sentence for the current axis state (count =
488
+ // visible stories after the intersection). Four combos:
489
+ // both all → "Showing all N stories"
490
+ // status only → "Showing {statusLabel}: N stories"
491
+ // breakpoint only → "Showing {breakpointLabel}: N stories"
492
+ // both → "Showing {statusLabel} at {breakpointLabel}: N stories"
493
+ // Singular/plural on stor(y|ies). aria-atomic replaces the whole sentence.
494
+ function filterMessage(visible) {
495
+ var noun = visible === 1 ? 'story' : 'stories';
496
+ var statusActive = statusFilter !== 'all';
497
+ var bpActive = bpFilter !== 'all';
498
+ if (!statusActive && !bpActive) {
499
+ return 'Showing all ' + total + ' ' + noun;
500
+ }
501
+ var statusLabel = filterLabel(
502
+ statusButtons.find(function (button) {
503
+ return button.getAttribute('aria-pressed') === 'true';
504
+ }),
505
+ );
506
+ var bpLabel = breakpointLabel(
507
+ bpButtons.find(function (button) {
508
+ return button.getAttribute('aria-pressed') === 'true';
509
+ }),
510
+ );
511
+ var scope;
512
+ if (statusActive && bpActive) {
513
+ scope = statusLabel + ' at ' + bpLabel;
514
+ } else if (statusActive) {
515
+ scope = statusLabel;
516
+ } else {
517
+ scope = bpLabel;
518
+ }
519
+ return 'Showing ' + scope + ': ' + visible + ' ' + noun;
520
+ }
521
+
522
+ statusButtons.forEach(function (button) {
523
+ button.addEventListener('click', function () {
524
+ var token = button.getAttribute('data-filter');
525
+ var isActive = button.getAttribute('aria-pressed') === 'true';
526
+ // Re-clicking the active, non-"all" filter reverts that axis to "all".
527
+ // Focus stays on the clicked button — it is in the summary row and never
528
+ // hidden by its own action.
529
+ if (isActive && token !== 'all') {
530
+ setPressed(statusButtons, statusAllButton);
531
+ statusFilter = 'all';
532
+ apply(button);
533
+ return;
534
+ }
535
+ // Re-clicking the already-active "all" button is a no-op.
536
+ if (isActive) return;
537
+ setPressed(statusButtons, button);
538
+ statusFilter = token;
539
+ apply(button);
540
+ });
541
+ });
542
+
543
+ bpButtons.forEach(function (button) {
544
+ button.addEventListener('click', function () {
545
+ var token = button.getAttribute('data-breakpoint-filter');
546
+ var isActive = button.getAttribute('aria-pressed') === 'true';
547
+ if (isActive && token !== 'all') {
548
+ setPressed(bpButtons, bpAllButton);
549
+ bpFilter = 'all';
550
+ apply(button);
551
+ return;
552
+ }
553
+ if (isActive) return;
554
+ setPressed(bpButtons, button);
555
+ bpFilter = token;
556
+ apply(button);
180
557
  });
181
558
  });
182
559
  })();
@@ -192,10 +569,9 @@
192
569
  // We use `details.open = true/false` rather than `details.click()` so the
193
570
  // browser does not dispatch a `toggle` event cascade for every panel.
194
571
  //
195
- // No debounce: bulk-toggle fires once per click (not on every arrow keypress
196
- // like the filter does), so the live region is not at risk of being flooded.
197
- // The filter's 150ms debounce exists because radios announce on every arrow
198
- // move; here a single click → single message is fine.
572
+ // No debounce: bulk-toggle fires once per click, so the live region is not
573
+ // at risk of being flooded. The filter's 150ms debounce exists to coalesce a
574
+ // rapid run of pill clicks; here a single click → single message is fine.
199
575
  //
200
576
  // Toggle announcements go to their own `.bulk-toggle-status` region via
201
577
  // `bulkRegion.write(msg)`, NOT the filter's `.story-filter-status`. Toggling
@@ -210,12 +586,34 @@
210
586
 
211
587
  function apply(mode) {
212
588
  var shouldOpen = mode === 'expand';
589
+ // Collapse-all focus rescue: if focus sits inside a disclosure that is
590
+ // about to collapse, move it to that disclosure's <summary> first — the
591
+ // summary stays visible after collapse, so focus is never dropped into a
592
+ // hidden subtree. Expand-all reveals content, so it needs no rescue.
593
+ if (!shouldOpen) {
594
+ var active = document.activeElement;
595
+ var openDetails =
596
+ active && active.closest && active.closest('details.shots[open]');
597
+ if (openDetails) {
598
+ var summary = openDetails.querySelector('summary');
599
+ if (summary) summary.focus();
600
+ }
601
+ }
213
602
  var visibleStories = Array.prototype.slice.call(
214
603
  document.querySelectorAll('.story:not([hidden])'),
215
604
  );
216
605
  visibleStories.forEach(function (story) {
606
+ // Only the rows surviving the active filter get toggled. The status
607
+ // axis prunes non-matching actions ([hidden] on the .action) and the
608
+ // breakpoint axis hides whole containers, so an action is "visible"
609
+ // iff neither it nor its container is hidden. Excluding both keeps a
610
+ // pruned row's panels closed — otherwise they would open here and
611
+ // later appear pre-expanded when a filter change reveals them,
612
+ // breaking the default-closed contract.
217
613
  var panels = Array.prototype.slice.call(
218
- story.querySelectorAll('details.shots'),
614
+ story.querySelectorAll(
615
+ '[data-breakpoint]:not([hidden]) .action:not([hidden]) details.shots',
616
+ ),
219
617
  );
220
618
  panels.forEach(function (panel) {
221
619
  panel.open = shouldOpen;
@@ -226,17 +624,19 @@
226
624
  // announcement reads the same to a screen reader either way, and this
227
625
  // keeps the logic simple.
228
626
  var count = visibleStories.length;
627
+ var noun = count === 1 ? 'story' : 'stories';
229
628
  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';
239
- bulkRegion.write(verb + ' ' + name + ' in ' + count + ' stories');
629
+ // Echo the active STATUS-filter scope so the announcement carries context
630
+ // for what was toggled (e.g. "Expanded passed in 3 stories"). The button
631
+ // label itself is static ("Expand all screenshots"); only this live
632
+ // announcement reflects the status filter. Read the pressed status pill at
633
+ // click time via the shared filterLabel helper. No pressed button falls
634
+ // back to "all".
635
+ var pressed = document.querySelector(
636
+ '.summary-filter[aria-pressed="true"]',
637
+ );
638
+ var name = filterLabel(pressed);
639
+ bulkRegion.write(verb + ' ' + name + ' in ' + count + ' ' + noun);
240
640
  }
241
641
 
242
642
  buttons.forEach(function (button) {
@@ -245,4 +645,38 @@
245
645
  });
246
646
  });
247
647
  })();
648
+
649
+ // Scroll a user-opened screenshot disclosure to the top of the viewport so the
650
+ // tall screenshot below the summary row is immediately visible. Fires ONLY on a
651
+ // user single-open:
652
+ // - The bulk-toggle sets `details.open` directly, which fires `toggle` but NO
653
+ // `click`, so a summary `click` listener never catches a bulk op (no
654
+ // scroll-thrash to the last-opened panel).
655
+ // - Native <summary> activation by mouse, Enter, or Space all dispatch one
656
+ // `click`, so a single listener covers every input mode.
657
+ // - The open-state flip is the click's DEFAULT action, run AFTER this handler,
658
+ // so `details.open` here is the PRE-toggle state. `if (open) return` means
659
+ // this click is closing the panel: never scroll on close.
660
+ // - Deferred to rAF so it runs after the flip + content layout. scrollIntoView
661
+ // does not move focus, so focus stays on the <summary>, which sits flush at
662
+ // the viewport top with its focus indicator visible.
663
+ (function () {
664
+ var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
665
+ document.addEventListener('click', function (event) {
666
+ var target = event.target;
667
+ var summary = target && target.closest ? target.closest('summary') : null;
668
+ if (!summary) return;
669
+ var details = summary.parentElement;
670
+ if (!details || !details.matches('details.shots')) return;
671
+ // Pre-toggle state: open === "this click will close it" → do not scroll.
672
+ if (details.open) return;
673
+ requestAnimationFrame(function () {
674
+ details.scrollIntoView({
675
+ behavior: reduce.matches ? 'instant' : 'smooth',
676
+ block: 'start',
677
+ inline: 'nearest',
678
+ });
679
+ });
680
+ });
681
+ })();
248
682
  })();