tuffgal 0.2.0-alpha.1 → 0.2.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,43 @@ this project uses [Pride Versioning](https://pridever.org) → `PROUD.DEFAULT.SH
8
8
 
9
9
  _Nothing just yet_
10
10
 
11
+ ## [0.2.0-alpha.3] — 2026-07-16
12
+
13
+ ### Added
14
+
15
+ Deep links for HTML report stories. Allows Tuffgal action to link directly
16
+ to changed stories in PR comments.
17
+
18
+ ## [0.2.0-alpha.2] — 2026-07-12
19
+
20
+ Report improvements. The HTML report picks up a breakpoint filter, sharper
21
+ status filtering, and an interactive viewer redesigned around
22
+ blink-comparing what changed.
23
+
24
+ ### Added
25
+
26
+ - Breakpoint filter pills in the report summary
27
+ - `npm run preview -- --interactive` renders sample report in interactive mode
28
+ - `sizeMismatch` on `results.json` action results
29
+
30
+ ### Changed
31
+
32
+ - A status-filtered story now shows only its matching actions
33
+ - Candidate note names its referent
34
+ - Redesigned interactive mode so resting state is the selected variant
35
+ - Press and hold the screenshot to flip to its baseline counterpart
36
+ - The Baseline/Actual/Diff chips are always visible
37
+ - The Showing caption tracks whatever is displayed
38
+ - Resized-screenshot note folded into one accessible sentence
39
+ - Summary status filters restyled as colorful pills
40
+
41
+ ### Fixed
42
+
43
+ - A press-to-native-image-drag no longer leaves the preview stuck
44
+ - Bulk expand/collapse no longer pre-expands hidden screenshot panels
45
+ - "Expanded changed in 1 stories" pluralization in bulk-toggle announcement
46
+ - `npm run preview` silently never rendered interactive mode
47
+
11
48
  ## [0.2.0-alpha.1] — 2026-07-10
12
49
 
13
50
  CI-owned baselines. CI is now the sole writer of committed baselines. Local
@@ -249,7 +286,9 @@ styling or interactivity.
249
286
  Initial public alpha. Tuffgal extracted from [Linklater](https://github.com/nschneble/linklater)'s
250
287
  in-tree visual testing workspace.
251
288
 
252
- [Unreleased]: https://github.com/nschneble/tuffgal/compare/v0.2.0-alpha.1...HEAD
289
+ [Unreleased]: https://github.com/nschneble/tuffgal/compare/v0.2.0-alpha.3...HEAD
290
+ [0.2.0-alpha.3]: https://github.com/nschneble/tuffgal/releases/tag/v0.2.0-alpha.3
291
+ [0.2.0-alpha.2]: https://github.com/nschneble/tuffgal/releases/tag/v0.2.0-alpha.2
253
292
  [0.2.0-alpha.1]: https://github.com/nschneble/tuffgal/releases/tag/v0.2.0-alpha.1
254
293
  [0.1.0-alpha.14]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.14
255
294
  [0.1.0-alpha.13]: https://github.com/nschneble/tuffgal/releases/tag/v0.1.0-alpha.13
@@ -245,69 +245,118 @@ h2 {
245
245
  color: var(--text-subtle);
246
246
  }
247
247
 
248
- /* Each status total is a single-select filter styled as a plain text link. The
249
- word ("passed") is the only underlined link; its sibling count precedes it
250
- outside the button. Resting is plain --text (1.4.3 text contrast, not the
251
- 1.4.11 non-text floor — no border to measure); hover and pressed tint the word
252
- AND its underline the status color (text-decoration inherits currentColor, so
253
- they carry a same-ratio underline). The visible count + word always show, and
254
- the pressed state is never color-alone (1.4.1) — bold weight + a double
255
- underline carry it independently. Target size ≥24×24 via min-height/min-width
256
- (2.5.8). Stays a <button>: it filters in place, never navigates. */
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. */
257
259
  .summary-filter {
258
260
  display: inline-flex;
259
261
  align-items: center;
260
262
  gap: 0.5rem;
261
263
  min-height: 24px;
262
264
  min-width: 24px;
263
- padding: 0 0.15rem;
265
+ padding: 0.2rem 0.6rem;
264
266
  line-height: 1.2;
265
- background: none;
266
- border: 0;
267
+ border: 1px solid var(--border-bright);
268
+ border-radius: 999px;
269
+ background: transparent;
267
270
  color: var(--text);
268
271
  font: inherit;
269
272
  cursor: pointer;
270
- text-decoration: underline;
271
- text-decoration-thickness: 1px;
272
- text-underline-offset: 4.5px;
273
+ transition-property: scale, color, background-color, border-color;
274
+ transition-duration: 0.15s;
275
+ transition-timing-function: ease;
273
276
  }
274
277
 
275
278
  .summary-filter[aria-pressed='true'] {
276
279
  cursor: default;
277
280
  user-select: none;
281
+ font-weight: 600;
278
282
  }
279
283
 
280
- /* Hover + pressed tint the word its status color. The "all" total stays --text
281
- on both: --accent equals --new, so tinting "all" blue would falsely imply the
282
- "new" filter. Status colors clear 4.5:1 on --bg-elevated in both themes. */
283
- .summary-item[data-status='pass'] .summary-filter:hover,
284
- .summary-item[data-status='pass'] .summary-filter[aria-pressed='true'] {
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 {
285
295
  color: var(--pass);
296
+ border-color: var(--pass);
286
297
  }
287
298
 
288
- .summary-item[data-status='changed'] .summary-filter:hover,
289
- .summary-item[data-status='changed'] .summary-filter[aria-pressed='true'] {
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);
306
+ }
307
+
308
+ .summary-item[data-status='changed'] .summary-filter {
290
309
  color: var(--changed);
310
+ border-color: var(--changed);
291
311
  }
292
312
 
293
- .summary-item[data-status='failed'] .summary-filter:hover,
294
- .summary-item[data-status='failed'] .summary-filter[aria-pressed='true'] {
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);
320
+ }
321
+
322
+ .summary-item[data-status='failed'] .summary-filter {
295
323
  color: var(--failed);
324
+ border-color: var(--failed);
296
325
  }
297
326
 
298
- .summary-item[data-status='new'] .summary-filter:hover,
299
- .summary-item[data-status='new'] .summary-filter[aria-pressed='true'] {
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 {
300
337
  color: var(--new);
338
+ border-color: var(--new);
301
339
  }
302
340
 
303
- .summary-item[data-status='all'] .summary-filter:hover,
304
- .summary-item[data-status='all'] .summary-filter[aria-pressed='true'] {
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 {
305
353
  color: var(--text);
354
+ border-color: var(--border-bright);
306
355
  }
307
356
 
308
- .summary-filter:hover,
309
- .summary-filter[aria-pressed='true'] {
310
- text-decoration-style: double;
357
+ .summary-item[data-status='all'] .summary-filter[aria-pressed='true'] {
358
+ background: var(--bg);
359
+ color: var(--text);
311
360
  }
312
361
 
313
362
  .summary-filter:focus-visible {
@@ -315,6 +364,86 @@ h2 {
315
364
  outline-offset: 2px;
316
365
  }
317
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;
412
+ }
413
+
414
+ .breakpoint-filter .breakpoint-dimensions {
415
+ color: var(--text-subtle);
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;
445
+ }
446
+
318
447
  .chip {
319
448
  display: inline-flex;
320
449
  align-items: center;
@@ -801,7 +930,12 @@ summary.action-row:focus-visible {
801
930
  sit in this fieldset, which lives OUTSIDE the clipped `.shot-stage`, so the
802
931
  chip focus ring is never clipped by the stage overflow nor painted over
803
932
  screenshot pixels. The chips reuse `.chip--toggle` (border `--border-bright`,
804
- `--accent` focus ring), so no new focus tokens are introduced here. */
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. */
805
939
  .shot-interactive {
806
940
  display: flex;
807
941
  flex-wrap: wrap;
@@ -812,38 +946,12 @@ summary.action-row:focus-visible {
812
946
  border: 0;
813
947
  }
814
948
 
815
- /* interactiveMode: chip buttons collapsed by default — sighted mouse users
816
- drive the viewer with the hover/press gesture on the stage. Clip (never
817
- display/visibility/inert) so the nested radio stays focusable and keeps
818
- its accessible name. Mirrors .sr-only. */
819
- .shot-interactive .shot-radio-label {
820
- position: absolute;
821
- width: 1px;
822
- height: 1px;
823
- margin: -1px;
824
- overflow: hidden;
825
- clip: rect(0 0 0 0);
826
- white-space: nowrap;
827
- }
828
-
829
- /* Reveal the whole chip group when a keyboard user focuses any radio.
830
- :focus-within needs no :has() fallback and never fires for mouse/touch
831
- (label clipped, input pointer-events:none → radios are keyboard-only
832
- reachable). Specificity (0,3,0) beats the collapse rule (0,2,0). */
833
- .shot-interactive:focus-within .shot-radio-label {
834
- position: static;
835
- width: auto;
836
- height: auto;
837
- margin: 0;
838
- overflow: visible;
839
- clip: auto;
840
- white-space: normal;
841
- }
842
-
843
- /* Persistent caption naming the committed variant. Text on `--text` (≥4.5:1),
844
- so the active variant is never conveyed by color alone — it pairs the checked
845
- chip's accent border with this readable label. aria-hidden keeps it visual
846
- only: the checked radio is the single accessible source of committed state. */
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. */
847
955
  .shot-caption {
848
956
  margin: 0 0 0 0.25rem;
849
957
  color: var(--text);
@@ -884,10 +992,14 @@ summary.action-row:focus-visible {
884
992
  @media (prefers-reduced-motion: reduce) {
885
993
  .skip-link,
886
994
  summary.action-row::after,
887
- .chip {
995
+ .chip,
996
+ .summary-filter,
997
+ .breakpoint-filter {
888
998
  transition: none;
889
999
  }
890
- .chip:active {
1000
+ .chip:active,
1001
+ .summary-filter:active,
1002
+ .breakpoint-filter:active {
891
1003
  scale: none;
892
1004
  }
893
1005
  }