tuffgal 0.1.0-alpha.8 → 0.2.0-alpha.1

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 (97) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/README.md +82 -4
  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 +19 -2
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/config.d.ts +121 -10
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +114 -9
  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/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/reporter/assets/report.css +330 -50
  24. package/dist/reporter/assets/report.js +361 -55
  25. package/dist/reporter/template.d.ts +8 -1
  26. package/dist/reporter/template.d.ts.map +1 -1
  27. package/dist/reporter/template.js +447 -78
  28. package/dist/reporter/template.js.map +1 -1
  29. package/dist/reporter/writeReport.d.ts +2 -2
  30. package/dist/reporter/writeReport.d.ts.map +1 -1
  31. package/dist/reporter/writeReport.js +3 -3
  32. package/dist/reporter/writeReport.js.map +1 -1
  33. package/dist/runner/approve.d.ts +34 -9
  34. package/dist/runner/approve.d.ts.map +1 -1
  35. package/dist/runner/approve.js +54 -10
  36. package/dist/runner/approve.js.map +1 -1
  37. package/dist/runner/approveFrom.d.ts +58 -0
  38. package/dist/runner/approveFrom.d.ts.map +1 -0
  39. package/dist/runner/approveFrom.js +384 -0
  40. package/dist/runner/approveFrom.js.map +1 -0
  41. package/dist/runner/breakpointPasses.d.ts +39 -0
  42. package/dist/runner/breakpointPasses.d.ts.map +1 -0
  43. package/dist/runner/breakpointPasses.js +107 -0
  44. package/dist/runner/breakpointPasses.js.map +1 -0
  45. package/dist/runner/bridges/database.d.ts +4 -3
  46. package/dist/runner/bridges/database.d.ts.map +1 -1
  47. package/dist/runner/bridges/database.js +4 -3
  48. package/dist/runner/bridges/database.js.map +1 -1
  49. package/dist/runner/exitCode.d.ts +34 -0
  50. package/dist/runner/exitCode.d.ts.map +1 -0
  51. package/dist/runner/exitCode.js +41 -0
  52. package/dist/runner/exitCode.js.map +1 -0
  53. package/dist/runner/manifest.d.ts +149 -0
  54. package/dist/runner/manifest.d.ts.map +1 -0
  55. package/dist/runner/manifest.js +229 -0
  56. package/dist/runner/manifest.js.map +1 -0
  57. package/dist/runner/mode.d.ts +41 -0
  58. package/dist/runner/mode.d.ts.map +1 -0
  59. package/dist/runner/mode.js +43 -0
  60. package/dist/runner/mode.js.map +1 -0
  61. package/dist/runner/orphanScan.d.ts +68 -0
  62. package/dist/runner/orphanScan.d.ts.map +1 -0
  63. package/dist/runner/orphanScan.js +150 -0
  64. package/dist/runner/orphanScan.js.map +1 -0
  65. package/dist/runner/run.d.ts +85 -1
  66. package/dist/runner/run.d.ts.map +1 -1
  67. package/dist/runner/run.js +267 -41
  68. package/dist/runner/run.js.map +1 -1
  69. package/dist/runner/runAction.d.ts +22 -0
  70. package/dist/runner/runAction.d.ts.map +1 -1
  71. package/dist/runner/runAction.js +146 -24
  72. package/dist/runner/runAction.js.map +1 -1
  73. package/dist/runner/runStory.d.ts +40 -2
  74. package/dist/runner/runStory.d.ts.map +1 -1
  75. package/dist/runner/runStory.js +195 -62
  76. package/dist/runner/runStory.js.map +1 -1
  77. package/dist/runner/storyFilter.d.ts +8 -0
  78. package/dist/runner/storyFilter.d.ts.map +1 -1
  79. package/dist/runner/storyFilter.js +11 -0
  80. package/dist/runner/storyFilter.js.map +1 -1
  81. package/dist/schema/action.d.ts +11 -11
  82. package/dist/schema/result.d.ts +124 -6
  83. package/dist/schema/result.d.ts.map +1 -1
  84. package/dist/schema/result.js.map +1 -1
  85. package/dist/schema/story.d.ts +15 -4
  86. package/dist/schema/story.d.ts.map +1 -1
  87. package/dist/schema/story.js +35 -11
  88. package/dist/schema/story.js.map +1 -1
  89. package/dist/screenshots/baselineStore.d.ts +111 -5
  90. package/dist/screenshots/baselineStore.d.ts.map +1 -1
  91. package/dist/screenshots/baselineStore.js +132 -20
  92. package/dist/screenshots/baselineStore.js.map +1 -1
  93. package/dist/screenshots/capture.d.ts +10 -5
  94. package/dist/screenshots/capture.d.ts.map +1 -1
  95. package/dist/screenshots/capture.js +31 -18
  96. package/dist/screenshots/capture.js.map +1 -1
  97. 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,77 @@ 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 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. */
257
+ .summary-filter {
258
+ display: inline-flex;
259
+ align-items: center;
260
+ gap: 0.5rem;
261
+ min-height: 24px;
262
+ min-width: 24px;
263
+ padding: 0 0.15rem;
264
+ line-height: 1.2;
265
+ background: none;
266
+ border: 0;
267
+ color: var(--text);
268
+ font: inherit;
269
+ cursor: pointer;
270
+ text-decoration: underline;
271
+ text-decoration-thickness: 1px;
272
+ text-underline-offset: 4.5px;
179
273
  }
180
274
 
181
- .summary-item[data-status='pass'] .indicator {
275
+ .summary-filter[aria-pressed='true'] {
276
+ cursor: default;
277
+ user-select: none;
278
+ }
279
+
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'] {
182
285
  color: var(--pass);
183
286
  }
184
287
 
185
- .summary-item[data-status='changed'] .indicator {
288
+ .summary-item[data-status='changed'] .summary-filter:hover,
289
+ .summary-item[data-status='changed'] .summary-filter[aria-pressed='true'] {
186
290
  color: var(--changed);
187
291
  }
188
292
 
189
- .summary-item[data-status='failed'] .indicator {
293
+ .summary-item[data-status='failed'] .summary-filter:hover,
294
+ .summary-item[data-status='failed'] .summary-filter[aria-pressed='true'] {
190
295
  color: var(--failed);
191
296
  }
192
297
 
193
- .summary-item.coverage .coverage-detail {
194
- color: var(--text-subtle);
195
- font-size: 0.8125rem;
298
+ .summary-item[data-status='new'] .summary-filter:hover,
299
+ .summary-item[data-status='new'] .summary-filter[aria-pressed='true'] {
300
+ color: var(--new);
301
+ }
302
+
303
+ .summary-item[data-status='all'] .summary-filter:hover,
304
+ .summary-item[data-status='all'] .summary-filter[aria-pressed='true'] {
305
+ color: var(--text);
306
+ }
307
+
308
+ .summary-filter:hover,
309
+ .summary-filter[aria-pressed='true'] {
310
+ text-decoration-style: double;
311
+ }
312
+
313
+ .summary-filter:focus-visible {
314
+ outline: 2px solid var(--accent);
315
+ outline-offset: 2px;
196
316
  }
197
317
 
198
318
  .chip {
@@ -236,64 +356,72 @@ h2 {
236
356
  }
237
357
  }
238
358
 
239
- .stories-toolbar {
359
+ /* The expand-all / collapse-all pair now lives at the right end of the summary
360
+ row (the slot the coverage stats vacated), pushed there by margin-left:auto
361
+ while staying the LAST flex children so DOM order matches the visual order. */
362
+ .story-bulk-toggle {
240
363
  display: flex;
241
- flex-wrap: wrap;
242
364
  align-items: center;
243
- gap: 0.75rem 1rem;
244
- margin: 0 0 0.75rem;
365
+ gap: 0.25rem;
366
+ margin-left: auto;
367
+ line-height: 24px;
368
+ font-size: 0.8125rem;
245
369
  }
246
370
 
247
- .story-filter {
248
- display: flex;
249
- flex-wrap: wrap;
371
+ /* Link-styled verbs ("Expand" / "Collapse") — still <button>s, they act in
372
+ place. The underline is the affordance; verbs exceed 24px wide intrinsically,
373
+ so min-height alone carries 2.5.8. */
374
+ .story-bulk-toggle-button {
375
+ display: inline-flex;
250
376
  align-items: center;
251
- gap: 0.5rem;
252
- margin: 0;
377
+ min-height: 24px;
253
378
  padding: 0;
379
+ background: none;
254
380
  border: 0;
381
+ color: var(--text);
382
+ font: inherit;
383
+ cursor: pointer;
384
+ vertical-align: middle;
385
+ text-decoration: underline;
386
+ text-decoration-thickness: 1px;
387
+ text-underline-offset: 4.5px;
255
388
  }
256
389
 
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;
390
+ .story-bulk-toggle-button:hover:not(:disabled) {
391
+ color: var(--accent);
392
+ text-decoration-thickness: 2px;
263
393
  }
264
394
 
265
- .story-filter-label input {
266
- position: absolute;
267
- opacity: 0;
268
- pointer-events: none;
395
+ .story-bulk-toggle-button:focus-visible {
396
+ outline: 2px solid var(--accent);
397
+ outline-offset: 1px;
269
398
  }
270
399
 
271
- .story-bulk-toggle {
272
- display: flex;
273
- flex-wrap: wrap;
274
- gap: 0.25rem;
275
- margin-left: auto;
400
+ /* Disabled verb: removing the underline is the color-independent cue (no
401
+ opacity — it can compound below the contrast floor). Disabled controls are
402
+ 1.4.3/1.4.11 exempt, but the state stays perceptible without color. */
403
+ .story-bulk-toggle-button:disabled {
404
+ color: var(--text-subtle);
405
+ text-decoration: none;
406
+ cursor: not-allowed;
276
407
  }
277
408
 
278
- .story-bulk-toggle-button {
279
- background: transparent;
280
- font: inherit;
281
- font-size: 0.8125rem;
282
- min-height: 24px;
409
+ .bulk-scope,
410
+ .bulk-sep {
411
+ color: var(--text-subtle);
283
412
  }
284
413
 
285
- .story-bulk-toggle-button:hover {
286
- color: var(--text);
287
- border-color: var(--accent);
414
+ .summary-item:has(+ .story-bulk-toggle) .bulk-sep {
415
+ display: none;
288
416
  }
289
417
 
290
- .story-bulk-toggle-button:focus-visible {
291
- outline: 2px solid var(--accent);
292
- outline-offset: 1px;
418
+ .bulk-scope {
419
+ cursor: default;
420
+ margin-inline-start: 0.25rem;
293
421
  }
294
422
 
295
423
  .story-filter-status {
296
- margin: 0;
424
+ margin: 0 0 0.75rem;
297
425
  color: var(--text-subtle);
298
426
  font-size: 0.8125rem;
299
427
  }
@@ -331,6 +459,10 @@ h2 {
331
459
  box-shadow: inset 3px 0 0 var(--failed);
332
460
  }
333
461
 
462
+ .story[data-status='new'] {
463
+ box-shadow: inset 3px 0 0 var(--new);
464
+ }
465
+
334
466
  .story[data-status='changed'] {
335
467
  box-shadow: inset 3px 0 0 var(--changed);
336
468
  }
@@ -357,6 +489,11 @@ h2 {
357
489
  color: var(--pass);
358
490
  }
359
491
 
492
+ .story[data-status='new'] .story-marker,
493
+ .story[data-status='new'] .story-file {
494
+ color: var(--new);
495
+ }
496
+
360
497
  .story[data-status='changed'] .story-marker,
361
498
  .story[data-status='changed'] .story-file {
362
499
  color: var(--changed);
@@ -380,6 +517,34 @@ h2 {
380
517
  text-wrap: pretty;
381
518
  }
382
519
 
520
+ /* `:not([hidden])` on both sides so a filter that hides the first group does
521
+ not strand its separator on the new first-visible group. */
522
+ .breakpoint-group:not([hidden]) + .breakpoint-group:not([hidden]) {
523
+ margin-top: 0.75rem;
524
+ padding-top: 0.75rem;
525
+ border-top: 1px dashed var(--border);
526
+ }
527
+
528
+ .breakpoint-label {
529
+ display: flex;
530
+ align-items: baseline;
531
+ gap: 0.5rem;
532
+ margin: 0 0 0.25rem;
533
+ font-size: 0.75rem;
534
+ letter-spacing: 0.05em;
535
+ text-transform: uppercase;
536
+ }
537
+
538
+ .breakpoint-name {
539
+ color: var(--accent);
540
+ font-weight: 600;
541
+ }
542
+
543
+ .breakpoint-dimensions {
544
+ color: var(--text-subtle);
545
+ text-transform: none;
546
+ }
547
+
383
548
  .actions {
384
549
  position: relative;
385
550
  margin: 0;
@@ -483,6 +648,28 @@ h2 {
483
648
  overflow-x: auto;
484
649
  }
485
650
 
651
+ /* Supplementary per-action notes (candidate / a11y-drift). Both plain prose
652
+ inside li.action; an a11y-only changed row stacks both. Indented to align
653
+ under the action name (past the status badge), muted like other secondary
654
+ prose. Stacked cleanly with a small gap when both render. */
655
+ .candidate-note,
656
+ .a11y-drift-note {
657
+ margin: 0.35rem 0 0 2.2rem;
658
+ color: var(--text-muted);
659
+ font-family: var(--font-sans);
660
+ font-size: 0.8125rem;
661
+ text-wrap: pretty;
662
+ }
663
+
664
+ .candidate-note + .a11y-drift-note {
665
+ margin-top: 0.15rem;
666
+ }
667
+
668
+ .a11y-drift-note code {
669
+ font-family: var(--font-mono);
670
+ color: var(--text);
671
+ }
672
+
486
673
  summary.action-row {
487
674
  list-style: none;
488
675
  cursor: pointer;
@@ -561,11 +748,13 @@ summary.action-row:focus-visible {
561
748
  padding: 0.5rem;
562
749
  border: 1px solid var(--border);
563
750
  background: var(--bg);
751
+ width: fit-content;
752
+ max-width: 100%;
564
753
  }
565
754
 
566
755
  .shot-panel img {
567
756
  display: block;
568
- width: 100%;
757
+ max-width: 100%;
569
758
  height: auto;
570
759
  outline: 1px solid var(--img-outline);
571
760
  outline-offset: -1px;
@@ -601,6 +790,97 @@ summary.action-row:focus-visible {
601
790
  color: var(--text-subtle);
602
791
  }
603
792
 
793
+ /* No diff image to show (dimension mismatch). The note IS the primary content
794
+ of this slot, so it keeps full `--text` weight; italic alone marks the
795
+ "no data" state. */
796
+ .diff-stats--unavailable .label {
797
+ font-style: italic;
798
+ }
799
+
800
+ /* Interactive screenshot viewer (interactiveMode). The radio group + caption
801
+ sit in this fieldset, which lives OUTSIDE the clipped `.shot-stage`, so the
802
+ chip focus ring is never clipped by the stage overflow nor painted over
803
+ screenshot pixels. The chips reuse `.chip--toggle` (border `--border-bright`,
804
+ `--accent` focus ring), so no new focus tokens are introduced here. */
805
+ .shot-interactive {
806
+ display: flex;
807
+ flex-wrap: wrap;
808
+ align-items: center;
809
+ gap: 0.5rem;
810
+ margin: 0.5rem 0 0.5rem 2.2rem;
811
+ padding: 0;
812
+ border: 0;
813
+ }
814
+
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. */
847
+ .shot-caption {
848
+ margin: 0 0 0 0.25rem;
849
+ color: var(--text);
850
+ font-size: 0.8125rem;
851
+ }
852
+
853
+ .shot-caption-variant {
854
+ font-weight: 600;
855
+ }
856
+
857
+ /* The stage clips the screenshot to the viewport height. Its border uses
858
+ `--border-bright` (3:1 non-text contrast). overflow:hidden only ever clips
859
+ the image — the controls live in the sibling fieldset above. */
860
+ .shot-stage {
861
+ margin-top: 0.5rem;
862
+ margin-left: 2.2rem;
863
+ padding: 0.5rem;
864
+ border: 1px solid var(--border-bright);
865
+ background: var(--bg);
866
+ width: fit-content;
867
+ max-width: 100%;
868
+ overflow: hidden;
869
+ }
870
+
871
+ /* Scale the screenshot to fit within the viewport height (and the panel width)
872
+ so a tall capture never forces the page to scroll. width/height:auto preserve
873
+ the aspect ratio while `max-height` caps it to the viewport. */
874
+ .shot-image {
875
+ display: block;
876
+ width: auto;
877
+ height: auto;
878
+ max-width: 100%;
879
+ max-height: 85vh;
880
+ outline: 1px solid var(--img-outline);
881
+ outline-offset: -1px;
882
+ }
883
+
604
884
  @media (prefers-reduced-motion: reduce) {
605
885
  .skip-link,
606
886
  summary.action-row::after,