@a2f0/agent-tool 0.1.33 → 0.1.35

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.35",
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;
@@ -624,7 +707,7 @@ function defineCarousel(): CustomElementConstructor {
624
707
  return limit > 0 ? slides.slice(0, limit) : slides;
625
708
  }
626
709
 
627
- #render(manifest: ScreenshotManifest, keep?: number): void {
710
+ #render(manifest: ScreenshotManifest): void {
628
711
  this.#manifest = manifest;
629
712
  const slides = this.#slidesFor(manifest);
630
713
  const signal = this.#abort!.signal;
@@ -634,9 +717,35 @@ function defineCarousel(): CustomElementConstructor {
634
717
  this.dataset["state"] = "ready";
635
718
  const project = slides[0]!.light.project;
636
719
  const frame = projectFrame(manifest, project);
637
- const linkBase = this.getAttribute("link-base");
638
720
  const loop = slides.length > 1;
639
- const copies = loop ? this.#copies : 0;
721
+ const track = element("div", { class: "shots-carousel-track", tabindex: "0" }, [this.#list(manifest, slides, loop ? this.#copies : 0)]);
722
+ this.#track = track;
723
+ const root = element("div", { class: "shots-carousel", role: "region", "aria-roledescription": "carousel", "aria-label": this.getAttribute("label") ?? "Screenshots", "data-frame": frame }, [track]);
724
+ const controls = element("div", { class: "shots-carousel-controls" });
725
+ const viewAll = this.getAttribute("view-all-href");
726
+ if (viewAll) controls.append(element("a", { class: "shots-carousel-view-all", href: viewAll, text: this.getAttribute("view-all-label") || "View all screenshots" }));
727
+ if (loop) {
728
+ const previous = element("button", { type: "button", class: "shots-carousel-step", "data-step": "-1", "aria-label": "Previous screens" }, [icon("prev")]);
729
+ const next = element("button", { type: "button", class: "shots-carousel-step", "data-step": "1", "aria-label": "Next screens" }, [icon("next")]);
730
+ previous.addEventListener("click", () => this.step(-1), { signal });
731
+ next.addEventListener("click", () => this.step(1), { signal });
732
+ controls.append(previous, next);
733
+ onScrollSettle(track, () => this.#normalize(), signal);
734
+ if (typeof ResizeObserver === "function") {
735
+ const observer = new ResizeObserver(() => this.#resize());
736
+ observer.observe(track);
737
+ signal.addEventListener("abort", () => observer.disconnect());
738
+ }
739
+ }
740
+ if (controls.childElementCount) root.append(controls);
741
+ this.replaceChildren(root);
742
+ if (loop) this.#jump(this.#copies * slides.length);
743
+ }
744
+
745
+ /** The slide list: the originals between `copies` loop copies on each side. */
746
+ #list(manifest: ScreenshotManifest, slides: { name: string; light: ScreenshotEntry; dark: ScreenshotEntry | undefined }[], copies: number): HTMLUListElement {
747
+ const project = slides[0]!.light.project;
748
+ const linkBase = this.getAttribute("link-base");
640
749
  const list = element("ul", { class: "shots-carousel-list", role: "list" });
641
750
  this.#slides = [];
642
751
  for (let copy = -copies; copy <= copies; copy += 1) {
@@ -646,7 +755,9 @@ function defineCarousel(): CustomElementConstructor {
646
755
  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
756
  picture.append(element("img", {
648
757
  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",
758
+ width: slide.light.width, height: slide.light.height, decoding: "async",
759
+ // The first original rests at the start edge on load: fetch it first, and the rest as they near the viewport.
760
+ loading: !clone && index === 0 ? "eager" : "lazy", fetchpriority: !clone && index === 0 ? "high" : undefined,
650
761
  }));
651
762
  const device = linkBase
652
763
  ? element("a", { class: "shots-device", href: screenshotPath(linkBase, project, slide.name), tabindex: clone ? "-1" : undefined }, [picture])
@@ -659,24 +770,7 @@ function defineCarousel(): CustomElementConstructor {
659
770
  list.append(item);
660
771
  });
661
772
  }
662
- const track = element("div", { class: "shots-carousel-track", tabindex: "0" }, [list]);
663
- this.#track = track;
664
- const root = element("div", { class: "shots-carousel", role: "region", "aria-roledescription": "carousel", "aria-label": this.getAttribute("label") ?? "Screenshots", "data-frame": frame }, [track]);
665
- if (loop) {
666
- const previous = element("button", { type: "button", class: "shots-carousel-step", "data-step": "-1", "aria-label": "Previous screens" }, [icon("prev")]);
667
- const next = element("button", { type: "button", class: "shots-carousel-step", "data-step": "1", "aria-label": "Next screens" }, [icon("next")]);
668
- previous.addEventListener("click", () => this.step(-1), { signal });
669
- next.addEventListener("click", () => this.step(1), { signal });
670
- root.append(element("div", { class: "shots-carousel-controls" }, [previous, next]));
671
- onScrollSettle(track, () => this.#normalize(), signal);
672
- if (typeof ResizeObserver === "function") {
673
- const observer = new ResizeObserver(() => this.#resize());
674
- observer.observe(track);
675
- signal.addEventListener("abort", () => observer.disconnect());
676
- }
677
- }
678
- this.replaceChildren(root);
679
- if (loop) this.#jump(copies * slides.length + (keep ?? 0));
773
+ return list;
680
774
  }
681
775
 
682
776
  #pitch(): number {
@@ -713,7 +807,13 @@ function defineCarousel(): CustomElementConstructor {
713
807
  const logical = wrapIndex(this.#current(), this.#count), manifest = this.#manifest, signal = this.#abort?.signal;
714
808
  this.#copies = copies;
715
809
  // Outside the observer callback, so the new slides do not resize the track within it.
716
- requestAnimationFrame(() => { if (!signal?.aborted) this.#render(manifest, logical); });
810
+ // Only the slides change: the controls and the view-all link stay the same nodes, so a
811
+ // click or focus on them survives a resize.
812
+ requestAnimationFrame(() => {
813
+ if (signal?.aborted || this.#track !== track) return;
814
+ track.replaceChildren(this.#list(manifest, this.#slidesFor(manifest), copies));
815
+ this.#jump(copies * this.#count + logical);
816
+ });
717
817
  } else this.#normalize();
718
818
  }
719
819