tuffgal 0.2.0-alpha.1 → 0.2.0-alpha.3
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.
- package/CHANGELOG.md +40 -1
- package/dist/reporter/assets/report.css +178 -66
- package/dist/reporter/assets/report.js +346 -158
- package/dist/reporter/template.d.ts.map +1 -1
- package/dist/reporter/template.js +187 -27
- package/dist/reporter/template.js.map +1 -1
- package/dist/runner/runAction.d.ts.map +1 -1
- package/dist/runner/runAction.js +3 -0
- package/dist/runner/runAction.js.map +1 -1
- package/dist/schema/result.d.ts +19 -0
- package/dist/schema/result.d.ts.map +1 -1
- package/dist/schema/result.js.map +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
142
|
-
// exactly like the radio-tab viewer
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// press
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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
|
|
197
|
+
// touch / AT path mutates. Mouse preview reverts here on release, leave,
|
|
198
|
+
// and dragstart.
|
|
182
199
|
var committed = null;
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
setupInteractiveShots(fieldset);
|
|
246
|
-
});
|
|
259
|
+
document.querySelectorAll('.shot-interactive').forEach(function (fieldset) {
|
|
260
|
+
setupInteractiveShots(fieldset);
|
|
261
|
+
});
|
|
247
262
|
})();
|
|
248
263
|
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
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
|
|
301
|
+
var statusButtons = Array.prototype.slice.call(
|
|
258
302
|
document.querySelectorAll('.summary-filter'),
|
|
259
303
|
);
|
|
260
|
-
if (
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
}) ||
|
|
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
|
|
282
|
-
// aria-pressed="true".
|
|
283
|
-
function setPressed(active) {
|
|
284
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
var
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
340
|
-
story.
|
|
341
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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".
|
|
424
|
-
// on the clicked button — it is in the summary row and never
|
|
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(
|
|
428
|
-
|
|
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
|
-
|
|
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
|
|
451
|
-
//
|
|
452
|
-
//
|
|
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
|
|
488
|
-
//
|
|
489
|
-
//
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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 + '
|
|
639
|
+
bulkRegion.write(verb + ' ' + name + ' in ' + count + ' ' + noun);
|
|
512
640
|
}
|
|
513
641
|
|
|
514
642
|
buttons.forEach(function (button) {
|
|
@@ -551,4 +679,64 @@
|
|
|
551
679
|
});
|
|
552
680
|
});
|
|
553
681
|
})();
|
|
682
|
+
|
|
683
|
+
// Deep-link support for the CI comment. A URL like `…/index.html#story-3`
|
|
684
|
+
// (built by tuffgal-action from the same `results.json` story ordinal that
|
|
685
|
+
// template.ts renders as `id="story-<index>"`) lands the reader ON that story
|
|
686
|
+
// with its screenshots already expanded — no scrolling or clicking through a
|
|
687
|
+
// 34-row report to find the one that changed.
|
|
688
|
+
//
|
|
689
|
+
// On a matching hash we:
|
|
690
|
+
// 1. Expand every VISIBLE screenshot disclosure in that story, reusing the
|
|
691
|
+
// bulk-toggle's own visibility predicate so a filtered-out row is not
|
|
692
|
+
// force-opened (default-closed contract). Setting `details.open` directly
|
|
693
|
+
// — not `.click()` — mirrors the bulk-toggle and avoids a toggle cascade.
|
|
694
|
+
// 2. Move focus to the story `<li>` (tabindex="-1") so assistive tech
|
|
695
|
+
// announces the story prose rather than dropping the reader mid-list.
|
|
696
|
+
// `preventScroll` keeps focus from yanking the viewport before our own
|
|
697
|
+
// smooth scroll runs.
|
|
698
|
+
// 3. Smooth-scroll the story to the top (respecting reduced-motion), so the
|
|
699
|
+
// expanded screenshots sit in view.
|
|
700
|
+
//
|
|
701
|
+
// Runs on initial load AND on `hashchange`, so following a second deep-link
|
|
702
|
+
// within an already-open report re-targets without a reload.
|
|
703
|
+
(function () {
|
|
704
|
+
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
705
|
+
|
|
706
|
+
function openStory(story) {
|
|
707
|
+
// Same predicate the bulk-toggle uses: only rows surviving the active
|
|
708
|
+
// filter (neither the action nor its breakpoint container hidden) get
|
|
709
|
+
// opened, so a deep-link never resurrects a pruned row pre-expanded.
|
|
710
|
+
var panels = Array.prototype.slice.call(
|
|
711
|
+
story.querySelectorAll(
|
|
712
|
+
'[data-breakpoint]:not([hidden]) .action:not([hidden]) details.shots',
|
|
713
|
+
),
|
|
714
|
+
);
|
|
715
|
+
panels.forEach(function (panel) {
|
|
716
|
+
panel.open = true;
|
|
717
|
+
});
|
|
718
|
+
if (typeof story.focus === 'function') {
|
|
719
|
+
story.focus({ preventScroll: true });
|
|
720
|
+
}
|
|
721
|
+
requestAnimationFrame(function () {
|
|
722
|
+
story.scrollIntoView({
|
|
723
|
+
behavior: reduce.matches ? 'instant' : 'smooth',
|
|
724
|
+
block: 'start',
|
|
725
|
+
inline: 'nearest',
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function applyHash() {
|
|
731
|
+
var hash = window.location.hash;
|
|
732
|
+
// Only act on our own `#story-<n>` fragments; leave every other in-page
|
|
733
|
+
// anchor (headings, skip link) to native browser behavior.
|
|
734
|
+
if (!/^#story-\d+$/.test(hash)) return;
|
|
735
|
+
var story = document.getElementById(hash.slice(1));
|
|
736
|
+
if (story && story.classList.contains('story')) openStory(story);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
applyHash();
|
|
740
|
+
window.addEventListener('hashchange', applyHash);
|
|
741
|
+
})();
|
|
554
742
|
})();
|