@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 +1 -1
- package/src/screenshots/gallery.css +21 -6
- package/src/screenshots/gallery.ts +126 -26
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|