@a2f0/agent-tool 0.1.33 → 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.33",
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",
@@ -281,7 +281,9 @@
281
281
  text-align: center;
282
282
  }
283
283
 
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. */
284
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));
285
287
  position: relative;
286
288
  display: flex;
287
289
  gap: 0.5rem;
@@ -296,7 +298,8 @@
296
298
  flex: 0 0 auto;
297
299
  flex-direction: column;
298
300
  gap: 0.25rem;
299
- max-width: var(--_shots-thumb-max-width);
301
+ box-sizing: content-box;
302
+ width: var(--_shots-thumb-width);
300
303
  padding: 0.25rem;
301
304
  border: 1px solid transparent;
302
305
  border-radius: calc(var(--_shots-radius) / 2);
@@ -309,13 +312,11 @@
309
312
  border-color: var(--_shots-accent);
310
313
  }
311
314
 
312
- /* The image keeps its capture's aspect ratio at a fixed height, cropped from the top only when wider than the cap. */
315
+ /* A capture of another shape is cropped to the thumbnail, keeping its top. */
313
316
  .shots-thumb-image {
314
317
  display: block;
318
+ width: 100%;
315
319
  height: var(--_shots-thumb-height);
316
- min-width: 4.5rem;
317
- max-width: calc(var(--_shots-thumb-max-width) - 0.5rem);
318
- aspect-ratio: 3 / 4;
319
320
  border-radius: 4px;
320
321
  background: var(--_shots-surface-raised);
321
322
  overflow: hidden;
@@ -329,8 +330,11 @@
329
330
  object-position: top;
330
331
  }
331
332
 
333
+ /* Long labels never widen a thumbnail; they end in an ellipsis, with the full label in the button's title. */
332
334
  .shots-thumb-label {
333
- max-width: calc(var(--_shots-thumb-max-width) - 0.5rem);
335
+ display: block;
336
+ min-width: 0;
337
+ max-width: 100%;
334
338
  overflow: hidden;
335
339
  color: var(--_shots-ink-muted);
336
340
  font-size: 0.75rem;
@@ -501,11 +505,22 @@
501
505
 
502
506
  .shots-carousel-controls {
503
507
  display: flex;
508
+ flex-wrap: wrap;
509
+ align-items: center;
504
510
  justify-content: flex-end;
505
511
  gap: 0.5rem;
506
512
  padding: 0 var(--_shots-carousel-padding);
507
513
  }
508
514
 
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
+
509
524
  .shots-carousel-step {
510
525
  display: inline-grid;
511
526
  place-items: center;
@@ -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 {
@@ -191,6 +243,7 @@ function defineGallery(): CustomElementConstructor {
191
243
  #overflow = "";
192
244
  #rendered = false;
193
245
  #announced = "";
246
+ #wheel = createWheelStepper();
194
247
 
195
248
  constructor() {
196
249
  super();
@@ -280,6 +333,7 @@ function defineGallery(): CustomElementConstructor {
280
333
 
281
334
  const track = element("div", { class: "shots-track", tabindex: "0", role: "group", "aria-roledescription": "carousel" });
282
335
  onScrollSettle(track, () => this.#settle(), signal);
336
+ track.addEventListener("wheel", (event: WheelEvent) => this.#onWheel(event), { passive: false, signal });
283
337
  const previous = element("button", { type: "button", class: "shots-icon-button", "data-action": "previous", "aria-label": "Previous screen" }, [icon("prev")]);
284
338
  const next = element("button", { type: "button", class: "shots-icon-button", "data-action": "next", "aria-label": "Next screen" }, [icon("next")]);
285
339
  previous.addEventListener("click", () => this.step(-1), { signal });
@@ -316,6 +370,30 @@ function defineGallery(): CustomElementConstructor {
316
370
  else if (event.key === "ArrowLeft" || (inside && event.key === "ArrowUp")) { event.preventDefault(); this.step(-1); }
317
371
  }
318
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
+
319
397
  #setExpanded(expanded: boolean): void {
320
398
  if (expanded === this.#expanded || !this.#root) return;
321
399
  this.#expanded = expanded;
@@ -340,14 +418,20 @@ function defineGallery(): CustomElementConstructor {
340
418
  this.#screens = projectScreens(manifest, this.#project);
341
419
  this.#index = Math.max(0, screen === undefined ? 0 : this.#screens.indexOf(screen));
342
420
  this.#loaded.clear();
421
+ this.#wheel.reset();
343
422
  const frame = projectFrame(manifest, this.#project);
344
423
  this.#root!.dataset["frame"] = frame;
345
424
  this.#root!.dataset["project"] = this.#project;
346
425
  for (const button of this.#root!.querySelectorAll<HTMLButtonElement>(".shots-toggle-button")) button.setAttribute("aria-pressed", String(button.dataset["project"] === this.#project));
347
426
  track.setAttribute("aria-label", `${projectInfo(manifest, this.#project).label} screens`);
348
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");
349
432
  strip.replaceChildren(...this.#screens.map((name, index) => {
350
- 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 })]);
351
435
  button.addEventListener("click", () => this.#select(index, scrollBehavior()), { signal: this.#abort!.signal });
352
436
  return button;
353
437
  }));
@@ -398,7 +482,6 @@ function defineGallery(): CustomElementConstructor {
398
482
  const holder = thumb.querySelector(".shots-thumb-image")!;
399
483
  thumb.setAttribute("aria-label", screenInfo(manifest, name).label);
400
484
  if (!entry) { holder.replaceChildren(element("span", { class: "shots-thumb-missing" })); continue; }
401
- holder.setAttribute("style", `aspect-ratio: ${entry.width} / ${entry.height}`);
402
485
  holder.replaceChildren(element("img", { src: entry.src, alt: "", loading: "lazy", decoding: "async", width: entry.width, height: entry.height }));
403
486
  }
404
487
  this.#fit();
@@ -569,7 +652,7 @@ function defineGallery(): CustomElementConstructor {
569
652
 
570
653
  function defineCarousel(): CustomElementConstructor {
571
654
  return class ScreenshotCarousel extends HTMLElement implements ScreenshotCarouselElement {
572
- static observedAttributes = ["manifest", "project", "limit"];
655
+ static observedAttributes = ["manifest", "project", "limit", "view-all-href", "view-all-label"];
573
656
  #data: ScreenshotManifest | null = null;
574
657
  #abort: AbortController | null = null;
575
658
  #track: HTMLDivElement | null = null;
@@ -646,7 +729,9 @@ function defineCarousel(): CustomElementConstructor {
646
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 })] : []);
647
730
  picture.append(element("img", {
648
731
  src: slide.light.src, alt: clone ? "" : screen.alt ?? `${screen.label}, ${projectInfo(manifest, project).label.toLowerCase()} screenshot`,
649
- 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,
650
735
  }));
651
736
  const device = linkBase
652
737
  ? element("a", { class: "shots-device", href: screenshotPath(linkBase, project, slide.name), tabindex: clone ? "-1" : undefined }, [picture])
@@ -662,12 +747,15 @@ function defineCarousel(): CustomElementConstructor {
662
747
  const track = element("div", { class: "shots-carousel-track", tabindex: "0" }, [list]);
663
748
  this.#track = track;
664
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" }));
665
753
  if (loop) {
666
754
  const previous = element("button", { type: "button", class: "shots-carousel-step", "data-step": "-1", "aria-label": "Previous screens" }, [icon("prev")]);
667
755
  const next = element("button", { type: "button", class: "shots-carousel-step", "data-step": "1", "aria-label": "Next screens" }, [icon("next")]);
668
756
  previous.addEventListener("click", () => this.step(-1), { signal });
669
757
  next.addEventListener("click", () => this.step(1), { signal });
670
- root.append(element("div", { class: "shots-carousel-controls" }, [previous, next]));
758
+ controls.append(previous, next);
671
759
  onScrollSettle(track, () => this.#normalize(), signal);
672
760
  if (typeof ResizeObserver === "function") {
673
761
  const observer = new ResizeObserver(() => this.#resize());
@@ -675,6 +763,7 @@ function defineCarousel(): CustomElementConstructor {
675
763
  signal.addEventListener("abort", () => observer.disconnect());
676
764
  }
677
765
  }
766
+ if (controls.childElementCount) root.append(controls);
678
767
  this.replaceChildren(root);
679
768
  if (loop) this.#jump(copies * slides.length + (keep ?? 0));
680
769
  }