@struq/skill 0.2.2 → 0.4.0
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/README.md +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/imagery.md +0 -144
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -341
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -280
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
|
@@ -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
|
+
}
|