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.
- package/CHANGELOG.md +149 -2
- package/README.md +78 -3
- package/dist/cli.d.ts +50 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +204 -18
- package/dist/cli.js.map +1 -1
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +11 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/config.d.ts +38 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +20 -1
- package/dist/config.js.map +1 -1
- package/dist/coverage/screens.d.ts +11 -6
- package/dist/coverage/screens.d.ts.map +1 -1
- package/dist/coverage/screens.js +12 -7
- package/dist/coverage/screens.js.map +1 -1
- package/dist/reporter/assets/report.css +415 -51
- package/dist/reporter/assets/report.js +502 -68
- package/dist/reporter/template.d.ts +8 -1
- package/dist/reporter/template.d.ts.map +1 -1
- package/dist/reporter/template.js +517 -85
- package/dist/reporter/template.js.map +1 -1
- package/dist/reporter/writeReport.d.ts +2 -2
- package/dist/reporter/writeReport.d.ts.map +1 -1
- package/dist/reporter/writeReport.js +3 -3
- package/dist/reporter/writeReport.js.map +1 -1
- package/dist/runner/approve.d.ts +34 -9
- package/dist/runner/approve.d.ts.map +1 -1
- package/dist/runner/approve.js +54 -10
- package/dist/runner/approve.js.map +1 -1
- package/dist/runner/approveFrom.d.ts +58 -0
- package/dist/runner/approveFrom.d.ts.map +1 -0
- package/dist/runner/approveFrom.js +384 -0
- package/dist/runner/approveFrom.js.map +1 -0
- package/dist/runner/breakpointPasses.d.ts +39 -0
- package/dist/runner/breakpointPasses.d.ts.map +1 -0
- package/dist/runner/breakpointPasses.js +107 -0
- package/dist/runner/breakpointPasses.js.map +1 -0
- package/dist/runner/bridges/database.d.ts +4 -3
- package/dist/runner/bridges/database.d.ts.map +1 -1
- package/dist/runner/bridges/database.js +4 -3
- package/dist/runner/bridges/database.js.map +1 -1
- package/dist/runner/exitCode.d.ts +34 -0
- package/dist/runner/exitCode.d.ts.map +1 -0
- package/dist/runner/exitCode.js +41 -0
- package/dist/runner/exitCode.js.map +1 -0
- package/dist/runner/manifest.d.ts +149 -0
- package/dist/runner/manifest.d.ts.map +1 -0
- package/dist/runner/manifest.js +229 -0
- package/dist/runner/manifest.js.map +1 -0
- package/dist/runner/mode.d.ts +41 -0
- package/dist/runner/mode.d.ts.map +1 -0
- package/dist/runner/mode.js +43 -0
- package/dist/runner/mode.js.map +1 -0
- package/dist/runner/orphanScan.d.ts +68 -0
- package/dist/runner/orphanScan.d.ts.map +1 -0
- package/dist/runner/orphanScan.js +150 -0
- package/dist/runner/orphanScan.js.map +1 -0
- package/dist/runner/run.d.ts +85 -1
- package/dist/runner/run.d.ts.map +1 -1
- package/dist/runner/run.js +267 -41
- package/dist/runner/run.js.map +1 -1
- package/dist/runner/runAction.d.ts +16 -0
- package/dist/runner/runAction.d.ts.map +1 -1
- package/dist/runner/runAction.js +112 -13
- package/dist/runner/runAction.js.map +1 -1
- package/dist/runner/runStory.d.ts +15 -4
- package/dist/runner/runStory.d.ts.map +1 -1
- package/dist/runner/runStory.js +41 -19
- package/dist/runner/runStory.js.map +1 -1
- package/dist/runner/storyFilter.d.ts +8 -0
- package/dist/runner/storyFilter.d.ts.map +1 -1
- package/dist/runner/storyFilter.js +11 -0
- package/dist/runner/storyFilter.js.map +1 -1
- package/dist/schema/action.d.ts +11 -11
- package/dist/schema/result.d.ts +125 -6
- package/dist/schema/result.d.ts.map +1 -1
- package/dist/schema/result.js.map +1 -1
- package/dist/screenshots/baselineStore.d.ts +92 -10
- package/dist/screenshots/baselineStore.d.ts.map +1 -1
- package/dist/screenshots/baselineStore.js +102 -18
- package/dist/screenshots/baselineStore.js.map +1 -1
- package/dist/screenshots/capture.d.ts +10 -5
- package/dist/screenshots/capture.d.ts.map +1 -1
- package/dist/screenshots/capture.js +31 -18
- package/dist/screenshots/capture.js.map +1 -1
- 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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
initialRadio
|
|
91
|
-
|
|
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
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
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
|
|
114
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
170
|
-
value === 'all'
|
|
171
|
-
? 'Showing all ' + total + ' stories'
|
|
172
|
-
: 'Showing ' + visible + ' of ' + total + ' stories';
|
|
470
|
+
statusRegion.writeDebounced(filterMessage(visible), 150);
|
|
173
471
|
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
196
|
-
//
|
|
197
|
-
//
|
|
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(
|
|
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
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
})();
|