@a2f0/agent-tool 0.1.32 → 0.1.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "packageManager": "bun@1.4.3",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
@@ -1,9 +1,16 @@
1
1
  /*
2
2
  * Styles for <screenshot-gallery> and <screenshot-carousel>
3
- * (@a2f0/agent-tool/screenshots/gallery.css). Every rule sits in :where(), so
4
- * any site selector overrides it; restyle through the --shots-* custom
5
- * properties first, set on the elements or any ancestor. Defaults, which
6
- * follow prefers-color-scheme, live in the internal --_shots-* properties.
3
+ * (@a2f0/agent-tool/screenshots/gallery.css). Restyle through the --shots-*
4
+ * custom properties first, set on the elements or any ancestor. Defaults,
5
+ * which follow prefers-color-scheme, live in the internal --_shots-*
6
+ * properties.
7
+ *
8
+ * Specificity: rules on the host elements are zero (:where), so any site
9
+ * selector overrides them. Every rule on a part the elements render has
10
+ * exactly one class of specificity (0,1,0), with its context in :where(), so
11
+ * element resets such as `button { background: none }` never reach the
12
+ * parts, while any site rule with a class and an element or a second class
13
+ * (`screenshot-gallery .shots-frame`) still overrides them.
7
14
  */
8
15
 
9
16
  :where(screenshot-gallery, screenshot-carousel) {
@@ -55,30 +62,30 @@
55
62
  }
56
63
  }
57
64
 
58
- :where(.shots-host) :where(button) {
65
+ .shots-host :where(button) {
59
66
  font: inherit;
60
67
  color: inherit;
61
68
  }
62
69
 
63
- :where(.shots-host) :where(button:focus-visible, a:focus-visible, [tabindex]:focus-visible) {
70
+ :where(.shots-host) :where(button, a, [tabindex]):focus-visible {
64
71
  outline: 2px solid var(--_shots-focus);
65
72
  outline-offset: 2px;
66
73
  }
67
74
 
68
- :where(.shots-icon) {
75
+ .shots-icon {
69
76
  display: inline-flex;
70
77
  width: 1.25rem;
71
78
  height: 1.25rem;
72
79
  }
73
80
 
74
- :where(.shots-icon svg) {
81
+ .shots-icon :where(svg) {
75
82
  width: 100%;
76
83
  height: 100%;
77
84
  }
78
85
 
79
86
  /* --- States --- */
80
87
 
81
- :where(.shots-status) {
88
+ .shots-status {
82
89
  display: grid;
83
90
  place-items: center;
84
91
  padding: 2rem 1rem;
@@ -90,13 +97,13 @@
90
97
  }
91
98
 
92
99
  /* Loading reserves the gallery's height so the page does not jump when it arrives. */
93
- :where(screenshot-gallery) :where(.shots-status[data-state="loading"]) {
100
+ :where(screenshot-gallery) .shots-status:where([data-state="loading"]) {
94
101
  min-height: calc(var(--_shots-stage-height) + var(--_shots-thumb-height) + 7rem);
95
102
  }
96
103
 
97
104
  /* --- Gallery --- */
98
105
 
99
- :where(.shots-gallery) {
106
+ .shots-gallery {
100
107
  display: flex;
101
108
  flex-direction: column;
102
109
  border: 1px solid var(--_shots-border);
@@ -105,7 +112,7 @@
105
112
  overflow: hidden;
106
113
  }
107
114
 
108
- :where(.shots-gallery[data-expanded]) {
115
+ .shots-gallery:where([data-expanded]) {
109
116
  position: fixed;
110
117
  inset: 0;
111
118
  z-index: var(--_shots-expanded-z);
@@ -113,7 +120,7 @@
113
120
  border-radius: 0;
114
121
  }
115
122
 
116
- :where(.shots-toolbar) {
123
+ .shots-toolbar {
117
124
  display: flex;
118
125
  flex-wrap: wrap;
119
126
  align-items: center;
@@ -122,7 +129,7 @@
122
129
  border-bottom: 1px solid var(--_shots-border);
123
130
  }
124
131
 
125
- :where(.shots-title) {
132
+ .shots-title {
126
133
  flex: 1 1 10rem;
127
134
  margin: 0;
128
135
  font-weight: 600;
@@ -131,14 +138,14 @@
131
138
  white-space: nowrap;
132
139
  }
133
140
 
134
- :where(.shots-toggle) {
141
+ .shots-toggle {
135
142
  display: inline-flex;
136
143
  padding: 2px;
137
144
  border: 1px solid var(--_shots-border);
138
145
  border-radius: 999px;
139
146
  }
140
147
 
141
- :where(.shots-toggle-button) {
148
+ .shots-toggle-button {
142
149
  display: inline-flex;
143
150
  align-items: center;
144
151
  gap: 0.375rem;
@@ -149,17 +156,17 @@
149
156
  cursor: pointer;
150
157
  }
151
158
 
152
- :where(.shots-toggle-button[aria-pressed="true"]) {
159
+ .shots-toggle-button:where([aria-pressed="true"]) {
153
160
  background: var(--_shots-accent);
154
161
  color: var(--_shots-accent-ink);
155
162
  }
156
163
 
157
- :where(.shots-actions) {
164
+ .shots-actions {
158
165
  display: inline-flex;
159
166
  gap: 0.25rem;
160
167
  }
161
168
 
162
- :where(.shots-icon-button) {
169
+ .shots-icon-button {
163
170
  display: inline-grid;
164
171
  place-items: center;
165
172
  width: var(--_shots-button-size);
@@ -171,12 +178,12 @@
171
178
  cursor: pointer;
172
179
  }
173
180
 
174
- :where(.shots-icon-button:disabled) {
181
+ .shots-icon-button:where(:disabled) {
175
182
  opacity: 0.4;
176
183
  cursor: default;
177
184
  }
178
185
 
179
- :where(.shots-body) {
186
+ .shots-body {
180
187
  display: flex;
181
188
  flex: 1 1 auto;
182
189
  flex-direction: column;
@@ -184,7 +191,7 @@
184
191
  }
185
192
 
186
193
  /* The main panel: one screen per page of a horizontal scroll-snap track. */
187
- :where(.shots-track) {
194
+ .shots-track {
188
195
  position: relative;
189
196
  display: flex;
190
197
  height: var(--_shots-stage-height);
@@ -196,16 +203,16 @@
196
203
  background: var(--_shots-surface-raised);
197
204
  }
198
205
 
199
- :where(.shots-gallery[data-expanded]) :where(.shots-track) {
206
+ :where(.shots-gallery[data-expanded]) .shots-track {
200
207
  flex: 1 1 auto;
201
208
  height: auto;
202
209
  }
203
210
 
204
- :where(.shots-track)::-webkit-scrollbar {
211
+ .shots-track::-webkit-scrollbar {
205
212
  display: none;
206
213
  }
207
214
 
208
- :where(.shots-slide) {
215
+ .shots-slide {
209
216
  display: flex;
210
217
  flex: 0 0 100%;
211
218
  align-items: center;
@@ -217,7 +224,7 @@
217
224
  scroll-snap-stop: always;
218
225
  }
219
226
 
220
- :where(.shots-frame) {
227
+ .shots-frame {
221
228
  display: block;
222
229
  box-sizing: content-box;
223
230
  max-width: 100%;
@@ -229,14 +236,14 @@
229
236
  cursor: zoom-in;
230
237
  }
231
238
 
232
- :where(.shots-image) {
239
+ .shots-image {
233
240
  display: block;
234
241
  max-width: 100%;
235
242
  height: auto;
236
243
  }
237
244
 
238
245
  /* Phone and tablet captures sit in a device bezel sized from the capture (fitFrame). */
239
- :where(.shots-gallery[data-frame="phone"], .shots-gallery[data-frame="tablet"]) :where(.shots-frame:not(.shots-missing)) {
246
+ :where(.shots-gallery[data-frame="phone"], .shots-gallery[data-frame="tablet"]) .shots-frame:where(:not(.shots-missing)) {
240
247
  padding: var(--shots-frame-pad, 0.6rem);
241
248
  border: 0;
242
249
  border-radius: calc(var(--shots-frame-radius, 1.5rem) + var(--shots-frame-pad, 0.6rem));
@@ -244,11 +251,11 @@
244
251
  box-shadow: inset 0 0 0 1px var(--_shots-bezel-edge), var(--_shots-shadow);
245
252
  }
246
253
 
247
- :where(.shots-gallery[data-frame="phone"], .shots-gallery[data-frame="tablet"]) :where(.shots-frame:not(.shots-missing) .shots-image) {
254
+ :where(.shots-gallery[data-frame="phone"], .shots-gallery[data-frame="tablet"]) :where(.shots-frame:not(.shots-missing)) .shots-image {
248
255
  border-radius: var(--shots-frame-radius, 1.5rem);
249
256
  }
250
257
 
251
- :where(.shots-missing) {
258
+ .shots-missing {
252
259
  display: grid;
253
260
  place-items: center;
254
261
  max-width: 24rem;
@@ -258,7 +265,7 @@
258
265
  cursor: default;
259
266
  }
260
267
 
261
- :where(.shots-navrow) {
268
+ .shots-navrow {
262
269
  display: flex;
263
270
  align-items: center;
264
271
  justify-content: center;
@@ -266,7 +273,7 @@
266
273
  padding: 0.5rem;
267
274
  }
268
275
 
269
- :where(.shots-counter) {
276
+ .shots-counter {
270
277
  min-width: 4.5rem;
271
278
  margin: 0;
272
279
  color: var(--_shots-ink-muted);
@@ -274,7 +281,9 @@
274
281
  text-align: center;
275
282
  }
276
283
 
277
- :where(.shots-filmstrip) {
284
+ /* Every thumbnail of a project has one width: the stage's typical capture shape (--_shots-thumb-ratio, set by the element) at the thumbnail height, within the 4.5rem to max-width range. */
285
+ .shots-filmstrip {
286
+ --_shots-thumb-width: clamp(4.5rem, calc(var(--_shots-thumb-height) * var(--_shots-thumb-ratio, 0.75)), calc(var(--_shots-thumb-max-width) - 0.5rem));
278
287
  position: relative;
279
288
  display: flex;
280
289
  gap: 0.5rem;
@@ -284,12 +293,13 @@
284
293
  scroll-snap-type: x proximity;
285
294
  }
286
295
 
287
- :where(.shots-thumb) {
296
+ .shots-thumb {
288
297
  display: flex;
289
298
  flex: 0 0 auto;
290
299
  flex-direction: column;
291
300
  gap: 0.25rem;
292
- max-width: var(--_shots-thumb-max-width);
301
+ box-sizing: content-box;
302
+ width: var(--_shots-thumb-width);
293
303
  padding: 0.25rem;
294
304
  border: 1px solid transparent;
295
305
  border-radius: calc(var(--_shots-radius) / 2);
@@ -298,23 +308,21 @@
298
308
  scroll-snap-align: start;
299
309
  }
300
310
 
301
- :where(.shots-thumb[aria-current="true"]) {
311
+ .shots-thumb:where([aria-current="true"]) {
302
312
  border-color: var(--_shots-accent);
303
313
  }
304
314
 
305
- /* The image keeps its capture's aspect ratio at a fixed height, cropped from the top only when wider than the cap. */
306
- :where(.shots-thumb-image) {
315
+ /* A capture of another shape is cropped to the thumbnail, keeping its top. */
316
+ .shots-thumb-image {
307
317
  display: block;
318
+ width: 100%;
308
319
  height: var(--_shots-thumb-height);
309
- min-width: 4.5rem;
310
- max-width: calc(var(--_shots-thumb-max-width) - 0.5rem);
311
- aspect-ratio: 3 / 4;
312
320
  border-radius: 4px;
313
321
  background: var(--_shots-surface-raised);
314
322
  overflow: hidden;
315
323
  }
316
324
 
317
- :where(.shots-thumb-image img) {
325
+ .shots-thumb-image :where(img) {
318
326
  display: block;
319
327
  width: 100%;
320
328
  height: 100%;
@@ -322,8 +330,11 @@
322
330
  object-position: top;
323
331
  }
324
332
 
325
- :where(.shots-thumb-label) {
326
- max-width: calc(var(--_shots-thumb-max-width) - 0.5rem);
333
+ /* Long labels never widen a thumbnail; they end in an ellipsis, with the full label in the button's title. */
334
+ .shots-thumb-label {
335
+ display: block;
336
+ min-width: 0;
337
+ max-width: 100%;
327
338
  overflow: hidden;
328
339
  color: var(--_shots-ink-muted);
329
340
  font-size: 0.75rem;
@@ -332,11 +343,11 @@
332
343
  white-space: nowrap;
333
344
  }
334
345
 
335
- :where(.shots-thumb[aria-current="true"]) :where(.shots-thumb-label) {
346
+ :where(.shots-thumb[aria-current="true"]) .shots-thumb-label {
336
347
  color: var(--_shots-ink);
337
348
  }
338
349
 
339
- :where(.shots-lightbox) {
350
+ .shots-lightbox {
340
351
  max-width: 100vw;
341
352
  max-height: 100vh;
342
353
  padding: 0;
@@ -345,11 +356,11 @@
345
356
  overflow: visible;
346
357
  }
347
358
 
348
- :where(.shots-lightbox)::backdrop {
359
+ .shots-lightbox::backdrop {
349
360
  background: var(--_shots-backdrop);
350
361
  }
351
362
 
352
- :where(.shots-lightbox-backdrop) {
363
+ .shots-lightbox-backdrop {
353
364
  position: fixed;
354
365
  inset: 0;
355
366
  border: 0;
@@ -357,11 +368,11 @@
357
368
  cursor: zoom-out;
358
369
  }
359
370
 
360
- :where(.shots-lightbox-frame) {
371
+ .shots-lightbox-frame {
361
372
  position: relative;
362
373
  }
363
374
 
364
- :where(.shots-lightbox-image) {
375
+ .shots-lightbox-image {
365
376
  display: block;
366
377
  width: auto;
367
378
  height: auto;
@@ -370,18 +381,18 @@
370
381
  border-radius: var(--shots-lightbox-radius, 8px);
371
382
  }
372
383
 
373
- :where(.shots-lightbox[data-frame="phone"], .shots-lightbox[data-frame="tablet"]) :where(.shots-lightbox-frame) {
384
+ :where(.shots-lightbox[data-frame="phone"], .shots-lightbox[data-frame="tablet"]) .shots-lightbox-frame {
374
385
  padding: var(--shots-frame-pad, 0.75rem);
375
386
  border-radius: calc(var(--shots-frame-radius, 2rem) + var(--shots-frame-pad, 0.75rem));
376
387
  background: var(--_shots-bezel);
377
388
  box-shadow: inset 0 0 0 1px var(--_shots-bezel-edge);
378
389
  }
379
390
 
380
- :where(.shots-lightbox[data-frame="phone"], .shots-lightbox[data-frame="tablet"]) :where(.shots-lightbox-image) {
391
+ :where(.shots-lightbox[data-frame="phone"], .shots-lightbox[data-frame="tablet"]) .shots-lightbox-image {
381
392
  border-radius: var(--shots-frame-radius, 2rem);
382
393
  }
383
394
 
384
- :where(.shots-lightbox-close) {
395
+ .shots-lightbox-close {
385
396
  position: fixed;
386
397
  top: 1rem;
387
398
  right: 1rem;
@@ -397,7 +408,7 @@
397
408
  cursor: pointer;
398
409
  }
399
410
 
400
- :where(.shots-lightbox) :where(.shots-missing) {
411
+ :where(.shots-lightbox) .shots-missing {
401
412
  position: relative;
402
413
  background: var(--_shots-surface);
403
414
  border-radius: var(--_shots-radius);
@@ -405,11 +416,11 @@
405
416
 
406
417
  /* --- Carousel --- */
407
418
 
408
- :where(.shots-carousel) {
419
+ .shots-carousel {
409
420
  position: relative;
410
421
  }
411
422
 
412
- :where(.shots-carousel-track) {
423
+ .shots-carousel-track {
413
424
  position: relative;
414
425
  overflow-x: auto;
415
426
  overscroll-behavior-x: contain;
@@ -419,11 +430,11 @@
419
430
  scrollbar-width: none;
420
431
  }
421
432
 
422
- :where(.shots-carousel-track)::-webkit-scrollbar {
433
+ .shots-carousel-track::-webkit-scrollbar {
423
434
  display: none;
424
435
  }
425
436
 
426
- :where(.shots-carousel-list) {
437
+ .shots-carousel-list {
427
438
  display: flex;
428
439
  gap: var(--_shots-carousel-gap);
429
440
  margin: 0;
@@ -431,33 +442,33 @@
431
442
  list-style: none;
432
443
  }
433
444
 
434
- :where(.shots-carousel-slide) {
445
+ .shots-carousel-slide {
435
446
  flex: 0 0 var(--_shots-carousel-slide-width);
436
447
  scroll-snap-align: start;
437
448
  }
438
449
 
439
- :where(.shots-carousel-slide figure) {
450
+ .shots-carousel-slide :where(figure) {
440
451
  margin: 0;
441
452
  }
442
453
 
443
- :where(.shots-carousel-slide figcaption) {
454
+ .shots-carousel-slide :where(figcaption) {
444
455
  margin-top: 0.75rem;
445
456
  color: var(--_shots-ink-muted);
446
457
  font-size: 0.875rem;
447
458
  text-align: center;
448
459
  }
449
460
 
450
- :where(.shots-device) {
461
+ .shots-device {
451
462
  display: block;
452
463
  color: inherit;
453
464
  container-type: inline-size;
454
465
  }
455
466
 
456
- :where(.shots-device picture) {
467
+ .shots-device :where(picture) {
457
468
  display: block;
458
469
  }
459
470
 
460
- :where(.shots-device img) {
471
+ .shots-device :where(img) {
461
472
  display: block;
462
473
  width: 100%;
463
474
  height: auto;
@@ -465,7 +476,7 @@
465
476
  }
466
477
 
467
478
  /* The bezel scales with the device's width, so it keeps its proportions at every size (FRAME_METRICS). */
468
- :where(.shots-carousel[data-frame="phone"], .shots-carousel[data-frame="tablet"]) :where(.shots-device picture) {
479
+ :where(.shots-carousel[data-frame="phone"], .shots-carousel[data-frame="tablet"]) .shots-device :where(picture) {
469
480
  --shots-frame-pad: 3.5cqi;
470
481
  --shots-frame-radius: calc((100cqi - 2 * var(--shots-frame-pad)) * 0.1214);
471
482
  padding: var(--shots-frame-pad);
@@ -474,32 +485,43 @@
474
485
  box-shadow: inset 0 0 0 1px var(--_shots-bezel-edge), var(--_shots-shadow);
475
486
  }
476
487
 
477
- :where(.shots-carousel[data-frame="tablet"]) :where(.shots-device picture) {
488
+ :where(.shots-carousel[data-frame="tablet"]) .shots-device :where(picture) {
478
489
  --shots-frame-pad: 3cqi;
479
490
  --shots-frame-radius: calc((100cqi - 2 * var(--shots-frame-pad)) * 0.025);
480
491
  }
481
492
 
482
- :where(.shots-carousel[data-frame="phone"], .shots-carousel[data-frame="tablet"]) :where(.shots-device img) {
493
+ :where(.shots-carousel[data-frame="phone"], .shots-carousel[data-frame="tablet"]) .shots-device :where(img) {
483
494
  border-radius: var(--shots-frame-radius);
484
495
  }
485
496
 
486
- :where(a.shots-device:focus-visible) {
497
+ .shots-device:where(a:focus-visible) {
487
498
  outline: none;
488
499
  }
489
500
 
490
- :where(a.shots-device:focus-visible picture) {
501
+ .shots-device:where(a:focus-visible) :where(picture) {
491
502
  outline: 2px solid var(--_shots-focus);
492
503
  outline-offset: 3px;
493
504
  }
494
505
 
495
- :where(.shots-carousel-controls) {
506
+ .shots-carousel-controls {
496
507
  display: flex;
508
+ flex-wrap: wrap;
509
+ align-items: center;
497
510
  justify-content: flex-end;
498
511
  gap: 0.5rem;
499
512
  padding: 0 var(--_shots-carousel-padding);
500
513
  }
501
514
 
502
- :where(.shots-carousel-step) {
515
+ /* The view-all link sits at the start of the controls row, before the step buttons. */
516
+ .shots-carousel-view-all {
517
+ margin-inline-end: auto;
518
+ color: var(--shots-link, var(--_shots-ink));
519
+ font-weight: 600;
520
+ text-decoration: underline;
521
+ text-underline-offset: 0.2em;
522
+ }
523
+
524
+ .shots-carousel-step {
503
525
  display: inline-grid;
504
526
  place-items: center;
505
527
  width: var(--_shots-button-size);
@@ -512,16 +534,16 @@
512
534
  }
513
535
 
514
536
  @media (prefers-reduced-motion: no-preference) {
515
- :where(.shots-track, .shots-carousel-track, .shots-filmstrip) {
537
+ :is(.shots-track, .shots-carousel-track, .shots-filmstrip) {
516
538
  scroll-behavior: smooth;
517
539
  }
518
540
 
519
- :where(a.shots-device picture) {
541
+ .shots-device:where(a) :where(picture) {
520
542
  transition: transform 200ms ease;
521
543
  }
522
544
 
523
545
  @media (hover: hover) {
524
- :where(a.shots-device:hover picture) {
546
+ .shots-device:where(a:hover) :where(picture) {
525
547
  transform: translateY(-4px);
526
548
  }
527
549
  }
@@ -73,6 +73,58 @@ export function loopCopies(viewportWidth: number, copyWidth: number): number {
73
73
  return copyWidth > 0 ? Math.max(1, Math.ceil(viewportWidth / copyWidth)) : 1;
74
74
  }
75
75
 
76
+ /** Wheel travel, in CSS pixels on a gesture's dominant axis, that steps one screen. */
77
+ export const WHEEL_STEP_THRESHOLD = 30;
78
+ /** A pause between wheel events this long, in milliseconds, ends a gesture, including a trackpad's inertia. */
79
+ export const WHEEL_GESTURE_IDLE = 200;
80
+
81
+ export interface WheelStepper {
82
+ /** Adds one wheel event's `delta` (pixels, signed) at `time` (ms); returns the step it completes: -1, 1, or 0. */
83
+ push(delta: number, time: number): -1 | 0 | 1;
84
+ reset(): void;
85
+ }
86
+
87
+ /**
88
+ * Turns a stream of wheel deltas into screen steps. Deltas accumulate until
89
+ * they reach `threshold`, so small wheel ticks add up, then the gesture steps
90
+ * once: further travel in the same direction, such as a trackpad's inertial
91
+ * tail, is ignored until the wheel pauses for `idle` ms. Travel of `threshold`
92
+ * in the other direction is a new gesture and steps back at once.
93
+ */
94
+ export function createWheelStepper(threshold = WHEEL_STEP_THRESHOLD, idle = WHEEL_GESTURE_IDLE): WheelStepper {
95
+ let total = 0, last = -Infinity, stepped: -1 | 0 | 1 = 0;
96
+ return {
97
+ push(delta, time) {
98
+ if (!(time - last < idle)) { total = 0; stepped = 0; }
99
+ last = time;
100
+ if (!delta || !Number.isFinite(delta)) return 0;
101
+ const direction = delta > 0 ? 1 : -1;
102
+ // The step's own direction keeps the gesture going; an opposing blip does not add up.
103
+ if (direction === stepped) { total = 0; return 0; }
104
+ if (total && Math.sign(total) !== direction) total = 0;
105
+ total += delta;
106
+ if (Math.abs(total) < threshold) return 0;
107
+ total = 0;
108
+ stepped = direction;
109
+ return direction;
110
+ },
111
+ reset() { total = 0; last = -Infinity; stepped = 0; },
112
+ };
113
+ }
114
+
115
+ /** A wheel event's delta in CSS pixels; `page` is the size of one page of the scroller. */
116
+ export function wheelPixels(delta: number, deltaMode: number, page: number): number {
117
+ return deltaMode === 1 ? delta * 16 : deltaMode === 2 ? delta * page : delta;
118
+ }
119
+
120
+ /** Median width-to-height ratio of the captures, so one odd capture does not resize every thumbnail. */
121
+ export function typicalRatio(entries: readonly { width: number; height: number }[]): number | undefined {
122
+ const ratios = entries.filter(entry => entry.width > 0 && entry.height > 0).map(entry => entry.width / entry.height).sort((a, b) => a - b);
123
+ if (!ratios.length) return undefined;
124
+ const middle = ratios.length >> 1;
125
+ return ratios.length % 2 ? ratios[middle]! : (ratios[middle - 1]! + ratios[middle]!) / 2;
126
+ }
127
+
76
128
  export interface ScreenshotChangeDetail { project: string; theme: string; screen: string; index: number; total: number }
77
129
 
78
130
  export interface ScreenshotGalleryElement extends HTMLElement {
@@ -156,6 +208,19 @@ function status(root: HTMLElement, state: string, message: string): void {
156
208
  root.replaceChildren(element("div", { class: "shots-status", role: "status", "data-state": state }, [element("p", { text: message })]));
157
209
  }
158
210
 
211
+ /**
212
+ * A page can set `manifestData` before the elements are defined; that value is
213
+ * an own property that shadows the accessor. Take it off, so the constructor
214
+ * can pass it through the setter.
215
+ */
216
+ function takePendingData(element: HTMLElement): { value: unknown } | undefined {
217
+ if (!Object.prototype.hasOwnProperty.call(element, "manifestData")) return undefined;
218
+ const own = element as unknown as Record<string, unknown>;
219
+ const value = own["manifestData"];
220
+ delete own["manifestData"];
221
+ return { value };
222
+ }
223
+
159
224
  function defineGallery(): CustomElementConstructor {
160
225
  return class ScreenshotGallery extends HTMLElement implements ScreenshotGalleryElement {
161
226
  static observedAttributes = ["manifest"];
@@ -178,6 +243,13 @@ function defineGallery(): CustomElementConstructor {
178
243
  #overflow = "";
179
244
  #rendered = false;
180
245
  #announced = "";
246
+ #wheel = createWheelStepper();
247
+
248
+ constructor() {
249
+ super();
250
+ const pending = takePendingData(this);
251
+ if (pending) this.manifestData = pending.value as ScreenshotManifest | null;
252
+ }
181
253
 
182
254
  get manifestData(): ScreenshotManifest | null { return this.#data; }
183
255
  set manifestData(value: ScreenshotManifest | null) { this.#data = value && parseScreenshotManifest(value); if (this.#connected) this.#load(); }
@@ -261,6 +333,7 @@ function defineGallery(): CustomElementConstructor {
261
333
 
262
334
  const track = element("div", { class: "shots-track", tabindex: "0", role: "group", "aria-roledescription": "carousel" });
263
335
  onScrollSettle(track, () => this.#settle(), signal);
336
+ track.addEventListener("wheel", (event: WheelEvent) => this.#onWheel(event), { passive: false, signal });
264
337
  const previous = element("button", { type: "button", class: "shots-icon-button", "data-action": "previous", "aria-label": "Previous screen" }, [icon("prev")]);
265
338
  const next = element("button", { type: "button", class: "shots-icon-button", "data-action": "next", "aria-label": "Next screen" }, [icon("next")]);
266
339
  previous.addEventListener("click", () => this.step(-1), { signal });
@@ -297,6 +370,30 @@ function defineGallery(): CustomElementConstructor {
297
370
  else if (event.key === "ArrowLeft" || (inside && event.key === "ArrowUp")) { event.preventDefault(); this.step(-1); }
298
371
  }
299
372
 
373
+ /**
374
+ * Steps once per wheel gesture over the main panel, instead of letting
375
+ * scroll snapping pull a small scroll back to the same screen. Horizontal
376
+ * travel (a trackpad swipe, a tilt wheel, Shift with a wheel) always steps;
377
+ * vertical travel steps only while the gallery is expanded, or with
378
+ * `vertical-wheel` until the first or last screen, so the page still scrolls.
379
+ */
380
+ #onWheel(event: WheelEvent): void {
381
+ const track = this.#track;
382
+ if (!track || event.defaultPrevented || event.ctrlKey || event.metaKey) return;
383
+ let x = event.deltaX, y = event.deltaY;
384
+ if (event.shiftKey && !x) { x = y; y = 0; }
385
+ const horizontal = Math.abs(x) > Math.abs(y);
386
+ const delta = wheelPixels(horizontal ? x : y, event.deltaMode, horizontal ? track.clientWidth : track.clientHeight);
387
+ if (!delta) return;
388
+ const next = this.#index + Math.sign(delta);
389
+ const inRange = next >= 0 && next < this.#screens.length;
390
+ if (!horizontal && !this.#expanded && (!this.hasAttribute("vertical-wheel") || !inRange)) return;
391
+ event.preventDefault();
392
+ const step = this.#wheel.push(delta, event.timeStamp);
393
+ // The wheel does not wrap: past either end it waits, like a native scroller.
394
+ if (step && this.#index + step >= 0 && this.#index + step < this.#screens.length) this.#select(this.#index + step, scrollBehavior());
395
+ }
396
+
300
397
  #setExpanded(expanded: boolean): void {
301
398
  if (expanded === this.#expanded || !this.#root) return;
302
399
  this.#expanded = expanded;
@@ -321,14 +418,20 @@ function defineGallery(): CustomElementConstructor {
321
418
  this.#screens = projectScreens(manifest, this.#project);
322
419
  this.#index = Math.max(0, screen === undefined ? 0 : this.#screens.indexOf(screen));
323
420
  this.#loaded.clear();
421
+ this.#wheel.reset();
324
422
  const frame = projectFrame(manifest, this.#project);
325
423
  this.#root!.dataset["frame"] = frame;
326
424
  this.#root!.dataset["project"] = this.#project;
327
425
  for (const button of this.#root!.querySelectorAll<HTMLButtonElement>(".shots-toggle-button")) button.setAttribute("aria-pressed", String(button.dataset["project"] === this.#project));
328
426
  track.setAttribute("aria-label", `${projectInfo(manifest, this.#project).label} screens`);
329
427
  track.replaceChildren(...this.#screens.map((_, index) => element("div", { class: "shots-slide", "data-index": index, role: "group", "aria-roledescription": "slide" })));
428
+ // Every thumbnail of a project has the same width, from its captures' typical shape.
429
+ const ratio = typicalRatio(manifest.entries.filter(entry => entry.project === this.#project));
430
+ if (ratio) strip.style.setProperty("--_shots-thumb-ratio", String(Math.round(ratio * 10000) / 10000));
431
+ else strip.style.removeProperty("--_shots-thumb-ratio");
330
432
  strip.replaceChildren(...this.#screens.map((name, index) => {
331
- const button = element("button", { type: "button", class: "shots-thumb", "data-index": index, "aria-current": "false" }, [element("span", { class: "shots-thumb-image" }), element("span", { class: "shots-thumb-label", text: screenInfo(manifest, name).label })]);
433
+ const label = screenInfo(manifest, name).label;
434
+ const button = element("button", { type: "button", class: "shots-thumb", "data-index": index, "aria-current": "false", title: label }, [element("span", { class: "shots-thumb-image" }), element("span", { class: "shots-thumb-label", text: label })]);
332
435
  button.addEventListener("click", () => this.#select(index, scrollBehavior()), { signal: this.#abort!.signal });
333
436
  return button;
334
437
  }));
@@ -359,7 +462,7 @@ function defineGallery(): CustomElementConstructor {
359
462
  button.setAttribute("aria-label", label);
360
463
  button.title = label;
361
464
  }
362
- this.#root!.dataset["theme"] = this.#theme;
465
+ this.#root!.dataset["shotsTheme"] = this.#theme;
363
466
  this.#loaded.clear();
364
467
  for (const slide of this.#track!.querySelectorAll<HTMLElement>(".shots-slide")) {
365
468
  const index = Number(slide.dataset["index"]), name = this.#screens[index]!, entry = this.#entry(name);
@@ -379,7 +482,6 @@ function defineGallery(): CustomElementConstructor {
379
482
  const holder = thumb.querySelector(".shots-thumb-image")!;
380
483
  thumb.setAttribute("aria-label", screenInfo(manifest, name).label);
381
484
  if (!entry) { holder.replaceChildren(element("span", { class: "shots-thumb-missing" })); continue; }
382
- holder.setAttribute("style", `aspect-ratio: ${entry.width} / ${entry.height}`);
383
485
  holder.replaceChildren(element("img", { src: entry.src, alt: "", loading: "lazy", decoding: "async", width: entry.width, height: entry.height }));
384
486
  }
385
487
  this.#fit();
@@ -550,7 +652,7 @@ function defineGallery(): CustomElementConstructor {
550
652
 
551
653
  function defineCarousel(): CustomElementConstructor {
552
654
  return class ScreenshotCarousel extends HTMLElement implements ScreenshotCarouselElement {
553
- static observedAttributes = ["manifest", "project", "limit"];
655
+ static observedAttributes = ["manifest", "project", "limit", "view-all-href", "view-all-label"];
554
656
  #data: ScreenshotManifest | null = null;
555
657
  #abort: AbortController | null = null;
556
658
  #track: HTMLDivElement | null = null;
@@ -559,6 +661,12 @@ function defineCarousel(): CustomElementConstructor {
559
661
  #slides: HTMLElement[] = [];
560
662
  #manifest: ScreenshotManifest | null = null;
561
663
 
664
+ constructor() {
665
+ super();
666
+ const pending = takePendingData(this);
667
+ if (pending) this.manifestData = pending.value as ScreenshotManifest | null;
668
+ }
669
+
562
670
  get manifestData(): ScreenshotManifest | null { return this.#data; }
563
671
  set manifestData(value: ScreenshotManifest | null) { this.#data = value && parseScreenshotManifest(value); if (this.#connected) this.#load(); }
564
672
 
@@ -621,7 +729,9 @@ function defineCarousel(): CustomElementConstructor {
621
729
  const picture = element("picture", {}, slide.dark ? [element("source", { media: "(prefers-color-scheme: dark)", srcset: slide.dark.src, width: slide.dark.width, height: slide.dark.height })] : []);
622
730
  picture.append(element("img", {
623
731
  src: slide.light.src, alt: clone ? "" : screen.alt ?? `${screen.label}, ${projectInfo(manifest, project).label.toLowerCase()} screenshot`,
624
- width: slide.light.width, height: slide.light.height, loading: !clone && index < 3 ? "eager" : "lazy", decoding: "async",
732
+ width: slide.light.width, height: slide.light.height, decoding: "async",
733
+ // The first original rests at the start edge on load: fetch it first, and the rest as they near the viewport.
734
+ loading: !clone && index === 0 ? "eager" : "lazy", fetchpriority: !clone && index === 0 ? "high" : undefined,
625
735
  }));
626
736
  const device = linkBase
627
737
  ? element("a", { class: "shots-device", href: screenshotPath(linkBase, project, slide.name), tabindex: clone ? "-1" : undefined }, [picture])
@@ -637,12 +747,15 @@ function defineCarousel(): CustomElementConstructor {
637
747
  const track = element("div", { class: "shots-carousel-track", tabindex: "0" }, [list]);
638
748
  this.#track = track;
639
749
  const root = element("div", { class: "shots-carousel", role: "region", "aria-roledescription": "carousel", "aria-label": this.getAttribute("label") ?? "Screenshots", "data-frame": frame }, [track]);
750
+ const controls = element("div", { class: "shots-carousel-controls" });
751
+ const viewAll = this.getAttribute("view-all-href");
752
+ if (viewAll) controls.append(element("a", { class: "shots-carousel-view-all", href: viewAll, text: this.getAttribute("view-all-label") || "View all screenshots" }));
640
753
  if (loop) {
641
754
  const previous = element("button", { type: "button", class: "shots-carousel-step", "data-step": "-1", "aria-label": "Previous screens" }, [icon("prev")]);
642
755
  const next = element("button", { type: "button", class: "shots-carousel-step", "data-step": "1", "aria-label": "Next screens" }, [icon("next")]);
643
756
  previous.addEventListener("click", () => this.step(-1), { signal });
644
757
  next.addEventListener("click", () => this.step(1), { signal });
645
- root.append(element("div", { class: "shots-carousel-controls" }, [previous, next]));
758
+ controls.append(previous, next);
646
759
  onScrollSettle(track, () => this.#normalize(), signal);
647
760
  if (typeof ResizeObserver === "function") {
648
761
  const observer = new ResizeObserver(() => this.#resize());
@@ -650,6 +763,7 @@ function defineCarousel(): CustomElementConstructor {
650
763
  signal.addEventListener("abort", () => observer.disconnect());
651
764
  }
652
765
  }
766
+ if (controls.childElementCount) root.append(controls);
653
767
  this.replaceChildren(root);
654
768
  if (loop) this.#jump(copies * slides.length + (keep ?? 0));
655
769
  }