tuffgal 0.1.0-alpha.9 → 0.2.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +149 -2
  2. package/README.md +78 -3
  3. package/dist/cli.d.ts +50 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +204 -18
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/init.d.ts +1 -1
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +11 -1
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/config.d.ts +38 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +20 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/coverage/screens.d.ts +11 -6
  16. package/dist/coverage/screens.d.ts.map +1 -1
  17. package/dist/coverage/screens.js +12 -7
  18. package/dist/coverage/screens.js.map +1 -1
  19. package/dist/reporter/assets/report.css +415 -51
  20. package/dist/reporter/assets/report.js +502 -68
  21. package/dist/reporter/template.d.ts +8 -1
  22. package/dist/reporter/template.d.ts.map +1 -1
  23. package/dist/reporter/template.js +517 -85
  24. package/dist/reporter/template.js.map +1 -1
  25. package/dist/reporter/writeReport.d.ts +2 -2
  26. package/dist/reporter/writeReport.d.ts.map +1 -1
  27. package/dist/reporter/writeReport.js +3 -3
  28. package/dist/reporter/writeReport.js.map +1 -1
  29. package/dist/runner/approve.d.ts +34 -9
  30. package/dist/runner/approve.d.ts.map +1 -1
  31. package/dist/runner/approve.js +54 -10
  32. package/dist/runner/approve.js.map +1 -1
  33. package/dist/runner/approveFrom.d.ts +58 -0
  34. package/dist/runner/approveFrom.d.ts.map +1 -0
  35. package/dist/runner/approveFrom.js +384 -0
  36. package/dist/runner/approveFrom.js.map +1 -0
  37. package/dist/runner/breakpointPasses.d.ts +39 -0
  38. package/dist/runner/breakpointPasses.d.ts.map +1 -0
  39. package/dist/runner/breakpointPasses.js +107 -0
  40. package/dist/runner/breakpointPasses.js.map +1 -0
  41. package/dist/runner/bridges/database.d.ts +4 -3
  42. package/dist/runner/bridges/database.d.ts.map +1 -1
  43. package/dist/runner/bridges/database.js +4 -3
  44. package/dist/runner/bridges/database.js.map +1 -1
  45. package/dist/runner/exitCode.d.ts +34 -0
  46. package/dist/runner/exitCode.d.ts.map +1 -0
  47. package/dist/runner/exitCode.js +41 -0
  48. package/dist/runner/exitCode.js.map +1 -0
  49. package/dist/runner/manifest.d.ts +149 -0
  50. package/dist/runner/manifest.d.ts.map +1 -0
  51. package/dist/runner/manifest.js +229 -0
  52. package/dist/runner/manifest.js.map +1 -0
  53. package/dist/runner/mode.d.ts +41 -0
  54. package/dist/runner/mode.d.ts.map +1 -0
  55. package/dist/runner/mode.js +43 -0
  56. package/dist/runner/mode.js.map +1 -0
  57. package/dist/runner/orphanScan.d.ts +68 -0
  58. package/dist/runner/orphanScan.d.ts.map +1 -0
  59. package/dist/runner/orphanScan.js +150 -0
  60. package/dist/runner/orphanScan.js.map +1 -0
  61. package/dist/runner/run.d.ts +85 -1
  62. package/dist/runner/run.d.ts.map +1 -1
  63. package/dist/runner/run.js +267 -41
  64. package/dist/runner/run.js.map +1 -1
  65. package/dist/runner/runAction.d.ts +16 -0
  66. package/dist/runner/runAction.d.ts.map +1 -1
  67. package/dist/runner/runAction.js +112 -13
  68. package/dist/runner/runAction.js.map +1 -1
  69. package/dist/runner/runStory.d.ts +15 -4
  70. package/dist/runner/runStory.d.ts.map +1 -1
  71. package/dist/runner/runStory.js +41 -19
  72. package/dist/runner/runStory.js.map +1 -1
  73. package/dist/runner/storyFilter.d.ts +8 -0
  74. package/dist/runner/storyFilter.d.ts.map +1 -1
  75. package/dist/runner/storyFilter.js +11 -0
  76. package/dist/runner/storyFilter.js.map +1 -1
  77. package/dist/schema/action.d.ts +11 -11
  78. package/dist/schema/result.d.ts +125 -6
  79. package/dist/schema/result.d.ts.map +1 -1
  80. package/dist/schema/result.js.map +1 -1
  81. package/dist/screenshots/baselineStore.d.ts +92 -10
  82. package/dist/screenshots/baselineStore.d.ts.map +1 -1
  83. package/dist/screenshots/baselineStore.js +102 -18
  84. package/dist/screenshots/baselineStore.js.map +1 -1
  85. package/dist/screenshots/capture.d.ts +10 -5
  86. package/dist/screenshots/capture.d.ts.map +1 -1
  87. package/dist/screenshots/capture.js +31 -18
  88. package/dist/screenshots/capture.js.map +1 -1
  89. package/package.json +1 -1
@@ -57,6 +57,15 @@
57
57
  box-sizing: border-box;
58
58
  }
59
59
 
60
+ /* The story filter prunes stories, breakpoint groups, and actions by toggling
61
+ the native `hidden` attribute. Those are layout containers, so a future
62
+ `display:` rule on `.action`/`.breakpoint-group` would outrank the UA
63
+ `[hidden]` default and silently resurrect filtered rows as visible and
64
+ keyboard-focusable. Pin it so `hidden` always wins. */
65
+ [hidden] {
66
+ display: none !important;
67
+ }
68
+
60
69
  html {
61
70
  -webkit-font-smoothing: antialiased;
62
71
  -moz-osx-font-smoothing: grayscale;
@@ -144,14 +153,59 @@ h2 {
144
153
  text-wrap: balance;
145
154
  }
146
155
 
147
- .summary {
156
+ /* Environment-mismatch banner, first inside <main>. The "warning" meaning is
157
+ carried by the heading word + body copy, never by color alone (1.4.1). Body
158
+ text stays --text (≥4.5:1); the red accent lives only on the left border (a
159
+ non-text ≥3:1 cue, mirroring .action-error). The list of diverging keys reads
160
+ as monospace <code> tokens. */
161
+ .env-mismatch {
148
162
  margin-top: 0.5rem;
163
+ padding: 0.75rem 1rem;
164
+ background: var(--bg-elevated);
165
+ border: 1px solid var(--border);
166
+ border-left: 3px solid var(--failed);
149
167
  }
150
168
 
151
- .summary-list {
169
+ .env-mismatch h2 {
170
+ margin-top: 0.25rem;
171
+ color: var(--changed);
172
+ }
173
+
174
+ .env-mismatch p {
175
+ margin: 0.5rem 0;
176
+ color: var(--text);
177
+ font-family: var(--font-sans);
178
+ font-size: 0.875rem;
179
+ text-wrap: pretty;
180
+ }
181
+
182
+ .env-mismatch ul {
152
183
  display: flex;
153
184
  flex-wrap: wrap;
154
- gap: 1.25rem;
185
+ gap: 0.5rem;
186
+ margin: 0.5rem 0 0;
187
+ padding: 0;
188
+ list-style: none;
189
+ }
190
+
191
+ .env-mismatch li {
192
+ color: var(--changed);
193
+ }
194
+
195
+ /* Deleted (orphaned-baseline) section, a peer landmark after the stories. */
196
+ .deleted {
197
+ margin-top: 0.5rem;
198
+ }
199
+
200
+ .deleted p {
201
+ margin: 0 0 0.5rem;
202
+ color: var(--text-muted);
203
+ font-family: var(--font-sans);
204
+ font-size: 0.875rem;
205
+ text-wrap: pretty;
206
+ }
207
+
208
+ .deleted ul {
155
209
  margin: 0;
156
210
  padding: 0.75rem 1rem;
157
211
  list-style: none;
@@ -159,8 +213,24 @@ h2 {
159
213
  border: 1px solid var(--border);
160
214
  }
161
215
 
162
- .summary-item:not(.coverage) + .summary-item.coverage {
163
- margin-left: auto;
216
+ .deleted li {
217
+ padding: 0.15rem 0;
218
+ color: var(--text);
219
+ }
220
+
221
+ .summary {
222
+ margin-top: 0.5rem;
223
+ }
224
+
225
+ .summary-list {
226
+ display: flex;
227
+ flex-wrap: wrap;
228
+ gap: 0.5rem;
229
+ margin: 0;
230
+ padding: 0.75rem 1rem;
231
+ list-style: none;
232
+ background: var(--bg-elevated);
233
+ border: 1px solid var(--border);
164
234
  }
165
235
 
166
236
  .summary-item {
@@ -172,27 +242,206 @@ h2 {
172
242
 
173
243
  .summary-item .count {
174
244
  font-weight: 600;
245
+ color: var(--text-subtle);
175
246
  }
176
247
 
177
- .summary-item .label {
178
- color: var(--text-muted);
248
+ /* Each status total is a single-select filter styled as a colorful, button-like
249
+ pill keyed to its status accent (aligned to the .chip idiom). Its sibling count
250
+ precedes it outside the button. Default (aria-pressed=false) is an OUTLINE pill:
251
+ transparent background, status-colored label + 1px status-colored border. Hover
252
+ adds a faint status tint; pressed becomes a FILLED pill (status background,
253
+ paired --*-fg label) with bold weight. The 1px solid status border on every
254
+ state guarantees a perceivable non-text edge (1.4.11); label/fg pairings clear
255
+ 4.5:1 on --bg-elevated in both themes (1.4.3). Pressed is never color-alone
256
+ (1.4.1): it is the only filled + bold pill, readable without hue. Target size
257
+ ≥24×24 via min-height/min-width (2.5.8). Stays a <button>: it filters in place,
258
+ never navigates. */
259
+ .summary-filter {
260
+ display: inline-flex;
261
+ align-items: center;
262
+ gap: 0.5rem;
263
+ min-height: 24px;
264
+ min-width: 24px;
265
+ padding: 0.2rem 0.6rem;
266
+ line-height: 1.2;
267
+ border: 1px solid var(--border-bright);
268
+ border-radius: 999px;
269
+ background: transparent;
270
+ color: var(--text);
271
+ font: inherit;
272
+ cursor: pointer;
273
+ transition-property: scale, color, background-color, border-color;
274
+ transition-duration: 0.15s;
275
+ transition-timing-function: ease;
179
276
  }
180
277
 
181
- .summary-item[data-status='pass'] .indicator {
278
+ .summary-filter[aria-pressed='true'] {
279
+ cursor: default;
280
+ user-select: none;
281
+ font-weight: 600;
282
+ }
283
+
284
+ .summary-filter:active {
285
+ scale: 0.96;
286
+ }
287
+
288
+ /* Status pills: OUTLINE by default (status label + status border, transparent
289
+ bg), faint status tint on hover, FILLED on pressed (status bg + paired --*-fg
290
+ label). The "all" total stays NEUTRAL on every state: --accent equals --new, so
291
+ tinting "all" blue would falsely imply the "new" filter. Status label + border
292
+ clear their contrast floors on --bg-elevated in both themes; each status token
293
+ paired with its --*-fg fill clears 4.5:1. */
294
+ .summary-item[data-status='pass'] .summary-filter {
182
295
  color: var(--pass);
296
+ border-color: var(--pass);
297
+ }
298
+
299
+ .summary-item[data-status='pass'] .summary-filter:hover {
300
+ background: color-mix(in srgb, var(--pass) 15%, transparent);
301
+ }
302
+
303
+ .summary-item[data-status='pass'] .summary-filter[aria-pressed='true'] {
304
+ background: var(--pass);
305
+ color: var(--pass-fg);
183
306
  }
184
307
 
185
- .summary-item[data-status='changed'] .indicator {
308
+ .summary-item[data-status='changed'] .summary-filter {
186
309
  color: var(--changed);
310
+ border-color: var(--changed);
311
+ }
312
+
313
+ .summary-item[data-status='changed'] .summary-filter:hover {
314
+ background: color-mix(in srgb, var(--changed) 15%, transparent);
315
+ }
316
+
317
+ .summary-item[data-status='changed'] .summary-filter[aria-pressed='true'] {
318
+ background: var(--changed);
319
+ color: var(--changed-fg);
187
320
  }
188
321
 
189
- .summary-item[data-status='failed'] .indicator {
322
+ .summary-item[data-status='failed'] .summary-filter {
190
323
  color: var(--failed);
324
+ border-color: var(--failed);
325
+ }
326
+
327
+ .summary-item[data-status='failed'] .summary-filter:hover {
328
+ background: color-mix(in srgb, var(--failed) 15%, transparent);
329
+ }
330
+
331
+ .summary-item[data-status='failed'] .summary-filter[aria-pressed='true'] {
332
+ background: var(--failed);
333
+ color: var(--failed-fg);
334
+ }
335
+
336
+ .summary-item[data-status='new'] .summary-filter {
337
+ color: var(--new);
338
+ border-color: var(--new);
339
+ }
340
+
341
+ .summary-item[data-status='new'] .summary-filter:hover {
342
+ background: color-mix(in srgb, var(--new) 15%, transparent);
343
+ }
344
+
345
+ .summary-item[data-status='new'] .summary-filter[aria-pressed='true'] {
346
+ background: var(--new);
347
+ color: var(--new-fg);
348
+ }
349
+
350
+ /* "all" stays NEUTRAL — never a status color, never blue. Pressed is a filled
351
+ neutral pill (it is pressed-by-default at load, so this is the resting view). */
352
+ .summary-item[data-status='all'] .summary-filter {
353
+ color: var(--text);
354
+ border-color: var(--border-bright);
355
+ }
356
+
357
+ .summary-item[data-status='all'] .summary-filter[aria-pressed='true'] {
358
+ background: var(--bg);
359
+ color: var(--text);
360
+ }
361
+
362
+ .summary-filter:focus-visible {
363
+ outline: 2px solid var(--accent);
364
+ outline-offset: 2px;
365
+ }
366
+
367
+ /* Breakpoint filter — the SECOND, independent single-select filter dimension,
368
+ rendered below the status totals inside the summary region (only for
369
+ multi-breakpoint runs). A role="group" row of neutral pills; report.js ANDs it
370
+ with the status filter. The row sits on the page --bg with a top gap that also
371
+ guarantees ≥24px vertical spacing between the two pill rows (2.5.8). */
372
+ .breakpoint-filters {
373
+ display: flex;
374
+ flex-wrap: wrap;
375
+ gap: 0.5rem;
376
+ margin-top: 0.75rem;
377
+ }
378
+
379
+ /* Neutral pills — no status hue, so the two filter rows read as siblings by
380
+ geometry, not color. Same pill geometry/radius as the status filter.
381
+ Default is an OUTLINE pill: transparent bg, --text-muted border (≥3:1 vs --bg
382
+ in both themes → 1.4.11), --text label (≥4.5:1 → 1.4.3). Hover brightens the
383
+ border + adds a faint neutral tint. Pressed is a FILLED pill: --text fill,
384
+ --bg label, bold weight — the ONLY filled + bold pill, so the pressed state is
385
+ never conveyed by color alone (1.4.1); the inverse fill clears 4.5:1 in both
386
+ themes. Focus ring is --accent 2px / offset 2px, painted on the page --bg
387
+ outside the pill — identical to the status filter. Target size ≥24×24 via
388
+ min-height/min-width (2.5.8). Stays a <button>: it filters in place. */
389
+ .breakpoint-filter {
390
+ display: inline-flex;
391
+ align-items: center;
392
+ gap: 0.5rem;
393
+ min-height: 24px;
394
+ min-width: 24px;
395
+ padding: 0.2rem 0.6rem;
396
+ line-height: 1.2;
397
+ border: 1px solid var(--text-muted);
398
+ border-radius: 999px;
399
+ background: transparent;
400
+ color: var(--text);
401
+ font: inherit;
402
+ cursor: pointer;
403
+ transition-property: scale, color, background-color, border-color;
404
+ transition-duration: 0.15s;
405
+ transition-timing-function: ease;
406
+ }
407
+
408
+ .breakpoint-filter .breakpoint-name {
409
+ color: inherit;
410
+ font-weight: inherit;
411
+ text-transform: none;
191
412
  }
192
413
 
193
- .summary-item.coverage .coverage-detail {
414
+ .breakpoint-filter .breakpoint-dimensions {
194
415
  color: var(--text-subtle);
195
- font-size: 0.8125rem;
416
+ }
417
+
418
+ .breakpoint-filter:hover:not([aria-pressed='true']) {
419
+ border-color: var(--text);
420
+ background: color-mix(in srgb, var(--text) 8%, transparent);
421
+ }
422
+
423
+ .breakpoint-filter[aria-pressed='true'] {
424
+ cursor: default;
425
+ user-select: none;
426
+ font-weight: 600;
427
+ background: var(--text);
428
+ color: var(--bg);
429
+ border-color: var(--text);
430
+ }
431
+
432
+ /* The pressed pill's dimensions ride the inverse fill: --bg on --text is the
433
+ same ≥4.5:1 pair as the label, so they stay legible. */
434
+ .breakpoint-filter[aria-pressed='true'] .breakpoint-dimensions {
435
+ color: color-mix(in srgb, var(--bg) 65%, var(--text));
436
+ }
437
+
438
+ .breakpoint-filter:active {
439
+ scale: 0.96;
440
+ }
441
+
442
+ .breakpoint-filter:focus-visible {
443
+ outline: 2px solid var(--accent);
444
+ outline-offset: 2px;
196
445
  }
197
446
 
198
447
  .chip {
@@ -236,64 +485,72 @@ h2 {
236
485
  }
237
486
  }
238
487
 
239
- .stories-toolbar {
488
+ /* The expand-all / collapse-all pair now lives at the right end of the summary
489
+ row (the slot the coverage stats vacated), pushed there by margin-left:auto
490
+ while staying the LAST flex children so DOM order matches the visual order. */
491
+ .story-bulk-toggle {
240
492
  display: flex;
241
- flex-wrap: wrap;
242
493
  align-items: center;
243
- gap: 0.75rem 1rem;
244
- margin: 0 0 0.75rem;
494
+ gap: 0.25rem;
495
+ margin-left: auto;
496
+ line-height: 24px;
497
+ font-size: 0.8125rem;
245
498
  }
246
499
 
247
- .story-filter {
248
- display: flex;
249
- flex-wrap: wrap;
500
+ /* Link-styled verbs ("Expand" / "Collapse") — still <button>s, they act in
501
+ place. The underline is the affordance; verbs exceed 24px wide intrinsically,
502
+ so min-height alone carries 2.5.8. */
503
+ .story-bulk-toggle-button {
504
+ display: inline-flex;
250
505
  align-items: center;
251
- gap: 0.5rem;
252
- margin: 0;
506
+ min-height: 24px;
253
507
  padding: 0;
508
+ background: none;
254
509
  border: 0;
510
+ color: var(--text);
511
+ font: inherit;
512
+ cursor: pointer;
513
+ vertical-align: middle;
514
+ text-decoration: underline;
515
+ text-decoration-thickness: 1px;
516
+ text-underline-offset: 4.5px;
255
517
  }
256
518
 
257
- .story-filter legend {
258
- padding: 0 0.5rem 0 0;
259
- color: var(--text-muted);
260
- font-size: 0.75rem;
261
- letter-spacing: 0.05em;
262
- text-transform: uppercase;
519
+ .story-bulk-toggle-button:hover:not(:disabled) {
520
+ color: var(--accent);
521
+ text-decoration-thickness: 2px;
263
522
  }
264
523
 
265
- .story-filter-label input {
266
- position: absolute;
267
- opacity: 0;
268
- pointer-events: none;
524
+ .story-bulk-toggle-button:focus-visible {
525
+ outline: 2px solid var(--accent);
526
+ outline-offset: 1px;
269
527
  }
270
528
 
271
- .story-bulk-toggle {
272
- display: flex;
273
- flex-wrap: wrap;
274
- gap: 0.25rem;
275
- margin-left: auto;
529
+ /* Disabled verb: removing the underline is the color-independent cue (no
530
+ opacity — it can compound below the contrast floor). Disabled controls are
531
+ 1.4.3/1.4.11 exempt, but the state stays perceptible without color. */
532
+ .story-bulk-toggle-button:disabled {
533
+ color: var(--text-subtle);
534
+ text-decoration: none;
535
+ cursor: not-allowed;
276
536
  }
277
537
 
278
- .story-bulk-toggle-button {
279
- background: transparent;
280
- font: inherit;
281
- font-size: 0.8125rem;
282
- min-height: 24px;
538
+ .bulk-scope,
539
+ .bulk-sep {
540
+ color: var(--text-subtle);
283
541
  }
284
542
 
285
- .story-bulk-toggle-button:hover {
286
- color: var(--text);
287
- border-color: var(--accent);
543
+ .summary-item:has(+ .story-bulk-toggle) .bulk-sep {
544
+ display: none;
288
545
  }
289
546
 
290
- .story-bulk-toggle-button:focus-visible {
291
- outline: 2px solid var(--accent);
292
- outline-offset: 1px;
547
+ .bulk-scope {
548
+ cursor: default;
549
+ margin-inline-start: 0.25rem;
293
550
  }
294
551
 
295
552
  .story-filter-status {
296
- margin: 0;
553
+ margin: 0 0 0.75rem;
297
554
  color: var(--text-subtle);
298
555
  font-size: 0.8125rem;
299
556
  }
@@ -331,6 +588,10 @@ h2 {
331
588
  box-shadow: inset 3px 0 0 var(--failed);
332
589
  }
333
590
 
591
+ .story[data-status='new'] {
592
+ box-shadow: inset 3px 0 0 var(--new);
593
+ }
594
+
334
595
  .story[data-status='changed'] {
335
596
  box-shadow: inset 3px 0 0 var(--changed);
336
597
  }
@@ -357,6 +618,11 @@ h2 {
357
618
  color: var(--pass);
358
619
  }
359
620
 
621
+ .story[data-status='new'] .story-marker,
622
+ .story[data-status='new'] .story-file {
623
+ color: var(--new);
624
+ }
625
+
360
626
  .story[data-status='changed'] .story-marker,
361
627
  .story[data-status='changed'] .story-file {
362
628
  color: var(--changed);
@@ -380,7 +646,9 @@ h2 {
380
646
  text-wrap: pretty;
381
647
  }
382
648
 
383
- .breakpoint-group + .breakpoint-group {
649
+ /* `:not([hidden])` on both sides so a filter that hides the first group does
650
+ not strand its separator on the new first-visible group. */
651
+ .breakpoint-group:not([hidden]) + .breakpoint-group:not([hidden]) {
384
652
  margin-top: 0.75rem;
385
653
  padding-top: 0.75rem;
386
654
  border-top: 1px dashed var(--border);
@@ -509,6 +777,28 @@ h2 {
509
777
  overflow-x: auto;
510
778
  }
511
779
 
780
+ /* Supplementary per-action notes (candidate / a11y-drift). Both plain prose
781
+ inside li.action; an a11y-only changed row stacks both. Indented to align
782
+ under the action name (past the status badge), muted like other secondary
783
+ prose. Stacked cleanly with a small gap when both render. */
784
+ .candidate-note,
785
+ .a11y-drift-note {
786
+ margin: 0.35rem 0 0 2.2rem;
787
+ color: var(--text-muted);
788
+ font-family: var(--font-sans);
789
+ font-size: 0.8125rem;
790
+ text-wrap: pretty;
791
+ }
792
+
793
+ .candidate-note + .a11y-drift-note {
794
+ margin-top: 0.15rem;
795
+ }
796
+
797
+ .a11y-drift-note code {
798
+ font-family: var(--font-mono);
799
+ color: var(--text);
800
+ }
801
+
512
802
  summary.action-row {
513
803
  list-style: none;
514
804
  cursor: pointer;
@@ -629,13 +919,87 @@ summary.action-row:focus-visible {
629
919
  color: var(--text-subtle);
630
920
  }
631
921
 
922
+ /* No diff image to show (dimension mismatch). The note IS the primary content
923
+ of this slot, so it keeps full `--text` weight; italic alone marks the
924
+ "no data" state. */
925
+ .diff-stats--unavailable .label {
926
+ font-style: italic;
927
+ }
928
+
929
+ /* Interactive screenshot viewer (interactiveMode). The radio group + caption
930
+ sit in this fieldset, which lives OUTSIDE the clipped `.shot-stage`, so the
931
+ chip focus ring is never clipped by the stage overflow nor painted over
932
+ screenshot pixels. The chips reuse `.chip--toggle` (border `--border-bright`,
933
+ `--accent` focus ring) and the same `.shot-radio-label` base styles as the
934
+ radio-tab viewer — permanently visible, no interactiveMode-specific chip
935
+ styling. Visible chips are the mouse/touch path to every variant (including
936
+ Diff, which the press gesture does not cover) and double as the viewer's
937
+ only visible affordance; the press-and-hold blink-compare in report.js is
938
+ enhancement on top. */
939
+ .shot-interactive {
940
+ display: flex;
941
+ flex-wrap: wrap;
942
+ align-items: center;
943
+ gap: 0.5rem;
944
+ margin: 0.5rem 0 0.5rem 2.2rem;
945
+ padding: 0;
946
+ border: 0;
947
+ }
948
+
949
+ /* Persistent caption naming the DISPLAYED variant — the committed one, or the
950
+ press-and-hold preview while it is held (report.js keeps it in sync). Text on
951
+ `--text` (≥4.5:1), so the active variant is never conveyed by color alone —
952
+ it pairs the checked chip's accent border with this readable label.
953
+ aria-hidden keeps it visual only: the checked radio is the single accessible
954
+ source of committed state. */
955
+ .shot-caption {
956
+ margin: 0 0 0 0.25rem;
957
+ color: var(--text);
958
+ font-size: 0.8125rem;
959
+ }
960
+
961
+ .shot-caption-variant {
962
+ font-weight: 600;
963
+ }
964
+
965
+ /* The stage clips the screenshot to the viewport height. Its border uses
966
+ `--border-bright` (3:1 non-text contrast). overflow:hidden only ever clips
967
+ the image — the controls live in the sibling fieldset above. */
968
+ .shot-stage {
969
+ margin-top: 0.5rem;
970
+ margin-left: 2.2rem;
971
+ padding: 0.5rem;
972
+ border: 1px solid var(--border-bright);
973
+ background: var(--bg);
974
+ width: fit-content;
975
+ max-width: 100%;
976
+ overflow: hidden;
977
+ }
978
+
979
+ /* Scale the screenshot to fit within the viewport height (and the panel width)
980
+ so a tall capture never forces the page to scroll. width/height:auto preserve
981
+ the aspect ratio while `max-height` caps it to the viewport. */
982
+ .shot-image {
983
+ display: block;
984
+ width: auto;
985
+ height: auto;
986
+ max-width: 100%;
987
+ max-height: 85vh;
988
+ outline: 1px solid var(--img-outline);
989
+ outline-offset: -1px;
990
+ }
991
+
632
992
  @media (prefers-reduced-motion: reduce) {
633
993
  .skip-link,
634
994
  summary.action-row::after,
635
- .chip {
995
+ .chip,
996
+ .summary-filter,
997
+ .breakpoint-filter {
636
998
  transition: none;
637
999
  }
638
- .chip:active {
1000
+ .chip:active,
1001
+ .summary-filter:active,
1002
+ .breakpoint-filter:active {
639
1003
  scale: none;
640
1004
  }
641
1005
  }