tuffgal 0.2.0-alpha.1 → 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.
@@ -138,16 +138,32 @@
138
138
 
139
139
  // Interactive screenshot viewer (rendered when the report is built with
140
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
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
151
167
  // non-focusable. Non-interactive reports have no .shot-interactive nodes, so
152
168
  // this block is a no-op there (and setupShots is a no-op on interactive ones).
153
169
  (function () {
@@ -178,29 +194,41 @@
178
194
  });
179
195
 
180
196
  // `committed` mirrors the checked radio — the ONLY state the keyboard /
181
- // touch / AT path mutates. Mouse preview reverts here on release/leave.
197
+ // touch / AT path mutates. Mouse preview reverts here on release, leave,
198
+ // and dragstart.
182
199
  var committed = null;
183
- var pressed = false;
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
+ }
184
211
 
185
212
  function commit(variant) {
186
213
  if (!(variant in sources)) return;
187
214
  committed = variant;
188
215
  image.src = sources[variant];
189
- if (captionVariant) {
190
- captionVariant.textContent = VARIANT_LABELS[variant] || variant;
191
- }
216
+ setCaption(variant);
192
217
  }
193
218
 
194
219
  // 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.
220
+ // has no src, so a press whose counterpart is absent leaves the displayed
221
+ // image AND the caption untouched (the caption stays truthful).
196
222
  function preview(variant) {
197
223
  if (!(variant in sources)) return;
198
224
  image.src = sources[variant];
225
+ setCaption(variant);
199
226
  }
200
227
 
201
228
  function revert() {
202
229
  if (committed && committed in sources) {
203
230
  image.src = sources[committed];
231
+ setCaption(committed);
204
232
  }
205
233
  }
206
234
 
@@ -216,58 +244,85 @@
216
244
  });
217
245
  });
218
246
 
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
247
  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();
248
+ preview(committed === 'baseline' ? 'actual' : 'baseline');
239
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);
240
257
  }
241
258
 
242
- document
243
- .querySelectorAll('.shot-interactive')
244
- .forEach(function (fieldset) {
245
- setupInteractiveShots(fieldset);
246
- });
259
+ document.querySelectorAll('.shot-interactive').forEach(function (fieldset) {
260
+ setupInteractiveShots(fieldset);
261
+ });
247
262
  })();
248
263
 
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.
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.
256
300
  (function () {
257
- var filterButtons = Array.prototype.slice.call(
301
+ var statusButtons = Array.prototype.slice.call(
258
302
  document.querySelectorAll('.summary-filter'),
259
303
  );
260
- if (filterButtons.length === 0) return;
304
+ if (statusButtons.length === 0) return;
305
+ var bpButtons = Array.prototype.slice.call(
306
+ document.querySelectorAll('.breakpoint-filter'),
307
+ );
261
308
  var list = document.querySelector('.stories');
262
309
  var empty = document.querySelector('.stories-empty');
263
310
  if (!list || !empty) return;
264
311
 
265
312
  var stories = Array.prototype.slice.call(list.querySelectorAll('.story'));
266
313
  var total = stories.length;
267
- var allButton =
268
- filterButtons.find(function (button) {
314
+
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) {
269
321
  return button.getAttribute('data-filter') === 'all';
270
- }) || filterButtons[0];
322
+ }) || statusButtons[0];
323
+ var bpAllButton = bpButtons.find(function (button) {
324
+ return button.getAttribute('data-breakpoint-filter') === 'all';
325
+ });
271
326
 
272
327
  // Cached for the zero-match disable + focus-rescue logic in apply(). The
273
328
  // scope word is now static markup ("screenshots" visible, " all screenshots"
@@ -278,10 +333,10 @@
278
333
  '[data-bulk-toggle="collapse"]',
279
334
  );
280
335
 
281
- // Maintain the single-pressed invariant: exactly one filter button carries
282
- // aria-pressed="true".
283
- function setPressed(active) {
284
- filterButtons.forEach(function (button) {
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) {
285
340
  button.setAttribute(
286
341
  'aria-pressed',
287
342
  button === active ? 'true' : 'false',
@@ -289,89 +344,106 @@
289
344
  });
290
345
  }
291
346
 
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) {
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) {
330
386
  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
387
  return false;
337
388
  }
338
389
 
339
- if (value === 'all') {
340
- story.hidden = false;
341
- actions.forEach(show);
342
- groups.forEach(show);
343
- lists.forEach(show);
344
- return true;
345
- }
390
+ var containers = Array.prototype.slice.call(
391
+ story.querySelectorAll('[data-breakpoint]'),
392
+ );
346
393
 
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);
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;
359
421
  });
360
422
 
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
423
  story.hidden = !storyVisible;
368
424
  return storyVisible;
369
425
  }
370
426
 
371
- function apply(value, trigger) {
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;
441
+ }
442
+
443
+ function apply(trigger) {
372
444
  var visible = 0;
373
445
  stories.forEach(function (story) {
374
- if (applyToStory(story, value)) visible += 1;
446
+ if (applyToStory(story)) visible += 1;
375
447
  });
376
448
  var hasNone = visible === 0;
377
449
  list.hidden = hasNone;
@@ -389,18 +461,13 @@
389
461
  ) {
390
462
  trigger.focus();
391
463
  }
392
- // Disable both bulk-toggle buttons when the active filter matches zero
464
+ // Disable both bulk-toggle buttons when the intersection matches zero
393
465
  // stories — there is nothing to expand/collapse. Runs on EVERY apply()
394
- // call (including "all"), so selecting a matching filter re-enables them.
466
+ // call, so a filter change that re-reveals stories re-enables them.
395
467
  if (expandButton) expandButton.disabled = hasNone;
396
468
  if (collapseButton) collapseButton.disabled = hasNone;
397
469
 
398
- var message =
399
- value === 'all'
400
- ? 'Showing all ' + total + ' stories'
401
- : 'Showing ' + visible + ' of ' + total + ' stories';
402
-
403
- statusRegion.writeDebounced(message, 150);
470
+ statusRegion.writeDebounced(filterMessage(visible), 150);
404
471
 
405
472
  // Post-apply focus-loss guard: if filtering left focus on nothing, the
406
473
  // body, or inside a now-[hidden] subtree, return it to the control that
@@ -410,28 +477,83 @@
410
477
  trigger &&
411
478
  (!active ||
412
479
  active === document.body ||
480
+ active === document.documentElement ||
413
481
  (active.closest && active.closest('[hidden]')))
414
482
  ) {
415
483
  trigger.focus();
416
484
  }
417
485
  }
418
486
 
419
- filterButtons.forEach(function (button) {
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) {
420
523
  button.addEventListener('click', function () {
421
524
  var token = button.getAttribute('data-filter');
422
525
  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.
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.
426
529
  if (isActive && token !== 'all') {
427
- setPressed(allButton);
428
- apply('all', button);
530
+ setPressed(statusButtons, statusAllButton);
531
+ statusFilter = 'all';
532
+ apply(button);
429
533
  return;
430
534
  }
431
535
  // Re-clicking the already-active "all" button is a no-op.
432
536
  if (isActive) return;
433
- setPressed(button);
434
- apply(token, button);
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);
435
557
  });
436
558
  });
437
559
  })();
@@ -447,10 +569,9 @@
447
569
  // We use `details.open = true/false` rather than `details.click()` so the
448
570
  // browser does not dispatch a `toggle` event cascade for every panel.
449
571
  //
450
- // No debounce: bulk-toggle fires once per click (not on every arrow keypress
451
- // like the filter does), so the live region is not at risk of being flooded.
452
- // The filter's 150ms debounce exists because radios announce on every arrow
453
- // 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.
454
575
  //
455
576
  // Toggle announcements go to their own `.bulk-toggle-status` region via
456
577
  // `bulkRegion.write(msg)`, NOT the filter's `.story-filter-status`. Toggling
@@ -472,9 +593,7 @@
472
593
  if (!shouldOpen) {
473
594
  var active = document.activeElement;
474
595
  var openDetails =
475
- active &&
476
- active.closest &&
477
- active.closest('details.shots[open]');
596
+ active && active.closest && active.closest('details.shots[open]');
478
597
  if (openDetails) {
479
598
  var summary = openDetails.querySelector('summary');
480
599
  if (summary) summary.focus();
@@ -484,11 +603,17 @@
484
603
  document.querySelectorAll('.story:not([hidden])'),
485
604
  );
486
605
  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.
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.
490
613
  var panels = Array.prototype.slice.call(
491
- story.querySelectorAll('.action:not([hidden]) details.shots'),
614
+ story.querySelectorAll(
615
+ '[data-breakpoint]:not([hidden]) .action:not([hidden]) details.shots',
616
+ ),
492
617
  );
493
618
  panels.forEach(function (panel) {
494
619
  panel.open = shouldOpen;
@@ -499,16 +624,19 @@
499
624
  // announcement reads the same to a screen reader either way, and this
500
625
  // keeps the logic simple.
501
626
  var count = visibleStories.length;
627
+ var noun = count === 1 ? 'story' : 'stories';
502
628
  var verb = shouldOpen ? 'Expanded' : 'Collapsed';
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
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
505
631
  // label itself is static ("Expand all screenshots"); only this live
506
- // announcement reflects the filter. Read the pressed filter button at
632
+ // announcement reflects the status filter. Read the pressed status pill at
507
633
  // click time via the shared filterLabel helper. No pressed button falls
508
634
  // back to "all".
509
- var pressed = document.querySelector('.summary-filter[aria-pressed="true"]');
635
+ var pressed = document.querySelector(
636
+ '.summary-filter[aria-pressed="true"]',
637
+ );
510
638
  var name = filterLabel(pressed);
511
- bulkRegion.write(verb + ' ' + name + ' in ' + count + ' stories');
639
+ bulkRegion.write(verb + ' ' + name + ' in ' + count + ' ' + noun);
512
640
  }
513
641
 
514
642
  buttons.forEach(function (button) {
@@ -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;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,EACjB,SAAS,EAAE,MAAM,EACjB,eAAe,UAAQ,GACtB,MAAM,CA+BR"}
1
+ {"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../src/reporter/template.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAIV,SAAS,EAEV,MAAM,qBAAqB,CAAC;AA8B7B;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,EACjB,SAAS,EAAE,MAAM,EACjB,eAAe,UAAQ,GACtB,MAAM,CA+BR"}