@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.
- package/README.md +22 -36
- package/package.json +4 -2
- package/skills/struq/SKILL.md +139 -307
- 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/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -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/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- 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 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- 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 +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- 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/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- 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/niche-packets.md +0 -338
- 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 -340
- 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 -279
- package/skills/struq/references/sections.md +0 -141
- 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
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// hover-list (Struq section recipe)
|
|
3
|
+
// A list of work set at display size: one project per row, its name as the composition and a quiet
|
|
4
|
+
// line of place and year on the name's baseline. Hover a row and that project's photograph stands in a
|
|
5
|
+
// small upright frame that follows the pointer, close behind it, tilting with the pointer's speed;
|
|
6
|
+
// the other rows step back and the hovered name moves a little forward. Moving from row to row swaps
|
|
7
|
+
// the photograph inside the frame through a mask: the new one wipes in the way the pointer travels,
|
|
8
|
+
// out of a slight push-in, over the old one. Leave the list and the frame folds shut on its middle.
|
|
9
|
+
//
|
|
10
|
+
// One clock: the frame follows on the GSAP ticker through quickTo, transforms only, and is within
|
|
11
|
+
// reach of the pointer in about 0.15 seconds, so it reads as attached, never as delayed. Every mask
|
|
12
|
+
// is written from plain numbers on update. Each photograph loads at the frame's size and is decoded
|
|
13
|
+
// before it is first shown, so a swap never shows an empty frame.
|
|
14
|
+
//
|
|
15
|
+
// <HoverList title="Gebouwd en bewoond." items={[
|
|
16
|
+
// { title: "De Lantaarn", place: "Utrecht", year: "2023", image: images.column[0], href: "#lantaarn" },
|
|
17
|
+
// ...
|
|
18
|
+
// ]} />
|
|
19
|
+
//
|
|
20
|
+
// Keyboard: a focused row shows its photograph in a fixed place beside it. Reduced motion: the frame
|
|
21
|
+
// stands beside the hovered row the same way, without the follow, the tilt or the wipes. Touch and
|
|
22
|
+
// phones: no floating frame; each row carries its photograph inline.
|
|
23
|
+
import Image from "next/image";
|
|
24
|
+
import { useRef } from "react";
|
|
25
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
26
|
+
import type { StruqImage } from "../kit/images";
|
|
27
|
+
|
|
28
|
+
type Item = {
|
|
29
|
+
title: string;
|
|
30
|
+
/** Where it is; set with the year as one quiet line. */
|
|
31
|
+
place: string;
|
|
32
|
+
year: string;
|
|
33
|
+
image: StruqImage;
|
|
34
|
+
href?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type Props = {
|
|
38
|
+
/** An optional heading above the list. */
|
|
39
|
+
title?: string;
|
|
40
|
+
items: Item[];
|
|
41
|
+
className?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The frame follows within reach in about 0.15s: a 0.26s power3.out covers nine tenths of the way by then. */
|
|
45
|
+
const FOLLOW = 0.26;
|
|
46
|
+
/** Tilt: degrees per pixel of pointer speed per frame, and the most it leans. */
|
|
47
|
+
const LEAN = 0.55;
|
|
48
|
+
const LEAN_MAX = 9;
|
|
49
|
+
/** A new photograph wipes in over this long, out of this push-in. */
|
|
50
|
+
const WIPE = 0.62;
|
|
51
|
+
const PUSH = 1.14;
|
|
52
|
+
/** The following frame rides beside the pointer, not on it, so the hovered name stays readable: its centre
|
|
53
|
+
* this share of its width to the right and this share of its height above. */
|
|
54
|
+
const AHEAD_X = 0.35;
|
|
55
|
+
const AHEAD_Y = 0.08;
|
|
56
|
+
/** Where a placed frame stands (keyboard, reduced motion) at the least: its centre, as a share of the width. */
|
|
57
|
+
const PLACE_X = 0.8;
|
|
58
|
+
|
|
59
|
+
const FINE = "(hover: hover) and (pointer: fine) and (min-width: 1024px)";
|
|
60
|
+
|
|
61
|
+
export function HoverList({ title, items, className = "" }: Props) {
|
|
62
|
+
const stage = useRef<HTMLElement>(null);
|
|
63
|
+
|
|
64
|
+
useGSAP(
|
|
65
|
+
() => {
|
|
66
|
+
const el = stage.current;
|
|
67
|
+
if (!el) return;
|
|
68
|
+
const list = el.querySelector<HTMLElement>("[data-list]");
|
|
69
|
+
const frame = el.querySelector<HTMLElement>("[data-frame]");
|
|
70
|
+
const fold = el.querySelector<HTMLElement>("[data-fold]");
|
|
71
|
+
const shade = el.querySelector<HTMLElement>("[data-shadow]");
|
|
72
|
+
const rows = Array.from(el.querySelectorAll<HTMLElement>("[data-row]"));
|
|
73
|
+
const layers = Array.from(el.querySelectorAll<HTMLElement>("[data-layer]"));
|
|
74
|
+
const pics = layers.map((l) => l.querySelector<HTMLElement>("[data-pic]"));
|
|
75
|
+
const imgs = layers.map((l) => l.querySelector<HTMLImageElement>("img"));
|
|
76
|
+
if (!list || !frame || !fold || rows.length === 0) return;
|
|
77
|
+
|
|
78
|
+
const mm = gsap.matchMedia();
|
|
79
|
+
mm.add({ fine: FINE, reduce: "(prefers-reduced-motion: reduce)" }, (ctx) => {
|
|
80
|
+
const { fine, reduce } = ctx.conditions as { fine: boolean; reduce: boolean };
|
|
81
|
+
if (!fine) return;
|
|
82
|
+
|
|
83
|
+
// Every photograph loads now, at the frame's size, and is decoded before it is first shown.
|
|
84
|
+
const ready = imgs.map((img) => {
|
|
85
|
+
if (!img) return Promise.resolve();
|
|
86
|
+
img.loading = "eager";
|
|
87
|
+
const loaded = img.complete
|
|
88
|
+
? Promise.resolve()
|
|
89
|
+
: new Promise<void>((res) => {
|
|
90
|
+
img.addEventListener("load", () => res(), { once: true });
|
|
91
|
+
img.addEventListener("error", () => res(), { once: true });
|
|
92
|
+
});
|
|
93
|
+
return loaded.then(() => img.decode()).catch(() => undefined);
|
|
94
|
+
});
|
|
95
|
+
const decoded = imgs.map(() => false);
|
|
96
|
+
ready.forEach((p, i) => p.then(() => (decoded[i] = true)));
|
|
97
|
+
|
|
98
|
+
// The masks, as plain numbers: the frame's fold (how far each edge has closed toward the middle, in
|
|
99
|
+
// %) and each layer's wipe (how much of it is still hidden, in %, from the side it wipes toward).
|
|
100
|
+
const fd = { v: 50 };
|
|
101
|
+
const paintFold = () => {
|
|
102
|
+
fold.style.clipPath = `inset(${fd.v.toFixed(2)}% 0% ${fd.v.toFixed(2)}% 0%)`;
|
|
103
|
+
if (shade) shade.style.transform = `scaleY(${(1 - fd.v / 50).toFixed(4)})`;
|
|
104
|
+
};
|
|
105
|
+
const wipe = layers.map(() => ({ a: 100, dir: 1 }));
|
|
106
|
+
const paintLayer = (i: number) => {
|
|
107
|
+
const w = wipe[i];
|
|
108
|
+
const a = w.a.toFixed(2);
|
|
109
|
+
layers[i].style.clipPath = w.dir > 0 ? `inset(0% 0% ${a}% 0%)` : `inset(${a}% 0% 0% 0%)`;
|
|
110
|
+
};
|
|
111
|
+
paintFold();
|
|
112
|
+
layers.forEach((_, i) => paintLayer(i));
|
|
113
|
+
|
|
114
|
+
gsap.set(frame, { xPercent: -50, yPercent: -50, x: 0, y: 0, rotation: 0, scale: 0.92 });
|
|
115
|
+
const xTo = gsap.quickTo(frame, "x", { duration: FOLLOW, ease: "power3.out" });
|
|
116
|
+
const yTo = gsap.quickTo(frame, "y", { duration: FOLLOW, ease: "power3.out" });
|
|
117
|
+
const rTo = gsap.quickTo(frame, "rotation", { duration: 0.5, ease: "power3.out" });
|
|
118
|
+
|
|
119
|
+
const st = { shown: false, current: -1, want: -1, follow: !reduce, px: 0, py: 0, lastX: 0, vx: 0, z: 1 };
|
|
120
|
+
|
|
121
|
+
// Where the frame stands for a row when it does not follow: beside the row's words, clear of them and
|
|
122
|
+
// of the focus ring drawn around them, centred on the row. When a wide face leaves no room beside them,
|
|
123
|
+
// it stands at the right edge just above the row instead, so it never covers the row it belongs to.
|
|
124
|
+
const placeFor = (i: number) => {
|
|
125
|
+
const er = el.getBoundingClientRect();
|
|
126
|
+
const rr = rows[i].getBoundingClientRect();
|
|
127
|
+
const text = rows[i].querySelector<HTMLElement>("[data-text]");
|
|
128
|
+
const end = (text ? text.getBoundingClientRect().right : rr.left) - er.left;
|
|
129
|
+
const half = frame.offsetWidth / 2;
|
|
130
|
+
const edge = er.width - half - er.width * 0.02;
|
|
131
|
+
const beside = Math.max(er.width * PLACE_X, end + half + er.width * 0.035);
|
|
132
|
+
if (beside <= edge) return { x: beside, y: rr.top - er.top + rr.height / 2 };
|
|
133
|
+
return { x: edge, y: rr.top - er.top - frame.offsetHeight / 2 - rr.height * 0.12 };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// The follow target: beside the pointer, kept wholly inside the section (with room for the most it
|
|
137
|
+
// leans), so the section's clip never cuts the frame at an edge.
|
|
138
|
+
const target = () => {
|
|
139
|
+
const er = el.getBoundingClientRect();
|
|
140
|
+
const fw = frame.offsetWidth;
|
|
141
|
+
const fh = frame.offsetHeight;
|
|
142
|
+
const lean = (fh / 2) * Math.sin(LEAN_MAX * (Math.PI / 180)) + 4;
|
|
143
|
+
const x = gsap.utils.clamp(fw / 2 + lean, er.width - fw / 2 - lean, st.px - er.left + fw * AHEAD_X);
|
|
144
|
+
const y = gsap.utils.clamp(fh / 2 + 4, er.height - fh / 2 - 4, st.py - er.top - fh * AHEAD_Y);
|
|
145
|
+
return { x, y };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// The one loop: while the frame follows, it eases toward the pointer and leans with its speed.
|
|
149
|
+
const tick = () => {
|
|
150
|
+
if (!st.shown || !st.follow) return;
|
|
151
|
+
const t = target();
|
|
152
|
+
xTo(t.x);
|
|
153
|
+
yTo(t.y);
|
|
154
|
+
st.vx += (st.px - st.lastX - st.vx) * 0.35;
|
|
155
|
+
st.lastX = st.px;
|
|
156
|
+
rTo(gsap.utils.clamp(-LEAN_MAX, LEAN_MAX, st.vx * LEAN));
|
|
157
|
+
};
|
|
158
|
+
gsap.ticker.add(tick);
|
|
159
|
+
|
|
160
|
+
const mark = (i: number) => {
|
|
161
|
+
rows.forEach((r, j) => {
|
|
162
|
+
if (j === i) r.dataset.current = "";
|
|
163
|
+
else delete r.dataset.current;
|
|
164
|
+
});
|
|
165
|
+
if (i >= 0) list.dataset.active = "";
|
|
166
|
+
else delete list.dataset.active;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const open = (i: number) => {
|
|
170
|
+
// First show: the frame unfolds where it is wanted, with the row's photograph already in it.
|
|
171
|
+
gsap.killTweensOf(fd);
|
|
172
|
+
for (let j = 0; j < layers.length; j += 1) {
|
|
173
|
+
wipe[j].a = j === i ? 0 : 100;
|
|
174
|
+
paintLayer(j);
|
|
175
|
+
}
|
|
176
|
+
layers[i].style.zIndex = String(++st.z);
|
|
177
|
+
const pic = pics[i];
|
|
178
|
+
if (pic) gsap.set(pic, { scale: 1, yPercent: 0 });
|
|
179
|
+
if (st.follow) {
|
|
180
|
+
const t = target();
|
|
181
|
+
xTo(t.x, t.x);
|
|
182
|
+
yTo(t.y, t.y);
|
|
183
|
+
st.lastX = st.px;
|
|
184
|
+
st.vx = 0;
|
|
185
|
+
} else {
|
|
186
|
+
const p = placeFor(i);
|
|
187
|
+
gsap.set(frame, { x: p.x, y: p.y, rotation: 0 });
|
|
188
|
+
}
|
|
189
|
+
frame.style.visibility = "visible";
|
|
190
|
+
st.shown = true;
|
|
191
|
+
if (reduce) {
|
|
192
|
+
fd.v = 0;
|
|
193
|
+
paintFold();
|
|
194
|
+
gsap.set(frame, { scale: 1 });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
gsap.to(fd, { v: 0, duration: 0.6, ease: "expo.out", onUpdate: paintFold, overwrite: true });
|
|
198
|
+
gsap.to(frame, { scale: 1, duration: 0.6, ease: "expo.out", overwrite: "auto" });
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const swap = (i: number, from: number) => {
|
|
202
|
+
if (!st.follow) {
|
|
203
|
+
const p = placeFor(i);
|
|
204
|
+
gsap.set(frame, { x: p.x, y: p.y });
|
|
205
|
+
}
|
|
206
|
+
// Back to a photograph that still shows under the ones wiping over it (a quick graze across a row
|
|
207
|
+
// and back): those draw back the way they came and it is never re-hidden, so no part of the frame
|
|
208
|
+
// is ever left without a picture.
|
|
209
|
+
if (wipe[i].a < 99.5) {
|
|
210
|
+
const zi = Number(layers[i].style.zIndex) || 0;
|
|
211
|
+
for (let j = 0; j < layers.length; j += 1) {
|
|
212
|
+
const wj = wipe[j];
|
|
213
|
+
if (j === i || wj.a >= 100 || (Number(layers[j].style.zIndex) || 0) <= zi) continue;
|
|
214
|
+
gsap.killTweensOf(wj);
|
|
215
|
+
if (reduce) {
|
|
216
|
+
wj.a = 100;
|
|
217
|
+
paintLayer(j);
|
|
218
|
+
} else {
|
|
219
|
+
gsap.to(wj, { a: 100, duration: WIPE * 0.85, ease: "expo.out", onUpdate: () => paintLayer(j) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const wi = wipe[i];
|
|
223
|
+
if (wi.a > 0) {
|
|
224
|
+
gsap.killTweensOf(wi);
|
|
225
|
+
if (reduce) {
|
|
226
|
+
wi.a = 0;
|
|
227
|
+
paintLayer(i);
|
|
228
|
+
} else {
|
|
229
|
+
gsap.to(wi, { a: 0, duration: WIPE, ease: "expo.out", onUpdate: () => paintLayer(i) });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// The new photograph wipes in the way the pointer travels, over the old one, out of a push-in.
|
|
235
|
+
const dir = i > from ? 1 : -1;
|
|
236
|
+
const w = wipe[i];
|
|
237
|
+
gsap.killTweensOf(w);
|
|
238
|
+
w.dir = dir;
|
|
239
|
+
w.a = 100;
|
|
240
|
+
layers[i].style.zIndex = String(++st.z);
|
|
241
|
+
paintLayer(i);
|
|
242
|
+
if (reduce) {
|
|
243
|
+
w.a = 0;
|
|
244
|
+
paintLayer(i);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const pic = pics[i];
|
|
248
|
+
if (pic) gsap.fromTo(pic, { scale: PUSH, yPercent: -7 * dir }, { scale: 1, yPercent: 0, duration: WIPE + 0.2, ease: "expo.out", overwrite: true });
|
|
249
|
+
gsap.to(w, {
|
|
250
|
+
a: 0,
|
|
251
|
+
duration: WIPE,
|
|
252
|
+
ease: "expo.out",
|
|
253
|
+
onUpdate: () => paintLayer(i),
|
|
254
|
+
onComplete: () => {
|
|
255
|
+
// Only the photograph on top stays open, so the next wipe always covers a whole picture.
|
|
256
|
+
for (let j = 0; j < layers.length; j += 1) {
|
|
257
|
+
if (j === i || j === st.current) continue;
|
|
258
|
+
wipe[j].a = 100;
|
|
259
|
+
paintLayer(j);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const show = (i: number) => {
|
|
266
|
+
st.want = i;
|
|
267
|
+
if (i === st.current && st.shown) return;
|
|
268
|
+
const go = () => {
|
|
269
|
+
if (st.want !== i) return;
|
|
270
|
+
const from = st.current;
|
|
271
|
+
st.current = i;
|
|
272
|
+
mark(i);
|
|
273
|
+
if (!st.shown) open(i);
|
|
274
|
+
else swap(i, from);
|
|
275
|
+
};
|
|
276
|
+
if (decoded[i]) go();
|
|
277
|
+
else ready[i].then(go);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const hide = () => {
|
|
281
|
+
st.want = -1;
|
|
282
|
+
if (!st.shown) return;
|
|
283
|
+
st.shown = false;
|
|
284
|
+
st.current = -1;
|
|
285
|
+
mark(-1);
|
|
286
|
+
if (reduce) {
|
|
287
|
+
frame.style.visibility = "hidden";
|
|
288
|
+
fd.v = 50;
|
|
289
|
+
paintFold();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// The frame folds shut on its middle and steps back.
|
|
293
|
+
gsap.to(fd, {
|
|
294
|
+
v: 50,
|
|
295
|
+
duration: 0.38,
|
|
296
|
+
ease: "power2.in",
|
|
297
|
+
onUpdate: paintFold,
|
|
298
|
+
overwrite: true,
|
|
299
|
+
onComplete: () => {
|
|
300
|
+
if (!st.shown) frame.style.visibility = "hidden";
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
gsap.to(frame, { scale: 0.92, duration: 0.38, ease: "power2.in", overwrite: "auto" });
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const onMove = (e: PointerEvent) => {
|
|
307
|
+
st.px = e.clientX;
|
|
308
|
+
st.py = e.clientY;
|
|
309
|
+
if (!reduce && !st.follow) {
|
|
310
|
+
// The pointer takes over from the keyboard: the frame starts following from where it stands.
|
|
311
|
+
st.follow = true;
|
|
312
|
+
st.lastX = st.px;
|
|
313
|
+
st.vx = 0;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const enters = rows.map((row, i) => {
|
|
317
|
+
const fn = (e: PointerEvent) => {
|
|
318
|
+
st.px = e.clientX;
|
|
319
|
+
st.py = e.clientY;
|
|
320
|
+
show(i);
|
|
321
|
+
};
|
|
322
|
+
row.addEventListener("pointerenter", fn);
|
|
323
|
+
return fn;
|
|
324
|
+
});
|
|
325
|
+
const focuses = rows.map((row, i) => {
|
|
326
|
+
const fn = () => {
|
|
327
|
+
if (!row.matches(":focus-visible")) return;
|
|
328
|
+
st.follow = false;
|
|
329
|
+
gsap.to(frame, { rotation: 0, duration: 0.3, ease: "power3.out", overwrite: "auto" });
|
|
330
|
+
if (st.shown) {
|
|
331
|
+
const p = placeFor(i);
|
|
332
|
+
gsap.to(frame, { x: p.x, y: p.y, duration: reduce ? 0 : 0.45, ease: "expo.out", overwrite: "auto" });
|
|
333
|
+
}
|
|
334
|
+
show(i);
|
|
335
|
+
};
|
|
336
|
+
row.addEventListener("focus", fn);
|
|
337
|
+
return fn;
|
|
338
|
+
});
|
|
339
|
+
const onLeave = () => hide();
|
|
340
|
+
const onFocusOut = (e: FocusEvent) => {
|
|
341
|
+
if (!list.contains(e.relatedTarget as Node | null)) hide();
|
|
342
|
+
};
|
|
343
|
+
list.addEventListener("pointermove", onMove, { passive: true });
|
|
344
|
+
list.addEventListener("pointerleave", onLeave);
|
|
345
|
+
list.addEventListener("focusout", onFocusOut);
|
|
346
|
+
|
|
347
|
+
return () => {
|
|
348
|
+
gsap.ticker.remove(tick);
|
|
349
|
+
rows.forEach((row, i) => {
|
|
350
|
+
row.removeEventListener("pointerenter", enters[i]);
|
|
351
|
+
row.removeEventListener("focus", focuses[i]);
|
|
352
|
+
delete row.dataset.current;
|
|
353
|
+
});
|
|
354
|
+
list.removeEventListener("pointermove", onMove);
|
|
355
|
+
list.removeEventListener("pointerleave", onLeave);
|
|
356
|
+
list.removeEventListener("focusout", onFocusOut);
|
|
357
|
+
delete list.dataset.active;
|
|
358
|
+
frame.style.visibility = "";
|
|
359
|
+
fold.style.clipPath = "";
|
|
360
|
+
if (shade) shade.style.transform = "";
|
|
361
|
+
for (const l of layers) {
|
|
362
|
+
l.style.clipPath = "";
|
|
363
|
+
l.style.zIndex = "";
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
return () => mm.revert();
|
|
368
|
+
},
|
|
369
|
+
{ scope: stage, dependencies: [items.length] },
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
// The shadow is made from the ground itself, a step deeper than it in light and dark palettes alike.
|
|
373
|
+
const shadow =
|
|
374
|
+
"0 2px 6px oklch(from var(--surface-0) calc(l * 0.3) c h / 0.3), 0 30px 60px -24px oklch(from var(--surface-0) calc(l * 0.22) c h / 0.55)";
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<section
|
|
378
|
+
ref={stage}
|
|
379
|
+
data-section="hover-list"
|
|
380
|
+
aria-labelledby={title ? "hover-list-title" : undefined}
|
|
381
|
+
className={`relative bg-surface-0 px-6 py-20 [overflow-x:clip] lg:px-[4vw] lg:py-[12svh] ${className}`}
|
|
382
|
+
>
|
|
383
|
+
{title ? (
|
|
384
|
+
<h2 id="hover-list-title" className="font-display mb-10 text-h2 text-ink lg:mb-[6svh]">
|
|
385
|
+
{title}
|
|
386
|
+
</h2>
|
|
387
|
+
) : null}
|
|
388
|
+
|
|
389
|
+
<ul data-list="" className="group/list border-t border-line">
|
|
390
|
+
{items.map((item, i) => {
|
|
391
|
+
const body = (
|
|
392
|
+
<>
|
|
393
|
+
{/* Touch and phones: the photograph inline, beside the name. */}
|
|
394
|
+
<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">
|
|
395
|
+
<Image
|
|
396
|
+
src={item.image.src}
|
|
397
|
+
alt=""
|
|
398
|
+
fill
|
|
399
|
+
sizes="(min-width: 640px) 16vw, 22vw"
|
|
400
|
+
unoptimized={item.image.unoptimized}
|
|
401
|
+
className="object-cover"
|
|
402
|
+
/>
|
|
403
|
+
</span>
|
|
404
|
+
{/* The focus ring is drawn around the words only, so the placed frame can stand clear of it. */}
|
|
405
|
+
<span
|
|
406
|
+
data-text=""
|
|
407
|
+
className="flex min-w-0 flex-col gap-1.5 rounded-[0.15rem] outline-offset-[0.6rem] transition-[translate] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-data-[current]/row:translate-x-[1.4vw] 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]"
|
|
408
|
+
>
|
|
409
|
+
<span
|
|
410
|
+
data-name=""
|
|
411
|
+
className="font-display block text-[10.5vw] leading-[0.95] tracking-[-0.035em] text-ink lg:text-[clamp(2.75rem,1rem_+_7vw,9rem)]"
|
|
412
|
+
>
|
|
413
|
+
{item.title}
|
|
414
|
+
</span>
|
|
415
|
+
<span data-meta="" className="block text-small text-ink-muted">
|
|
416
|
+
{item.place}, {item.year}
|
|
417
|
+
</span>
|
|
418
|
+
</span>
|
|
419
|
+
</>
|
|
420
|
+
);
|
|
421
|
+
const rowClass =
|
|
422
|
+
"group/row flex items-center gap-4 py-4 focus-visible:outline-none! transition-opacity duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] lg:py-[1.3vw] group-data-[active]/list:not-data-[current]:opacity-[0.28]";
|
|
423
|
+
return (
|
|
424
|
+
<li key={item.title} className="border-b border-line">
|
|
425
|
+
{item.href ? (
|
|
426
|
+
<a data-row="" href={item.href} className={rowClass}>
|
|
427
|
+
{body}
|
|
428
|
+
</a>
|
|
429
|
+
) : (
|
|
430
|
+
<div data-row="" className={rowClass}>
|
|
431
|
+
{body}
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
</li>
|
|
435
|
+
);
|
|
436
|
+
})}
|
|
437
|
+
</ul>
|
|
438
|
+
|
|
439
|
+
{/* The frame: desktop with a fine pointer only. Hidden until a row is hovered or focused. */}
|
|
440
|
+
<div
|
|
441
|
+
data-frame=""
|
|
442
|
+
aria-hidden
|
|
443
|
+
className="pointer-events-none invisible absolute left-0 top-0 z-20 hidden aspect-[4/5] w-[clamp(13rem,19vw,22rem)] [@media(hover:hover)_and_(pointer:fine)_and_(min-width:1024px)]:block"
|
|
444
|
+
>
|
|
445
|
+
{/* The shadow folds with the frame: scaled on its height, since a clip would cut it off. */}
|
|
446
|
+
<div data-shadow="" className="absolute inset-0 rounded-[0.2rem]" style={{ boxShadow: shadow, transform: "scaleY(0)" }} />
|
|
447
|
+
<div data-fold="" className="absolute inset-0 overflow-hidden rounded-[0.2rem] bg-surface-2" style={{ clipPath: "inset(50% 0% 50% 0%)" }}>
|
|
448
|
+
{items.map((item, i) => (
|
|
449
|
+
<div key={item.title} data-layer="" className="absolute inset-0 overflow-hidden" style={{ clipPath: "inset(100% 0% 0% 0%)", zIndex: i === 0 ? 1 : 0 }}>
|
|
450
|
+
<div data-pic="" className="absolute inset-0">
|
|
451
|
+
<Image
|
|
452
|
+
src={item.image.src}
|
|
453
|
+
alt=""
|
|
454
|
+
fill
|
|
455
|
+
loading="lazy"
|
|
456
|
+
sizes="(min-width: 1024px) clamp(13rem, 19vw, 22rem), 1px"
|
|
457
|
+
unoptimized={item.image.unoptimized}
|
|
458
|
+
className="object-cover"
|
|
459
|
+
/>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
))}
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
</section>
|
|
466
|
+
);
|
|
467
|
+
}
|