@struq/skill 0.3.0 → 0.4.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 (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +4 -2
  3. package/skills/struq/SKILL.md +139 -307
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
  19. package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
  20. package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
  21. package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
  22. package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
  23. package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
  24. package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
  25. package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
  26. package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/images.ts +17 -0
  29. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  30. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  31. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  32. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  33. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  34. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  35. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  36. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  37. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  38. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  39. package/skills/struq/blocks/sections/approach.tsx +661 -0
  40. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  41. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  42. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  43. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  44. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  45. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  46. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  47. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  48. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  49. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  50. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  51. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  52. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  53. package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
  54. package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
  55. package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
  56. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  57. package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
  58. package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
  59. package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
  60. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  61. package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
  62. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  63. package/skills/struq/canon/canon.json +627 -0
  64. package/skills/struq/cinematic/README.md +7 -18
  65. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  66. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  67. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  68. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  69. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  70. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  71. package/skills/struq/recipes/app/fonts.ts +12 -22
  72. package/skills/struq/recipes/app/globals.css +34 -177
  73. package/skills/struq/recipes/app/layout.tsx +13 -11
  74. package/skills/struq/references/craft.md +77 -0
  75. package/skills/struq/references/iconography.md +28 -230
  76. package/skills/struq/references/stack-targets.md +23 -74
  77. package/skills/struq/references/tells.md +52 -0
  78. package/skills/struq/references/triage.md +6 -7
  79. package/skills/struq/scripts/blocks.mjs +156 -0
  80. package/skills/struq/scripts/canon.mjs +106 -281
  81. package/skills/struq/scripts/check.mjs +92 -126
  82. package/skills/struq/scripts/images.mjs +2 -2
  83. package/skills/struq/scripts/log.mjs +6 -23
  84. package/skills/struq/scripts/tokens.mjs +222 -0
  85. package/skills/struq/cinematic/showpieces/README.md +0 -18
  86. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
  87. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
  88. package/skills/struq/references/anti-patterns.md +0 -330
  89. package/skills/struq/references/baseline-prompt.md +0 -122
  90. package/skills/struq/references/brand.md +0 -143
  91. package/skills/struq/references/brief-router.md +0 -218
  92. package/skills/struq/references/composition.md +0 -190
  93. package/skills/struq/references/depth-parallax.md +0 -235
  94. package/skills/struq/references/direction.md +0 -188
  95. package/skills/struq/references/feel-layer.md +0 -107
  96. package/skills/struq/references/gsap-api.md +0 -293
  97. package/skills/struq/references/hero-scenes.md +0 -107
  98. package/skills/struq/references/imagery.md +0 -125
  99. package/skills/struq/references/loaders.md +0 -287
  100. package/skills/struq/references/micro-interactions.md +0 -270
  101. package/skills/struq/references/mobile-composition.md +0 -70
  102. package/skills/struq/references/nav-header.md +0 -296
  103. package/skills/struq/references/niche-packets.md +0 -338
  104. package/skills/struq/references/page-architecture.md +0 -134
  105. package/skills/struq/references/page-entrances.md +0 -198
  106. package/skills/struq/references/performance.md +0 -235
  107. package/skills/struq/references/pinning-scrub.md +0 -340
  108. package/skills/struq/references/pitfalls.md +0 -290
  109. package/skills/struq/references/route-transitions.md +0 -281
  110. package/skills/struq/references/scaling.md +0 -151
  111. package/skills/struq/references/scroll-reveals.md +0 -279
  112. package/skills/struq/references/sections.md +0 -141
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
  123. package/skills/struq/scripts/font-names.mjs +0 -40
  124. package/skills/struq/scripts/recipes.mjs +0 -135
  125. /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
  126. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
  127. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
  128. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
  129. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
@@ -0,0 +1,432 @@
1
+ "use client";
2
+ // image-trail (Struq section recipe)
3
+ // A list of work set at display size; moving the pointer over it deals the hovered project's photographs
4
+ // onto the page. Each time the pointer has travelled a set distance a new print lands exactly where the
5
+ // pointer is, slightly turned, settling as if dropped from a hand; the prints before it hold a moment,
6
+ // then shrink and fade behind it. At most six are on screen. A fast hand leaves a longer, wider trail, a
7
+ // still hand leaves nothing new, and leaving the list lets the last prints settle away.
8
+ //
9
+ // One ticker loop, a fixed pool of six prints reused in turn (nothing is created per move), transforms
10
+ // and opacity only, positions in whole pixels. Each print carries every project's photograph and shows
11
+ // the one it is dealt; a project's photograph is decoded in every print before it can be dealt, so a
12
+ // print never lands empty.
13
+ //
14
+ // <ImageTrail title="Werk." items={[
15
+ // { title: "De Lantaarn", place: "Utrecht", year: "2023", image: images.column[0], href: "#lantaarn" },
16
+ // ...
17
+ // ]} />
18
+ //
19
+ // Keyboard: a focused row shows its photograph once, beside the row and clear of its focus ring. Reduced
20
+ // motion: hovering a row shows that one print beside it, no trail. Touch and phones: each row carries its
21
+ // photograph inline. Without scripts: the list.
22
+ import Image from "next/image";
23
+ import { useRef } from "react";
24
+ import { gsap, useGSAP } from "../kit/gsap";
25
+ import type { StruqImage } from "../kit/images";
26
+
27
+ type Item = {
28
+ title: string;
29
+ /** Where it is; set with the year as one quiet line. */
30
+ place: string;
31
+ year: string;
32
+ image: StruqImage;
33
+ href?: string;
34
+ };
35
+
36
+ type Props = {
37
+ /** An optional heading above the list. */
38
+ title?: string;
39
+ items: Item[];
40
+ className?: string;
41
+ };
42
+
43
+ /** Prints in the pool, the most on screen at once. */
44
+ const POOL = 6;
45
+ /** Pointer travel (px) between two prints. */
46
+ const STEP = 110;
47
+ /** A print lands over this long, holds, then shrinks and fades by the end of its life (seconds). */
48
+ const LAND = 0.32;
49
+ const HOLD = 0.28;
50
+ const LIFE = 1.05;
51
+ /** A landing print drops from this scale and this much extra turn. */
52
+ const DROP = 1.08;
53
+ const SPIN = 1.7;
54
+ /** Old prints shrink to this. */
55
+ const END = 0.78;
56
+
57
+ const easeLand = gsap.parseEase("expo.out");
58
+ const easeFade = gsap.parseEase("power2.in");
59
+
60
+ const FINE = "(hover: hover) and (pointer: fine) and (min-width: 1024px)";
61
+
62
+ export function ImageTrail({ title, items, className = "" }: Props) {
63
+ const stage = useRef<HTMLElement>(null);
64
+
65
+ useGSAP(
66
+ () => {
67
+ const el = stage.current;
68
+ if (!el) return;
69
+ const list = el.querySelector<HTMLElement>("[data-list]");
70
+ const rows = Array.from(el.querySelectorAll<HTMLElement>("[data-row]"));
71
+ const prints = Array.from(
72
+ el.querySelectorAll<HTMLElement>("[data-print]"),
73
+ );
74
+ const still = el.querySelector<HTMLElement>("[data-still]");
75
+ if (!list || rows.length === 0 || prints.length === 0 || !still) return;
76
+ const shots = prints.map((p) =>
77
+ Array.from(p.querySelectorAll<HTMLElement>("[data-shot]")),
78
+ );
79
+ const stillShots = Array.from(
80
+ still.querySelectorAll<HTMLElement>("[data-shot]"),
81
+ );
82
+
83
+ const mm = gsap.matchMedia();
84
+ mm.add(
85
+ { fine: FINE, reduce: "(prefers-reduced-motion: reduce)" },
86
+ (ctx) => {
87
+ const { fine, reduce } = ctx.conditions as {
88
+ fine: boolean;
89
+ reduce: boolean;
90
+ };
91
+ if (!fine) return;
92
+
93
+ // A project can be dealt once its photograph is decoded in every print that may show it.
94
+ const decoded = items.map(() => false);
95
+ items.forEach((_, i) => {
96
+ const imgs = [...shots.map((s) => s[i]), stillShots[i]]
97
+ .map((s) => s?.querySelector("img"))
98
+ .filter((img): img is HTMLImageElement => Boolean(img));
99
+ Promise.all(
100
+ imgs.map((img) => {
101
+ img.loading = "eager";
102
+ const loaded = img.complete
103
+ ? Promise.resolve()
104
+ : new Promise<void>((res) => {
105
+ img.addEventListener("load", () => res(), { once: true });
106
+ img.addEventListener("error", () => res(), {
107
+ once: true,
108
+ });
109
+ });
110
+ return loaded.then(() => img.decode()).catch(() => undefined);
111
+ }),
112
+ ).then(() => {
113
+ decoded[i] = true;
114
+ });
115
+ });
116
+
117
+ // Each print's state as plain numbers: where it landed, its turn, which photograph, when it landed.
118
+ const state = prints.map(() => ({
119
+ x: 0,
120
+ y: 0,
121
+ r: 0,
122
+ item: -1,
123
+ born: -1,
124
+ }));
125
+ const show = (shotsOf: HTMLElement[], item: number) => {
126
+ shotsOf.forEach((s, j) => {
127
+ s.style.visibility = j === item ? "inherit" : "hidden";
128
+ });
129
+ };
130
+ const st = {
131
+ inside: false,
132
+ current: -1,
133
+ px: 0,
134
+ py: 0,
135
+ lastX: 0,
136
+ lastY: 0,
137
+ travel: 0,
138
+ next: 0,
139
+ z: 1,
140
+ speed: 0,
141
+ };
142
+
143
+ const mark = (i: number) => {
144
+ rows.forEach((r, j) => {
145
+ if (j === i) r.dataset.current = "";
146
+ else delete r.dataset.current;
147
+ });
148
+ if (i >= 0) list.dataset.active = "";
149
+ else delete list.dataset.active;
150
+ };
151
+
152
+ // The next print in turn may be dealt only once it has faded out completely; a print that still shows
153
+ // is never taken back (the widening step makes this the rare case, this makes it the rule).
154
+ const free = (now: number) => {
155
+ const s = state[st.next];
156
+ return s.born < 0 || now - s.born >= LIFE;
157
+ };
158
+
159
+ // One print, dealt at the pointer in this frame.
160
+ const deal = (now: number, dx: number) => {
161
+ const item = st.current;
162
+ if (item < 0 || !decoded[item]) return;
163
+ const k = st.next;
164
+ st.next = (st.next + 1) % prints.length;
165
+ const s = state[k];
166
+ const er = el.getBoundingClientRect();
167
+ s.x = Math.round(st.px - er.left);
168
+ s.y = Math.round(st.py - er.top);
169
+ // Turned a little toward the way the hand is moving, with a touch of chance so no two land alike.
170
+ s.r =
171
+ gsap.utils.clamp(-8, 8, dx * 0.08) + (Math.random() - 0.5) * 5;
172
+ s.item = item;
173
+ s.born = now;
174
+ prints[k].style.zIndex = String(++st.z);
175
+ show(shots[k], item);
176
+ };
177
+
178
+ const paint = (now: number) => {
179
+ for (let k = 0; k < prints.length; k += 1) {
180
+ const s = state[k];
181
+ const p = prints[k];
182
+ if (s.born < 0) continue;
183
+ const a = now - s.born;
184
+ if (a >= LIFE) {
185
+ s.born = -1;
186
+ p.style.opacity = "0";
187
+ p.style.visibility = "hidden";
188
+ continue;
189
+ }
190
+ const land = easeLand(Math.min(1, a / LAND));
191
+ const fade = easeFade(Math.max(0, (a - HOLD) / (LIFE - HOLD)));
192
+ const scale = (DROP + (1 - DROP) * land) * (1 - (1 - END) * fade);
193
+ const r = s.r * (SPIN + (1 - SPIN) * land);
194
+ p.style.visibility = "visible";
195
+ p.style.opacity = (1 - fade).toFixed(3);
196
+ p.style.transform = `translate3d(${s.x}px, ${s.y}px, 0) translate(-50%, -50%) rotate(${r.toFixed(2)}deg) scale(${scale.toFixed(4)})`;
197
+ }
198
+ };
199
+
200
+ // The one loop: deal when the pointer has travelled far enough, then age every print.
201
+ const tick = (time: number, deltaMs: number) => {
202
+ const dt = Math.max(0.001, Math.min(0.1, deltaMs / 1000));
203
+ const dx = st.px - st.lastX;
204
+ const dy = st.py - st.lastY;
205
+ const moved = Math.hypot(dx, dy);
206
+ // The hand's speed, lightly smoothed.
207
+ st.speed += (moved / dt - st.speed) * (1 - Math.exp(-dt / 0.08));
208
+ if (st.inside && st.current >= 0 && !reduce) {
209
+ // A hand moving mostly up or down is passing across rows, not reading one: it deals nothing.
210
+ if (Math.abs(dy) <= Math.abs(dx) * 1.4) st.travel += moved;
211
+ // The step widens with speed so six prints always cover one life of the trail: a fast hand
212
+ // leaves a longer, wider trail, and no print is ever taken back while it still shows.
213
+ const step = Math.max(STEP, (st.speed * LIFE) / prints.length);
214
+ if (st.travel >= step && free(time)) {
215
+ st.travel = 0;
216
+ deal(time, dx);
217
+ }
218
+ }
219
+ st.lastX = st.px;
220
+ st.lastY = st.py;
221
+ paint(time);
222
+ };
223
+ gsap.ticker.add(tick);
224
+
225
+ // The single print beside a row: for keyboard focus, and for hover under reduced motion.
226
+ const place = (i: number) => {
227
+ const er = el.getBoundingClientRect();
228
+ const rr = rows[i].getBoundingClientRect();
229
+ const text = rows[i].querySelector<HTMLElement>("[data-text]");
230
+ const end =
231
+ (text ? text.getBoundingClientRect().right : rr.left) - er.left;
232
+ const half = still.offsetWidth / 2;
233
+ const edge = er.width - half - er.width * 0.03;
234
+ const beside = Math.max(
235
+ er.width * 0.72,
236
+ end + half + er.width * 0.035,
237
+ );
238
+ const x = Math.round(Math.min(beside, edge));
239
+ const y = Math.round(
240
+ beside <= edge
241
+ ? rr.top - er.top + rr.height / 2
242
+ : rr.top - er.top - still.offsetHeight / 2 - 8,
243
+ );
244
+ show(stillShots, i);
245
+ still.style.transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%)`;
246
+ still.style.visibility = "visible";
247
+ still.style.opacity = "1";
248
+ };
249
+ const hideStill = () => {
250
+ still.style.visibility = "hidden";
251
+ };
252
+
253
+ const onEnter = rows.map((row, i) => {
254
+ const fn = (e: PointerEvent) => {
255
+ st.px = e.clientX;
256
+ st.py = e.clientY;
257
+ // A row deals only after the hand has travelled a full step inside it, so rows merely crossed on
258
+ // the way out stay quiet.
259
+ if (st.current !== i) st.travel = 0;
260
+ st.current = i;
261
+ mark(i);
262
+ if (reduce && decoded[i]) place(i);
263
+ };
264
+ row.addEventListener("pointerenter", fn);
265
+ return fn;
266
+ });
267
+ const onFocus = rows.map((row, i) => {
268
+ const fn = () => {
269
+ if (!row.matches(":focus-visible")) return;
270
+ mark(i);
271
+ if (decoded[i]) place(i);
272
+ };
273
+ row.addEventListener("focus", fn);
274
+ return fn;
275
+ });
276
+ const onMove = (e: PointerEvent) => {
277
+ st.px = e.clientX;
278
+ st.py = e.clientY;
279
+ if (!st.inside) {
280
+ st.inside = true;
281
+ st.lastX = st.px;
282
+ st.lastY = st.py;
283
+ }
284
+ };
285
+ const onLeave = () => {
286
+ st.inside = false;
287
+ st.current = -1;
288
+ st.travel = 0;
289
+ mark(-1);
290
+ hideStill();
291
+ };
292
+ const onFocusOut = (e: FocusEvent) => {
293
+ if (!list.contains(e.relatedTarget as Node | null)) {
294
+ mark(-1);
295
+ hideStill();
296
+ }
297
+ };
298
+ list.addEventListener("pointermove", onMove, { passive: true });
299
+ list.addEventListener("pointerleave", onLeave);
300
+ list.addEventListener("focusout", onFocusOut);
301
+
302
+ return () => {
303
+ gsap.ticker.remove(tick);
304
+ rows.forEach((row, i) => {
305
+ row.removeEventListener("pointerenter", onEnter[i]);
306
+ row.removeEventListener("focus", onFocus[i]);
307
+ delete row.dataset.current;
308
+ });
309
+ list.removeEventListener("pointermove", onMove);
310
+ list.removeEventListener("pointerleave", onLeave);
311
+ list.removeEventListener("focusout", onFocusOut);
312
+ delete list.dataset.active;
313
+ for (const p of [...prints, still]) {
314
+ p.style.transform = "";
315
+ p.style.opacity = "";
316
+ p.style.visibility = "";
317
+ p.style.zIndex = "";
318
+ }
319
+ };
320
+ },
321
+ );
322
+ return () => mm.revert();
323
+ },
324
+ { scope: stage, dependencies: [items.length] },
325
+ );
326
+
327
+ // A soft shadow made from the ground itself, a step deeper than it in light and dark palettes alike.
328
+ const shadow =
329
+ "0 2px 6px oklch(from var(--surface-0) calc(l * 0.3) c h / 0.28), 0 26px 50px -22px oklch(from var(--surface-0) calc(l * 0.22) c h / 0.5)";
330
+ const print = (key: string, attr: Record<string, string>) => (
331
+ <div
332
+ key={key}
333
+ {...attr}
334
+ aria-hidden
335
+ className="pointer-events-none invisible absolute left-0 top-0 z-20 hidden aspect-[4/5] w-[clamp(11rem,15vw,17rem)] overflow-hidden rounded-[0.15rem] bg-surface-2 opacity-0 [@media(hover:hover)_and_(pointer:fine)_and_(min-width:1024px)]:block"
336
+ style={{ boxShadow: shadow }}
337
+ >
338
+ {items.map((item, j) => (
339
+ <div
340
+ key={item.title}
341
+ data-shot=""
342
+ className="invisible absolute inset-0"
343
+ data-index={j}
344
+ >
345
+ <Image
346
+ src={item.image.src}
347
+ alt=""
348
+ fill
349
+ loading="lazy"
350
+ sizes="(min-width: 1024px) clamp(11rem, 15vw, 17rem), 1px"
351
+ unoptimized={item.image.unoptimized}
352
+ className="object-cover"
353
+ />
354
+ </div>
355
+ ))}
356
+ </div>
357
+ );
358
+
359
+ return (
360
+ <section
361
+ ref={stage}
362
+ data-section="image-trail"
363
+ aria-labelledby={title ? "image-trail-title" : undefined}
364
+ className={`relative bg-surface-0 px-6 py-20 [overflow:clip] lg:px-[4vw] lg:py-[12svh] ${className}`}
365
+ >
366
+ {title ? (
367
+ <h2
368
+ id="image-trail-title"
369
+ className="font-display mb-10 text-h2 text-ink lg:mb-[6svh]"
370
+ >
371
+ {title}
372
+ </h2>
373
+ ) : null}
374
+
375
+ <ul
376
+ data-list=""
377
+ className="group/list relative z-10 border-t border-line"
378
+ >
379
+ {items.map((item) => {
380
+ const body = (
381
+ <>
382
+ {/* Touch and phones: the photograph inline, beside the name. */}
383
+ <span className="relative block aspect-[4/5] w-[22vw] shrink-0 overflow-hidden rounded-[0.15rem] bg-surface-2 sm:w-[16vw] [@media(hover:hover)_and_(pointer:fine)_and_(min-width:1024px)]:hidden">
384
+ <Image
385
+ src={item.image.src}
386
+ alt=""
387
+ fill
388
+ sizes="(min-width: 640px) 16vw, 22vw"
389
+ unoptimized={item.image.unoptimized}
390
+ className="object-cover"
391
+ />
392
+ </span>
393
+ {/* The focus ring is drawn around the words only, so the print beside it stands clear of it. */}
394
+ <span
395
+ data-text=""
396
+ className="flex min-w-0 flex-col gap-1.5 rounded-[0.15rem] outline-offset-[0.6rem] group-focus-visible/row:outline-2 group-focus-visible/row:outline-solid group-focus-visible/row:outline-accent lg:flex-row lg:flex-wrap lg:items-baseline lg:gap-x-[2.2vw]"
397
+ >
398
+ <span className="font-display block text-[10.5vw] leading-[0.95] tracking-[-0.035em] text-ink lg:text-[clamp(2.75rem,1rem_+_7vw,9rem)]">
399
+ {item.title}
400
+ </span>
401
+ <span className="block text-small text-ink-muted">
402
+ {item.place}, {item.year}
403
+ </span>
404
+ </span>
405
+ </>
406
+ );
407
+ const rowClass =
408
+ "group/row flex items-center gap-4 py-4 transition-opacity duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] focus-visible:outline-none! lg:py-[1.2vw] group-data-[active]/list:not-data-[current]:opacity-[0.3]";
409
+ return (
410
+ <li key={item.title} className="border-b border-line">
411
+ {item.href ? (
412
+ <a data-row="" href={item.href} className={rowClass}>
413
+ {body}
414
+ </a>
415
+ ) : (
416
+ <div data-row="" className={rowClass}>
417
+ {body}
418
+ </div>
419
+ )}
420
+ </li>
421
+ );
422
+ })}
423
+ </ul>
424
+
425
+ {/* The prints: a fixed pool dealt in turn, and the one that stands beside a focused row. */}
426
+ {Array.from({ length: POOL }, (_, k) =>
427
+ print(`p${k}`, { "data-print": "" }),
428
+ )}
429
+ {print("still", { "data-still": "" })}
430
+ </section>
431
+ );
432
+ }