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
|
@@ -6,6 +6,17 @@ const STATUS_LABELS = {
|
|
|
6
6
|
skipped: 'skipped',
|
|
7
7
|
new: 'new baseline',
|
|
8
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* The matcher token for a breakpoint bucket key, shared by the filter pills and
|
|
11
|
+
* the `[data-breakpoint]` hooks so a token always matches its container. The
|
|
12
|
+
* empty-string key (the defensive parse-guard bucket where a result carried no
|
|
13
|
+
* breakpoint) maps to the reserved `legacy` token — the same string
|
|
14
|
+
* {@link renderDeletedEntry} uses for the pre-breakpoint layout. Every other key
|
|
15
|
+
* is the real breakpoint name, passed through verbatim.
|
|
16
|
+
*/
|
|
17
|
+
function breakpointToken(key) {
|
|
18
|
+
return key === '' ? 'legacy' : key;
|
|
19
|
+
}
|
|
9
20
|
const STATUS_MARKERS = {
|
|
10
21
|
pass: '✓',
|
|
11
22
|
changed: '~',
|
|
@@ -17,8 +28,15 @@ const STATUS_MARKERS = {
|
|
|
17
28
|
* Static HTML report. Rocks a terminal, dev-tool aesthetic. Dark mode by
|
|
18
29
|
* default, monospace font for data, sharp borders, and minimal but
|
|
19
30
|
* evocative icons + neon colors to indicate statuses.
|
|
31
|
+
*
|
|
32
|
+
* `<main>` reading order: an optional environment-mismatch banner (first, only
|
|
33
|
+
* when `environment.mismatch`), then the summary, the stories, and an optional
|
|
34
|
+
* deleted (orphaned-baseline) section last. The banner and deleted section are
|
|
35
|
+
* peer `<h2>` landmarks; the two supplementary per-action notes (candidate /
|
|
36
|
+
* a11y-drift) contribute no heading levels, so the outline stays
|
|
37
|
+
* H1 → summary → stories → deleted.
|
|
20
38
|
*/
|
|
21
|
-
export function renderReport(result, reportDir) {
|
|
39
|
+
export function renderReport(result, reportDir, interactiveMode = false) {
|
|
22
40
|
const dateLabel = formatDate(result.finishedAt);
|
|
23
41
|
return `
|
|
24
42
|
<!doctype html>
|
|
@@ -40,98 +58,258 @@ export function renderReport(result, reportDir) {
|
|
|
40
58
|
</p>
|
|
41
59
|
</header>
|
|
42
60
|
<main id="main" tabindex="-1">
|
|
61
|
+
${renderEnvMismatch(result)}
|
|
43
62
|
${renderSummary(result)}
|
|
44
|
-
${renderStories(result, reportDir)}
|
|
63
|
+
${renderStories(result, reportDir, interactiveMode)}
|
|
64
|
+
${renderDeleted(result)}
|
|
45
65
|
</main>
|
|
46
66
|
<script src="assets/report.js"></script>
|
|
47
67
|
</body>
|
|
48
68
|
</html>
|
|
49
69
|
`;
|
|
50
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* The status totals double as the report's filter controls: each is a native
|
|
73
|
+
* `<button aria-pressed>` single-select filter, with the "stories" total acting
|
|
74
|
+
* as the "show all / clear" control (pressed by default). The expand-all /
|
|
75
|
+
* collapse-all pair sits at the right end of the same row — the slot the old
|
|
76
|
+
* coverage stats used to occupy. The `<ul>` stays a plain list (no composite
|
|
77
|
+
* role): a non-filter total and the bulk-toggle group share it, so a
|
|
78
|
+
* radiogroup/fieldset could not cleanly scope just the filters.
|
|
79
|
+
*
|
|
80
|
+
* A second, independent filter dimension — the breakpoint filter — renders
|
|
81
|
+
* BELOW the totals row inside this same summary region (see
|
|
82
|
+
* {@link renderBreakpointFilters}), but only when the run spans two or more
|
|
83
|
+
* distinct breakpoints. The two dimensions are ANDed by report.js.
|
|
84
|
+
*/
|
|
51
85
|
function renderSummary(result) {
|
|
52
|
-
const screens = result.customCoverage.screens;
|
|
53
|
-
const flows = result.customCoverage.flows;
|
|
54
86
|
return `
|
|
55
87
|
<section class="summary" aria-labelledby="summary-heading">
|
|
56
88
|
<h2 id="summary-heading">summary</h2>
|
|
57
89
|
<ul class="summary-list" aria-label="Run totals">
|
|
58
|
-
${
|
|
59
|
-
${
|
|
60
|
-
${
|
|
61
|
-
${
|
|
62
|
-
${
|
|
63
|
-
|
|
90
|
+
${summaryFilter('stories', result.totals.stories, 'all', true, ' — show all stories')}
|
|
91
|
+
${summaryFilter('passed', result.totals.passed, 'pass', false, ', show only passed stories')}
|
|
92
|
+
${summaryFilter('new', result.totals.new, 'new', false, ', show only new stories')}
|
|
93
|
+
${summaryFilter('changed', result.totals.changed, 'changed', false, ', show only changed stories')}
|
|
94
|
+
${summaryFilter('failed', result.totals.failed, 'failed', false, ', show only failed stories')}
|
|
95
|
+
<li class="story-bulk-toggle">
|
|
96
|
+
<button type="button" class="story-bulk-toggle-button" data-bulk-toggle="expand"><span class="bulk-verb">Expand</span><span class="bulk-scope-sr sr-only"> all screenshots</span></button>
|
|
97
|
+
<span class="bulk-sep" aria-hidden="true">/</span>
|
|
98
|
+
<button type="button" class="story-bulk-toggle-button" data-bulk-toggle="collapse"><span class="bulk-verb">Collapse</span><span class="bulk-scope-sr sr-only"> all screenshots</span></button>
|
|
99
|
+
<span class="bulk-scope" aria-hidden="true">screenshots</span>
|
|
100
|
+
</li>
|
|
64
101
|
</ul>
|
|
102
|
+
${renderBreakpointFilters(result)}
|
|
65
103
|
</section>
|
|
66
104
|
`;
|
|
67
105
|
}
|
|
68
|
-
|
|
69
|
-
|
|
106
|
+
/**
|
|
107
|
+
* The distinct breakpoints a run spanned, in first-seen order across every
|
|
108
|
+
* story's flat action array — the SAME order {@link renderStoryActions} buckets
|
|
109
|
+
* a single story's groups in, extended across stories. Each distinct breakpoint
|
|
110
|
+
* carries the capture dimensions of the FIRST action seen at it (the same source
|
|
111
|
+
* {@link renderBreakpointGroup} labels a group with), so the filter pill can
|
|
112
|
+
* echo the size.
|
|
113
|
+
*
|
|
114
|
+
* The bucket KEY is `action.breakpoint ?? ''` — identical to
|
|
115
|
+
* {@link renderStoryActions}, so a filter token always matches a rendered
|
|
116
|
+
* `[data-breakpoint]` container. The empty-string key is a defensive parse-guard
|
|
117
|
+
* only (the runner always tags a real breakpoint name); it maps to the reserved
|
|
118
|
+
* `legacy` token/label. A live `ActionResult` never carries the literal
|
|
119
|
+
* `'legacy'` breakpoint — that synthetic name is confined to orphaned-baseline
|
|
120
|
+
* (`deleted`) entries — so the empty-string bucket and a real `legacy` bucket
|
|
121
|
+
* cannot collide within one run's stories.
|
|
122
|
+
*/
|
|
123
|
+
function distinctBreakpoints(result) {
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
const distinct = [];
|
|
126
|
+
for (const story of result.stories) {
|
|
127
|
+
for (const action of story.actions) {
|
|
128
|
+
const key = action.breakpoint ?? '';
|
|
129
|
+
if (seen.has(key))
|
|
130
|
+
continue;
|
|
131
|
+
seen.add(key);
|
|
132
|
+
distinct.push({
|
|
133
|
+
key,
|
|
134
|
+
width: action.breakpointWidth,
|
|
135
|
+
height: action.breakpointHeight,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return distinct;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The breakpoint filter group: a second single-select filter dimension, sibling
|
|
143
|
+
* to the status totals inside the summary region. Rendered ONLY when the run
|
|
144
|
+
* spans two or more distinct breakpoints — a single-breakpoint run makes the
|
|
145
|
+
* filter a no-op, so the whole group is omitted.
|
|
146
|
+
*
|
|
147
|
+
* Each pill is a native `<button aria-pressed>` toggle (like the status filter),
|
|
148
|
+
* grouped in a `role="group"` with an accessible label so assistive tech reads
|
|
149
|
+
* the two rows as distinct filter axes. A leading "all breakpoints" reset is
|
|
150
|
+
* pressed by default and clears the dimension. The neutral palette (outline
|
|
151
|
+
* default, filled + bold when pressed) carries no status hue, so the two rows
|
|
152
|
+
* read as siblings without implying a status.
|
|
153
|
+
*
|
|
154
|
+
* The visible `.indicator` carries the human LABEL (breakpoint name + decorative
|
|
155
|
+
* dimensions), while `data-breakpoint-filter` carries the MATCHER TOKEN (the
|
|
156
|
+
* exact bucket key). report.js echoes the label, matches on the token against a
|
|
157
|
+
* container's `data-breakpoint`. The blank-bucket token/label is `legacy` (with
|
|
158
|
+
* the shared "(pre-breakpoint layout)" sr-only clarifier); every other token is
|
|
159
|
+
* the real breakpoint name. Dimensions reuse the exact idiom of
|
|
160
|
+
* {@link renderBreakpointGroup}: a decorative aria-hidden `375×667` plus an
|
|
161
|
+
* sr-only `375 by 667 pixels` longhand so the `×` glyph is never read as "x".
|
|
162
|
+
*/
|
|
163
|
+
function renderBreakpointFilters(result) {
|
|
164
|
+
const distinct = distinctBreakpoints(result);
|
|
165
|
+
if (distinct.length < 2) {
|
|
166
|
+
return '';
|
|
167
|
+
}
|
|
168
|
+
const pills = distinct.map((bp) => breakpointFilter(bp)).join('\n ');
|
|
70
169
|
return `
|
|
71
|
-
<
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
</
|
|
170
|
+
<div class="breakpoint-filters" role="group" aria-label="Filter by breakpoint">
|
|
171
|
+
<button type="button" class="breakpoint-filter" data-breakpoint-filter="all" aria-pressed="true" aria-controls="stories-list">
|
|
172
|
+
<span class="indicator label">all breakpoints</span><span class="sr-only">, show results at every breakpoint</span>
|
|
173
|
+
</button>
|
|
174
|
+
${pills}
|
|
175
|
+
</div>
|
|
77
176
|
`;
|
|
78
177
|
}
|
|
79
|
-
|
|
178
|
+
/**
|
|
179
|
+
* One breakpoint rendered as an unpressed single-select filter pill. The blank
|
|
180
|
+
* bucket (`key === ''`) reads visibly as "legacy" with an sr-only
|
|
181
|
+
* "(pre-breakpoint layout)" clarifier, mirroring {@link renderDeletedEntry};
|
|
182
|
+
* both the visible name and the `data-breakpoint-filter` token become the
|
|
183
|
+
* reserved `legacy` string so the token still matches the flat list's
|
|
184
|
+
* `data-breakpoint="legacy"` hook. Dimensions render only when the recorded
|
|
185
|
+
* result carried them.
|
|
186
|
+
*/
|
|
187
|
+
function breakpointFilter(bp) {
|
|
188
|
+
const isLegacy = bp.key === '';
|
|
189
|
+
const token = breakpointToken(bp.key);
|
|
190
|
+
const name = isLegacy
|
|
191
|
+
? 'legacy<span class="sr-only"> (pre-breakpoint layout)</span>'
|
|
192
|
+
: `${escapeHtml(bp.key)}`;
|
|
193
|
+
const dimensions = bp.width !== undefined && bp.height !== undefined
|
|
194
|
+
? ` <span class="breakpoint-dimensions" aria-hidden="true">${bp.width}×${bp.height}</span><span class="sr-only"> ${bp.width} by ${bp.height} pixels</span>`
|
|
195
|
+
: '';
|
|
196
|
+
return `<button type="button" class="breakpoint-filter" data-breakpoint-filter="${escapeHtml(token)}" aria-pressed="false" aria-controls="stories-list">
|
|
197
|
+
<span class="indicator label"><span class="breakpoint-name">${name}</span>${dimensions}</span><span class="sr-only">, show only results at this breakpoint</span>
|
|
198
|
+
</button>`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* One status total rendered as a single-select filter button. The visible count
|
|
202
|
+
* sits OUTSIDE the button as a sibling span and is wired to it via
|
|
203
|
+
* aria-describedby, so only the word ("passed") is the pill button while the
|
|
204
|
+
* count still reads as the button's description. The accessible name is the
|
|
205
|
+
* visible label plus a visually-hidden action suffix (e.g. ", show only passed
|
|
206
|
+
* stories"), composed from contents so the visible text is never dropped (WCAG
|
|
207
|
+
* 2.5.3 — never an aria-label). The `data-filter` token ("pass") is kept
|
|
208
|
+
* distinct from the visible label ("passed") so report.js matches
|
|
209
|
+
* `story[data-status="pass"]`.
|
|
210
|
+
*/
|
|
211
|
+
function summaryFilter(label, value, filter, pressed, actionSuffix) {
|
|
80
212
|
return `
|
|
81
|
-
<li class="summary-item"
|
|
82
|
-
<span class="count">${value}</span>
|
|
83
|
-
<span class="
|
|
213
|
+
<li class="summary-item" data-status="${filter}">
|
|
214
|
+
<span class="count" id="summary-count-${filter}">${value}</span><button type="button" class="summary-filter" data-filter="${filter}" aria-pressed="${pressed}" aria-controls="stories-list" aria-describedby="summary-count-${filter}"><span class="indicator label">${label}</span><span class="sr-only">${actionSuffix}</span></button>
|
|
215
|
+
<span class="bulk-sep" aria-hidden="true">·</span>
|
|
84
216
|
</li>
|
|
85
217
|
`;
|
|
86
218
|
}
|
|
87
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Environment-mismatch banner, rendered first inside `<main>` (above the
|
|
221
|
+
* summary) ONLY when `environment.mismatch === true` — i.e. a CI run whose
|
|
222
|
+
* committed baselines were captured under a different, pixel-affecting
|
|
223
|
+
* environment, or whose committed manifest could not be read at all.
|
|
224
|
+
*
|
|
225
|
+
* `role="alert"` is an explicit acceptance criterion. It does NOT auto-announce
|
|
226
|
+
* on content present at load, so the real discoverability comes from the banner
|
|
227
|
+
* being first in reading order behind a `<h2>` landmark. Kept because it is
|
|
228
|
+
* harmless and specified.
|
|
229
|
+
*
|
|
230
|
+
* The "warning" meaning is carried by the TEXT (the heading word and body copy),
|
|
231
|
+
* never by color alone (WCAG 1.4.1): body text stays `--text` / amber
|
|
232
|
+
* `--changed`, and the red accent lives only on the section's left border (a
|
|
233
|
+
* non-text ≥3:1 cue, mirroring `.action-error`).
|
|
234
|
+
*
|
|
235
|
+
* Two copy branches: the `['manifest']` sentinel (committed manifest unreadable,
|
|
236
|
+
* so the diverging keys can't be enumerated) drops the key list for a plain
|
|
237
|
+
* "can't be verified" message; any other mismatch lists the diverging keys.
|
|
238
|
+
*/
|
|
239
|
+
function renderEnvMismatch(result) {
|
|
240
|
+
const environment = result.environment;
|
|
241
|
+
if (environment?.mismatch !== true) {
|
|
242
|
+
return '';
|
|
243
|
+
}
|
|
244
|
+
const isManifestSentinel = environment.mismatchKeys.length === 1 &&
|
|
245
|
+
environment.mismatchKeys[0] === 'manifest';
|
|
246
|
+
const body = isManifestSentinel
|
|
247
|
+
? `<p>The committed baseline manifest could not be read, so the capture environment can't be verified. Expect a full re-approve.</p>`
|
|
248
|
+
: `<p>Expect a full re-approve — baselines were captured under a different environment.</p>
|
|
249
|
+
<ul aria-label="Changed environment keys">
|
|
250
|
+
${environment.mismatchKeys
|
|
251
|
+
.map((key) => `<li><code>${escapeHtml(key)}</code></li>`)
|
|
252
|
+
.join('\n ')}
|
|
253
|
+
</ul>`;
|
|
254
|
+
return `
|
|
255
|
+
<section class="env-mismatch" role="alert" aria-labelledby="env-mismatch-heading">
|
|
256
|
+
<h2 id="env-mismatch-heading">capture environment changed</h2>
|
|
257
|
+
${body}
|
|
258
|
+
</section>
|
|
259
|
+
`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Deleted (orphaned committed baselines) section — a peer landmark rendered
|
|
263
|
+
* AFTER the stories section, inside `<main>`, ONLY when `deleted` is non-empty.
|
|
264
|
+
* Lists each orphaned baseline (action + breakpoint) that `approve --prune`
|
|
265
|
+
* would remove. A `'legacy'` breakpoint (the pre-breakpoint `<action>/0.png`
|
|
266
|
+
* layout) reads cryptically on its own, so it carries an sr-only clarifier.
|
|
267
|
+
*/
|
|
268
|
+
function renderDeleted(result) {
|
|
269
|
+
if (result.deleted.length === 0) {
|
|
270
|
+
return '';
|
|
271
|
+
}
|
|
272
|
+
const items = result.deleted
|
|
273
|
+
.map((entry) => renderDeletedEntry(entry))
|
|
274
|
+
.join('\n ');
|
|
275
|
+
return `
|
|
276
|
+
<section class="deleted" aria-labelledby="deleted-heading">
|
|
277
|
+
<h2 id="deleted-heading">deleted</h2>
|
|
278
|
+
<p>These committed baselines have no matching story this run. <code>tuffgal approve --prune</code> removes them.</p>
|
|
279
|
+
<ul aria-label="Orphaned baselines to be pruned on approve">
|
|
280
|
+
${items}
|
|
281
|
+
</ul>
|
|
282
|
+
</section>
|
|
283
|
+
`;
|
|
284
|
+
}
|
|
285
|
+
function renderDeletedEntry(entry) {
|
|
286
|
+
const breakpoint = entry.breakpoint === 'legacy'
|
|
287
|
+
? 'legacy<span class="sr-only"> (pre-breakpoint layout)</span>'
|
|
288
|
+
: escapeHtml(entry.breakpoint);
|
|
289
|
+
return `<li><code>${escapeHtml(entry.action)}</code> ${breakpoint}</li>`;
|
|
290
|
+
}
|
|
291
|
+
function renderStories(result, reportDir, interactiveMode) {
|
|
88
292
|
const items = result.stories
|
|
89
|
-
.map((story, index) => renderStory(story, index, reportDir))
|
|
293
|
+
.map((story, index) => renderStory(story, index, reportDir, interactiveMode))
|
|
90
294
|
.join('\n');
|
|
91
295
|
const total = result.stories.length;
|
|
296
|
+
// The interactive controls now live in the summary row; this section keeps
|
|
297
|
+
// only the two live regions (both server-rendered between the heading and the
|
|
298
|
+
// list so a screen reader announces filter/bulk changes reliably) and the
|
|
299
|
+
// list itself, whose id the summary filter buttons target via aria-controls.
|
|
92
300
|
return `
|
|
93
301
|
<section aria-labelledby="stories-heading">
|
|
94
302
|
<h2 id="stories-heading">stories</h2>
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
${storyFilterRadio('all', true)}
|
|
99
|
-
${storyFilterRadio('passed', false)}
|
|
100
|
-
${storyFilterRadio('changed', false)}
|
|
101
|
-
${storyFilterRadio('failed', false)}
|
|
102
|
-
</fieldset>
|
|
103
|
-
<p class="story-filter-status" role="status" aria-live="polite">Showing all ${total} stories</p>
|
|
104
|
-
<div class="story-bulk-toggle">
|
|
105
|
-
<button type="button" class="chip story-bulk-toggle-button" data-bulk-toggle="expand">Expand all</button>
|
|
106
|
-
<button type="button" class="chip story-bulk-toggle-button" data-bulk-toggle="collapse">Collapse all</button>
|
|
107
|
-
</div>
|
|
108
|
-
<p class="bulk-toggle-status sr-only" role="status" aria-live="polite"></p>
|
|
109
|
-
</div>
|
|
110
|
-
<ol class="stories" aria-label="Stories executed in dependency order">
|
|
303
|
+
<p class="story-filter-status" role="status" aria-live="polite" aria-atomic="true">Showing all ${total} stories</p>
|
|
304
|
+
<p class="bulk-toggle-status sr-only" role="status" aria-live="polite" aria-atomic="true"></p>
|
|
305
|
+
<ol class="stories" id="stories-list" aria-label="Stories executed in dependency order">
|
|
111
306
|
${items}
|
|
112
307
|
</ol>
|
|
113
308
|
<p class="stories-empty" hidden>No matching stories</p>
|
|
114
309
|
</section>
|
|
115
310
|
`;
|
|
116
311
|
}
|
|
117
|
-
function
|
|
118
|
-
const inputId = `story-filter-${status}`;
|
|
119
|
-
const value = status === 'passed' ? 'pass' : status;
|
|
120
|
-
return `
|
|
121
|
-
<label for="${inputId}" class="chip chip--toggle story-filter-label">
|
|
122
|
-
<input
|
|
123
|
-
type="radio"
|
|
124
|
-
name="story-filter"
|
|
125
|
-
id="${inputId}"
|
|
126
|
-
value="${value}"
|
|
127
|
-
data-filter-name="${status}"
|
|
128
|
-
${checked ? 'checked' : ''}
|
|
129
|
-
/>
|
|
130
|
-
${status}
|
|
131
|
-
</label>
|
|
132
|
-
`;
|
|
133
|
-
}
|
|
134
|
-
function renderStory(story, storyIndex, reportDir) {
|
|
312
|
+
function renderStory(story, storyIndex, reportDir, interactiveMode) {
|
|
135
313
|
return `
|
|
136
314
|
<li class="story" data-status="${story.status}">
|
|
137
315
|
<div class="story-row">
|
|
@@ -142,7 +320,7 @@ function renderStory(story, storyIndex, reportDir) {
|
|
|
142
320
|
</div>
|
|
143
321
|
<p class="story-prose">${escapeHtml(story.story)}</p>
|
|
144
322
|
<br/>
|
|
145
|
-
${renderStoryActions(story, storyIndex, reportDir)}
|
|
323
|
+
${renderStoryActions(story, storyIndex, reportDir, interactiveMode)}
|
|
146
324
|
</li>
|
|
147
325
|
`;
|
|
148
326
|
}
|
|
@@ -165,7 +343,7 @@ function renderStory(story, storyIndex, reportDir) {
|
|
|
165
343
|
* shot-panel `id`s stay globally unique across groups and report.js's
|
|
166
344
|
* `name="<actionId>-shot"` radio grouping never collides between modes.
|
|
167
345
|
*/
|
|
168
|
-
function renderStoryActions(story, storyIndex, reportDir) {
|
|
346
|
+
function renderStoryActions(story, storyIndex, reportDir, interactiveMode) {
|
|
169
347
|
// Bucket actions by breakpoint, preserving first-seen order.
|
|
170
348
|
const order = [];
|
|
171
349
|
const buckets = new Map();
|
|
@@ -178,17 +356,20 @@ function renderStoryActions(story, storyIndex, reportDir) {
|
|
|
178
356
|
buckets.get(key).push({ action, id: `s${storyIndex}-a${actionIndex}` });
|
|
179
357
|
});
|
|
180
358
|
// One mode (or untagged) → flat list, no caption. Captions only earn their
|
|
181
|
-
// chrome when there is more than one mode to tell apart.
|
|
359
|
+
// chrome when there is more than one mode to tell apart. The flat list still
|
|
360
|
+
// carries the `data-breakpoint` hook (keyed off the sole bucket) so the
|
|
361
|
+
// breakpoint filter can hide the whole single-mode story when it does not
|
|
362
|
+
// match — no visible chrome is added, only the filter hook.
|
|
182
363
|
if (order.length <= 1) {
|
|
183
364
|
const actions = story.actions
|
|
184
|
-
.map((action, actionIndex) => renderAction(action, `s${storyIndex}-a${actionIndex}`, reportDir))
|
|
365
|
+
.map((action, actionIndex) => renderAction(action, `s${storyIndex}-a${actionIndex}`, reportDir, interactiveMode))
|
|
185
366
|
.join('\n');
|
|
186
|
-
return `<ol class="actions" aria-label="Actions">
|
|
367
|
+
return `<ol class="actions" aria-label="Actions" data-breakpoint="${escapeHtml(breakpointToken(order[0] ?? ''))}">
|
|
187
368
|
${actions}
|
|
188
369
|
</ol>`;
|
|
189
370
|
}
|
|
190
371
|
return order
|
|
191
|
-
.map((key, groupIndex) => renderBreakpointGroup(key, buckets.get(key), `s${storyIndex}-bp${groupIndex}`, reportDir))
|
|
372
|
+
.map((key, groupIndex) => renderBreakpointGroup(key, buckets.get(key), `s${storyIndex}-bp${groupIndex}`, reportDir, interactiveMode))
|
|
192
373
|
.join('\n');
|
|
193
374
|
}
|
|
194
375
|
/**
|
|
@@ -208,8 +389,12 @@ function renderStoryActions(story, storyIndex, reportDir) {
|
|
|
208
389
|
* Dimensions render in their own `aria-hidden` span (decorative "375×667")
|
|
209
390
|
* with an sr-only longhand ("375 by 667 pixels") so screen readers don't read
|
|
210
391
|
* the `×` glyph as "x" mid-stream.
|
|
392
|
+
*
|
|
393
|
+
* The group `<div>` additionally carries a `data-breakpoint` hook (the bucket
|
|
394
|
+
* key's matcher token) so the breakpoint filter can hide a whole group whose
|
|
395
|
+
* mode is not selected. Purely additive — the caption/label idiom is untouched.
|
|
211
396
|
*/
|
|
212
|
-
function renderBreakpointGroup(key, entries, groupId, reportDir) {
|
|
397
|
+
function renderBreakpointGroup(key, entries, groupId, reportDir, interactiveMode) {
|
|
213
398
|
const labelId = `${groupId}-label`;
|
|
214
399
|
const name = key;
|
|
215
400
|
// The capture dimensions recorded on the result reflect per-config and
|
|
@@ -220,14 +405,12 @@ function renderBreakpointGroup(key, entries, groupId, reportDir) {
|
|
|
220
405
|
recorded.breakpointHeight !== undefined
|
|
221
406
|
? { width: recorded.breakpointWidth, height: recorded.breakpointHeight }
|
|
222
407
|
: undefined;
|
|
223
|
-
const dimensionMarkup = dimensions
|
|
224
|
-
? `<span class="breakpoint-dimensions" aria-hidden="true">${dimensions.width}×${dimensions.height}</span><span class="sr-only">${dimensions.width} by ${dimensions.height} pixels</span>`
|
|
225
|
-
: '';
|
|
408
|
+
const dimensionMarkup = dimensions ? dimensionPair(dimensions) : '';
|
|
226
409
|
const actions = entries
|
|
227
|
-
.map(({ action, id }) => renderAction(action, id, reportDir))
|
|
410
|
+
.map(({ action, id }) => renderAction(action, id, reportDir, interactiveMode))
|
|
228
411
|
.join('\n');
|
|
229
412
|
return `
|
|
230
|
-
<div class="breakpoint-group">
|
|
413
|
+
<div class="breakpoint-group" data-breakpoint="${escapeHtml(breakpointToken(key))}">
|
|
231
414
|
<p class="breakpoint-label" id="${labelId}">
|
|
232
415
|
<span class="breakpoint-name">${escapeHtml(name)}</span>
|
|
233
416
|
${dimensionMarkup}
|
|
@@ -239,8 +422,8 @@ function renderBreakpointGroup(key, entries, groupId, reportDir) {
|
|
|
239
422
|
</div>
|
|
240
423
|
`;
|
|
241
424
|
}
|
|
242
|
-
function renderAction(action, actionId, reportDir) {
|
|
243
|
-
const screenshots = renderScreenshots(action, actionId, reportDir);
|
|
425
|
+
function renderAction(action, actionId, reportDir, interactiveMode) {
|
|
426
|
+
const screenshots = renderScreenshots(action, actionId, reportDir, interactiveMode);
|
|
244
427
|
const errorBlock = action.status === 'failed'
|
|
245
428
|
? `<pre class="action-error">${escapeHtml(action.failureMessage ?? 'unknown error')}</pre>`
|
|
246
429
|
: '';
|
|
@@ -264,14 +447,56 @@ ${statusBadge(action.status)}
|
|
|
264
447
|
${rowInner}
|
|
265
448
|
</div>
|
|
266
449
|
`;
|
|
450
|
+
const notes = renderActionNotes(action, reportDir);
|
|
267
451
|
return `
|
|
268
452
|
<li class="action" data-status="${action.status}">
|
|
269
453
|
${row}
|
|
270
454
|
${parameters}
|
|
271
455
|
${errorBlock}
|
|
456
|
+
${notes}
|
|
272
457
|
</li>
|
|
273
458
|
`;
|
|
274
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Supplementary per-action prose, rendered inside `li.action` after the
|
|
462
|
+
* row/screenshots/error block. Both are plain `<p role="note">` — NOT headings:
|
|
463
|
+
* they live in the story `<ol>` subtree, so a heading would orphan under a
|
|
464
|
+
* list-item (same rationale as {@link renderBreakpointGroup}'s caption). Neither
|
|
465
|
+
* is a live region (present at load), so an a11y-only `changed` row can carry
|
|
466
|
+
* BOTH with no double-announce.
|
|
467
|
+
*
|
|
468
|
+
* - candidate note: on `changed`/`new` rows only — supplementary prose flagging
|
|
469
|
+
* "this run's actual screenshot is the proposed new baseline". Names the
|
|
470
|
+
* Actual variant explicitly (the note sits OUTSIDE the <details>, so "this
|
|
471
|
+
* render" would point at whichever tab is selected — or nothing when
|
|
472
|
+
* collapsed). No aria wiring; it does not duplicate the status badge's
|
|
473
|
+
* sr-only label.
|
|
474
|
+
* - a11y-drift note: gated STRICTLY on `a11yChanged === true`, NEVER inferred
|
|
475
|
+
* from a missing `diffPath` (a size-mismatch `changed` row also lacks a
|
|
476
|
+
* diffPath yet deliberately omits `a11yChanged`; keying on `!diffPath` would
|
|
477
|
+
* misfire on it — the load-bearing discriminator, per the ActionResult
|
|
478
|
+
* contract). Renders the recorded `a11yBaselinePath` as plain escaped
|
|
479
|
+
* `<code>` TEXT, not a link (the report is portable / opened over file://),
|
|
480
|
+
* relativized like the other paths when absolute.
|
|
481
|
+
*
|
|
482
|
+
* Order: candidate note first, then a11y-drift note, so an a11y-only changed row
|
|
483
|
+
* reads "proposed new baseline" before the a11y specifics.
|
|
484
|
+
*/
|
|
485
|
+
function renderActionNotes(action, reportDir) {
|
|
486
|
+
const notes = [];
|
|
487
|
+
if (action.status === 'changed' || action.status === 'new') {
|
|
488
|
+
notes.push(`<p class="candidate-note" role="note">This run's actual screenshot is the proposed new baseline.</p>`);
|
|
489
|
+
}
|
|
490
|
+
if (action.a11yChanged === true) {
|
|
491
|
+
const path = action.a11yBaselinePath;
|
|
492
|
+
const displayPath = path !== undefined ? toReportRelative(reportDir, path) : undefined;
|
|
493
|
+
const pathMarkup = displayPath !== undefined
|
|
494
|
+
? ` Proposed a11y baseline written to <code>${escapeHtml(displayPath)}</code>.`
|
|
495
|
+
: '';
|
|
496
|
+
notes.push(`<p class="a11y-drift-note" role="note">Accessibility snapshot changed.${pathMarkup}</p>`);
|
|
497
|
+
}
|
|
498
|
+
return notes.join('\n ');
|
|
499
|
+
}
|
|
275
500
|
function renderParameters(parameters) {
|
|
276
501
|
if (!parameters || Object.keys(parameters).length === 0) {
|
|
277
502
|
return '';
|
|
@@ -281,11 +506,43 @@ function renderParameters(parameters) {
|
|
|
281
506
|
.join('');
|
|
282
507
|
return `<dl class="action-parameters">${entries}</dl>`;
|
|
283
508
|
}
|
|
284
|
-
|
|
509
|
+
/**
|
|
510
|
+
* A "changed" row whose diff could not be computed: baseline and actual differ
|
|
511
|
+
* in dimensions, so pixelmatch throws before producing a diffRatio or diff
|
|
512
|
+
* image, leaving only the recorded failureMessage. Centralizes the predicate
|
|
513
|
+
* shared by the radio fallback, the diff radio's describedby wiring, and
|
|
514
|
+
* renderDiffStats' unavailable note.
|
|
515
|
+
*/
|
|
516
|
+
function isDiffUnavailable(action) {
|
|
517
|
+
return (action.diffRatio === undefined &&
|
|
518
|
+
action.status === 'changed' &&
|
|
519
|
+
!!action.failureMessage);
|
|
520
|
+
}
|
|
521
|
+
function renderScreenshots(action, actionId, reportDir, interactiveMode) {
|
|
285
522
|
if (!action.actualPath && !action.baselinePath) {
|
|
286
523
|
return '';
|
|
287
524
|
}
|
|
288
|
-
|
|
525
|
+
// interactiveMode swaps the radio-tab output for the single-image
|
|
526
|
+
// press-and-hold viewer. When it is false the function falls through to the
|
|
527
|
+
// radio-tab output below, byte-identical with the pre-interactiveMode render.
|
|
528
|
+
//
|
|
529
|
+
// When the baseline and actual differ in dimensions the diff is uncomputable
|
|
530
|
+
// (renderDiffStats' unavailable branch — both paths present, `changed` status,
|
|
531
|
+
// a recorded failureMessage, no diffRatio). Press-flipping between two
|
|
532
|
+
// differently-sized captures misaligns everything and reads as "everything
|
|
533
|
+
// changed", so fall back to the radio-tab render: visible panels, baseline +
|
|
534
|
+
// actual, and the disabled diff option carrying the mismatch reason.
|
|
535
|
+
const diffUncomputable = action.baselinePath !== undefined &&
|
|
536
|
+
action.actualPath !== undefined &&
|
|
537
|
+
isDiffUnavailable(action);
|
|
538
|
+
if (interactiveMode && !diffUncomputable) {
|
|
539
|
+
return renderInteractiveScreenshots(action, actionId, reportDir);
|
|
540
|
+
}
|
|
541
|
+
// A `new` baseline writes its baseline straight from this run's actual, so the
|
|
542
|
+
// two paths point at byte-identical PNGs. There is no prior state to compare
|
|
543
|
+
// against, so suppress the baseline variant for display — like the diff, it is
|
|
544
|
+
// not a real artifact here. The path stays in the data model for `approve`.
|
|
545
|
+
const baseline = action.baselinePath && action.status !== 'new'
|
|
289
546
|
? toReportRelative(reportDir, action.baselinePath)
|
|
290
547
|
: undefined;
|
|
291
548
|
const actual = action.actualPath
|
|
@@ -294,30 +551,205 @@ function renderScreenshots(action, actionId, reportDir) {
|
|
|
294
551
|
const diff = action.diffPath
|
|
295
552
|
? toReportRelative(reportDir, action.diffPath)
|
|
296
553
|
: undefined;
|
|
297
|
-
const defaultTab =
|
|
554
|
+
const defaultTab = 'actual';
|
|
298
555
|
const available = { baseline, actual, diff };
|
|
299
556
|
const initialTab = available[defaultTab] !== undefined
|
|
300
557
|
? defaultTab
|
|
301
558
|
: ['baseline', 'actual', 'diff'].find((name) => available[name] !== undefined);
|
|
302
559
|
const diffStatsId = `${actionId}-diff-stats`;
|
|
303
|
-
const diffStats = action
|
|
304
|
-
|
|
305
|
-
|
|
560
|
+
const diffStats = renderDiffStats(action, diffStatsId);
|
|
561
|
+
// renderDiffStats emits the `diff-stats--unavailable` note (with this id) when
|
|
562
|
+
// the diff is uncomputable. The diff radio is disabled in that case, so wire
|
|
563
|
+
// its aria-describedby to the note — a keyboard/AT user reaching the disabled
|
|
564
|
+
// diff option then hears why no diff exists. (The diff PANEL only takes the
|
|
565
|
+
// association when a real diff image renders, below.)
|
|
566
|
+
const diffNoteUnavailable = isDiffUnavailable(action);
|
|
567
|
+
// When only one variant is real (the common case for a `new` baseline, whose
|
|
568
|
+
// baseline is suppressed and has no diff), a radio group offering a single
|
|
569
|
+
// choice is noise to AT. Drop the controls and show the image alone; the row's
|
|
570
|
+
// status badge already announces "new baseline". Zero variants — a `new` row
|
|
571
|
+
// whose actual capture is somehow missing — collapses to nothing.
|
|
572
|
+
const soleVariant = ['baseline', 'actual', 'diff'].filter((name) => available[name] !== undefined);
|
|
573
|
+
if (soleVariant.length <= 1) {
|
|
574
|
+
const only = soleVariant[0];
|
|
575
|
+
if (only === undefined) {
|
|
576
|
+
return '';
|
|
577
|
+
}
|
|
578
|
+
const lone = shotPanel(actionId, only, available[only], SHOT_ALT[only](action), true);
|
|
579
|
+
return `${diffStats}${lone}`;
|
|
580
|
+
}
|
|
306
581
|
return `
|
|
307
582
|
<fieldset class="shot-radio" data-default-tab="${defaultTab}">
|
|
308
583
|
<legend class="sr-only">Screenshot to display</legend>
|
|
309
584
|
${shotRadio(actionId, 'baseline', baseline === undefined, initialTab === 'baseline')}
|
|
310
585
|
${shotRadio(actionId, 'actual', actual === undefined, initialTab === 'actual')}
|
|
311
|
-
${shotRadio(actionId, 'diff', diff === undefined, initialTab === 'diff')}
|
|
586
|
+
${shotRadio(actionId, 'diff', diff === undefined, initialTab === 'diff', diffNoteUnavailable ? { describedById: diffStatsId } : undefined)}
|
|
312
587
|
${diffStats}
|
|
313
588
|
</fieldset>
|
|
314
|
-
${shotPanel(actionId, 'baseline', baseline,
|
|
315
|
-
${shotPanel(actionId, 'actual', actual,
|
|
316
|
-
${shotPanel(actionId, 'diff', diff,
|
|
589
|
+
${shotPanel(actionId, 'baseline', baseline, SHOT_ALT.baseline(action), initialTab === 'baseline')}
|
|
590
|
+
${shotPanel(actionId, 'actual', actual, SHOT_ALT.actual(action), initialTab === 'actual')}
|
|
591
|
+
${shotPanel(actionId, 'diff', diff, SHOT_ALT.diff(action), initialTab === 'diff', action.diffRatio !== undefined ? diffStatsId : undefined)}
|
|
317
592
|
`;
|
|
318
593
|
}
|
|
319
|
-
|
|
594
|
+
/** Alt text per screenshot variant, shared by the radio-tab and collapsed render. */
|
|
595
|
+
const SHOT_ALT = {
|
|
596
|
+
baseline: (action) => `${action.action} baseline screenshot`,
|
|
597
|
+
actual: (action) => `${action.action} actual screenshot from this run`,
|
|
598
|
+
diff: (action) => `Pixel diff overlay for ${action.action}; changed regions are highlighted`,
|
|
599
|
+
};
|
|
600
|
+
/**
|
|
601
|
+
* The pixel-diff overlay only exists when baseline and actual share dimensions.
|
|
602
|
+
* A mismatch (e.g. a fullPage capture whose document grew taller) throws before
|
|
603
|
+
* a diff is computed, so `diffRatio` is absent and there is no diff image to
|
|
604
|
+
* show — surface the recorded reason here, in the slot the "% differs" stat
|
|
605
|
+
* would normally occupy, so a "changed" row never reads as an unexplained no-op.
|
|
606
|
+
*
|
|
607
|
+
* The mismatch note folds baseline and actual sizes into ONE sentence and renders
|
|
608
|
+
* each dimension pair in the file's split idiom (a `breakpoint-dimensions` span
|
|
609
|
+
* carrying the `×` glyph for sight, plus an `sr-only` "W by H pixels" longhand for
|
|
610
|
+
* AT — never a bare `x`). It reads those numbers from the structured
|
|
611
|
+
* `action.sizeMismatch` pair, NOT by parsing `failureMessage`. When that pair is
|
|
612
|
+
* absent (a malformed or older result), it falls back to escaping the recorded
|
|
613
|
+
* `failureMessage` prose rather than risk a wrong parse.
|
|
614
|
+
*/
|
|
615
|
+
function renderDiffStats(action, diffStatsId) {
|
|
616
|
+
if (action.diffRatio !== undefined) {
|
|
617
|
+
return `<p class="diff-stats" id="${diffStatsId}"><span class="count">${parseFloat((action.diffRatio * 100).toFixed(2))}%</span> <span class="label">differs</span> <span class="coverage-detail">· ${(action.diffPixels ?? 0).toLocaleString('en-US')} pixels</span></p>`;
|
|
618
|
+
}
|
|
619
|
+
if (action.status !== 'changed' || !action.failureMessage) {
|
|
620
|
+
return '';
|
|
621
|
+
}
|
|
622
|
+
const label = action.sizeMismatch
|
|
623
|
+
? `No pixel diff — screenshot resized from ${dimensionPair(action.sizeMismatch.baseline)} to ${dimensionPair(action.sizeMismatch.actual)}.`
|
|
624
|
+
: `No pixel diff. ${escapeHtml(action.failureMessage)}`;
|
|
625
|
+
return `<p class="diff-stats diff-stats--unavailable" id="${diffStatsId}"><span class="label">${label}</span></p>`;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* A `width`×`height` pair in the file's split idiom: the `×` glyph is sight-only
|
|
629
|
+
* (aria-hidden), and AT hears the spoken "W by H pixels" longhand instead of an
|
|
630
|
+
* ambiguous "x". Shared by {@link renderDiffStats} (the size-mismatch note) and
|
|
631
|
+
* {@link renderBreakpointGroup} (the per-mode caption), which emit byte-identical
|
|
632
|
+
* markup. {@link breakpointFilter} renders the same idiom but keeps its own
|
|
633
|
+
* inline copy: its sr-only longhand carries a leading space (` W by H pixels`)
|
|
634
|
+
* that this helper omits, so routing it through here would change the rendered
|
|
635
|
+
* bytes.
|
|
636
|
+
*/
|
|
637
|
+
function dimensionPair(size) {
|
|
638
|
+
return `<span class="breakpoint-dimensions" aria-hidden="true">${size.width}×${size.height}</span><span class="sr-only">${size.width} by ${size.height} pixels</span>`;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Interactive screenshot viewer (interactiveMode). One native radio group per
|
|
642
|
+
* action is the committed-state source of truth — keyboard, touch, and AT all
|
|
643
|
+
* operate the radios (reusing `name="${actionId}-shot"`), and the chips render
|
|
644
|
+
* permanently visible (the mouse/touch path to every variant, including Diff).
|
|
645
|
+
* report.js layers a VISUAL-ONLY press-and-hold blink-compare on a SINGLE
|
|
646
|
+
* shared `<img>`: press shows the committed variant's counterpart (baseline
|
|
647
|
+
* normally, actual when baseline is committed); release/leave/dragstart
|
|
648
|
+
* reverts. No hover behavior. The gesture rewrites only the img src and the
|
|
649
|
+
* aria-hidden "Showing:" caption (which names the DISPLAYED variant, preview
|
|
650
|
+
* included) — never radio state, the img alt, or any ARIA. The controls +
|
|
651
|
+
* caption live OUTSIDE the clipped `.shot-stage` so
|
|
652
|
+
* the focus ring is never clipped or painted over screenshot pixels. The diff
|
|
653
|
+
* variant — and its radio — is omitted entirely (not disabled) when this action
|
|
654
|
+
* produced no diff image; the existing diff-stats association then rides on the
|
|
655
|
+
* diff radio control rather than the image, leaving no dangling describedby.
|
|
656
|
+
* (Distinct from the dimension-mismatch case, where the diff is uncomputable:
|
|
657
|
+
* renderScreenshots never reaches this viewer and instead falls back to the
|
|
658
|
+
* radio-tab render with a disabled — not omitted — diff radio.)
|
|
659
|
+
*/
|
|
660
|
+
function renderInteractiveScreenshots(action, actionId, reportDir) {
|
|
661
|
+
// A `new` baseline writes its baseline straight from this run's actual, so the
|
|
662
|
+
// two paths point at byte-identical PNGs. There is no prior state to compare
|
|
663
|
+
// against, so suppress the baseline variant for display — like the diff, it is
|
|
664
|
+
// omitted (no radio, no image). The path stays in the data model for `approve`.
|
|
665
|
+
const baseline = action.baselinePath && action.status !== 'new'
|
|
666
|
+
? toReportRelative(reportDir, action.baselinePath)
|
|
667
|
+
: undefined;
|
|
668
|
+
const actual = action.actualPath
|
|
669
|
+
? toReportRelative(reportDir, action.actualPath)
|
|
670
|
+
: undefined;
|
|
671
|
+
const diff = action.diffPath
|
|
672
|
+
? toReportRelative(reportDir, action.diffPath)
|
|
673
|
+
: undefined;
|
|
674
|
+
// Default committed variant is `actual`; only a baseline-only row (no actual
|
|
675
|
+
// capture this run) commits to baseline instead. renderScreenshots' guard
|
|
676
|
+
// guarantees at least one of the two exists, so `committedSrc` is never empty
|
|
677
|
+
// at runtime — the `?? ''` only satisfies the optional types.
|
|
678
|
+
const committed = actual !== undefined ? 'actual' : 'baseline';
|
|
679
|
+
const committedSrc = (committed === 'actual' ? actual : baseline) ?? '';
|
|
680
|
+
// When only one variant is real (the common case for a `new` baseline, whose
|
|
681
|
+
// baseline is suppressed and has no diff), the press-and-hold flip has nothing
|
|
682
|
+
// to toggle to — a lone radio in a group is noise to AT. Render the image alone;
|
|
683
|
+
// the row's status badge already announces "new baseline". Zero variants — a
|
|
684
|
+
// `new` row whose actual capture is missing — collapses to nothing.
|
|
685
|
+
const variantCount = [baseline, actual, diff].filter((v) => v !== undefined).length;
|
|
686
|
+
if (variantCount === 0) {
|
|
687
|
+
return '';
|
|
688
|
+
}
|
|
689
|
+
if (variantCount === 1) {
|
|
690
|
+
return `
|
|
691
|
+
<div class="shot-stage">
|
|
692
|
+
<img
|
|
693
|
+
class="shot-image"
|
|
694
|
+
src="${escapeHtml(committedSrc)}"
|
|
695
|
+
alt="${escapeHtml(`Screenshot of ${action.action}`)}"
|
|
696
|
+
loading="lazy"
|
|
697
|
+
/>
|
|
698
|
+
</div>
|
|
699
|
+
`;
|
|
700
|
+
}
|
|
701
|
+
const diffStatsId = `${actionId}-diff-stats`;
|
|
702
|
+
const diffStats = renderDiffStats(action, diffStatsId);
|
|
703
|
+
const radios = [
|
|
704
|
+
baseline !== undefined
|
|
705
|
+
? shotRadio(actionId, 'baseline', false, committed === 'baseline', {
|
|
706
|
+
label: 'Baseline',
|
|
707
|
+
})
|
|
708
|
+
: '',
|
|
709
|
+
actual !== undefined
|
|
710
|
+
? shotRadio(actionId, 'actual', false, committed === 'actual', {
|
|
711
|
+
label: 'Actual',
|
|
712
|
+
})
|
|
713
|
+
: '',
|
|
714
|
+
diff !== undefined
|
|
715
|
+
? shotRadio(actionId, 'diff', false, false, {
|
|
716
|
+
label: 'Diff',
|
|
717
|
+
describedById: diffStats !== '' ? diffStatsId : undefined,
|
|
718
|
+
})
|
|
719
|
+
: '',
|
|
720
|
+
].join('');
|
|
721
|
+
const captionLabel = committed === 'actual' ? 'Actual' : 'Baseline';
|
|
722
|
+
const dataSrc = [
|
|
723
|
+
baseline !== undefined
|
|
724
|
+
? ` data-src-baseline="${escapeHtml(baseline)}"`
|
|
725
|
+
: '',
|
|
726
|
+
actual !== undefined ? ` data-src-actual="${escapeHtml(actual)}"` : '',
|
|
727
|
+
diff !== undefined ? ` data-src-diff="${escapeHtml(diff)}"` : '',
|
|
728
|
+
].join('');
|
|
729
|
+
return `
|
|
730
|
+
<fieldset class="shot-interactive">
|
|
731
|
+
<legend class="sr-only">${escapeHtml(action.action)} screenshot</legend>
|
|
732
|
+
${radios}
|
|
733
|
+
<p class="shot-caption" aria-hidden="true">Showing: <span class="shot-caption-variant">${captionLabel}</span></p>
|
|
734
|
+
${diffStats}
|
|
735
|
+
</fieldset>
|
|
736
|
+
<div class="shot-stage">
|
|
737
|
+
<img
|
|
738
|
+
class="shot-image"
|
|
739
|
+
src="${escapeHtml(committedSrc)}"
|
|
740
|
+
alt="${escapeHtml(`Screenshot of ${action.action}`)}"
|
|
741
|
+
loading="lazy"
|
|
742
|
+
${dataSrc}
|
|
743
|
+
/>
|
|
744
|
+
</div>
|
|
745
|
+
`;
|
|
746
|
+
}
|
|
747
|
+
function shotRadio(actionId, name, disabled, checked, opts) {
|
|
320
748
|
const inputId = `${actionId}-radio-${name}`;
|
|
749
|
+
const label = opts?.label ?? name;
|
|
750
|
+
const describedBy = opts?.describedById
|
|
751
|
+
? ` aria-describedby="${opts.describedById}"`
|
|
752
|
+
: '';
|
|
321
753
|
return `
|
|
322
754
|
<label for="${inputId}" class="chip chip--toggle shot-radio-label">
|
|
323
755
|
<input
|
|
@@ -325,11 +757,11 @@ function shotRadio(actionId, name, disabled, checked) {
|
|
|
325
757
|
name="${actionId}-shot"
|
|
326
758
|
id="${inputId}"
|
|
327
759
|
value="${name}"
|
|
328
|
-
data-tab="${name}"
|
|
760
|
+
data-tab="${name}"${describedBy}
|
|
329
761
|
${disabled ? 'disabled' : ''}
|
|
330
762
|
${checked ? 'checked' : ''}
|
|
331
763
|
/>
|
|
332
|
-
${
|
|
764
|
+
${label}
|
|
333
765
|
</label>
|
|
334
766
|
`;
|
|
335
767
|
}
|