@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.
Files changed (122) hide show
  1. package/README.md +23 -35
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +148 -212
  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/blocks/kit/Cursor.tsx +117 -0
  19. package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
  20. package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
  21. package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
  22. package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
  23. package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
  24. package/skills/struq/blocks/kit/Reveal.tsx +64 -0
  25. package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
  26. package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/gsap.ts +21 -0
  29. package/skills/struq/blocks/kit/images.ts +17 -0
  30. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  31. package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
  32. package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
  33. package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
  34. package/skills/struq/blocks/kit/scenes/types.ts +114 -0
  35. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  36. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  37. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  38. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  39. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  40. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  41. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  42. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  43. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  44. package/skills/struq/blocks/sections/approach.tsx +661 -0
  45. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  46. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  47. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  48. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  49. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  50. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  51. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  52. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  53. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  54. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  55. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  56. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  57. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  58. package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
  59. package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
  60. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
  61. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  62. package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
  63. package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
  64. package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
  65. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  66. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
  67. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  68. package/skills/struq/canon/canon.json +627 -0
  69. package/skills/struq/cinematic/README.md +7 -18
  70. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  71. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  72. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  73. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  74. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  75. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  76. package/skills/struq/recipes/app/fonts.ts +12 -0
  77. package/skills/struq/recipes/app/globals.css +42 -0
  78. package/skills/struq/recipes/app/layout.tsx +30 -0
  79. package/skills/struq/references/craft.md +77 -0
  80. package/skills/struq/references/iconography.md +28 -230
  81. package/skills/struq/references/stack-targets.md +23 -74
  82. package/skills/struq/references/tells.md +52 -0
  83. package/skills/struq/references/triage.md +6 -7
  84. package/skills/struq/scripts/blocks.mjs +156 -0
  85. package/skills/struq/scripts/canon.mjs +134 -0
  86. package/skills/struq/scripts/check.mjs +513 -0
  87. package/skills/struq/scripts/images.mjs +360 -0
  88. package/skills/struq/scripts/log.mjs +75 -0
  89. package/skills/struq/scripts/packs.mjs +156 -0
  90. package/skills/struq/scripts/tokens.mjs +222 -0
  91. package/skills/struq/references/anti-patterns.md +0 -265
  92. package/skills/struq/references/baseline-prompt.md +0 -118
  93. package/skills/struq/references/brand.md +0 -143
  94. package/skills/struq/references/brief-router.md +0 -219
  95. package/skills/struq/references/composition.md +0 -191
  96. package/skills/struq/references/depth-parallax.md +0 -235
  97. package/skills/struq/references/direction.md +0 -188
  98. package/skills/struq/references/feel-layer.md +0 -107
  99. package/skills/struq/references/gsap-api.md +0 -293
  100. package/skills/struq/references/imagery.md +0 -144
  101. package/skills/struq/references/loaders.md +0 -287
  102. package/skills/struq/references/micro-interactions.md +0 -270
  103. package/skills/struq/references/mobile-composition.md +0 -70
  104. package/skills/struq/references/nav-header.md +0 -296
  105. package/skills/struq/references/page-architecture.md +0 -134
  106. package/skills/struq/references/page-entrances.md +0 -198
  107. package/skills/struq/references/performance.md +0 -235
  108. package/skills/struq/references/pinning-scrub.md +0 -341
  109. package/skills/struq/references/pitfalls.md +0 -290
  110. package/skills/struq/references/route-transitions.md +0 -281
  111. package/skills/struq/references/scaling.md +0 -151
  112. package/skills/struq/references/scroll-reveals.md +0 -280
  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
@@ -0,0 +1,547 @@
1
+ "use client";
2
+ // fanned-cards (Struq section recipe)
3
+ // The offers, or a few cases, as a hand of cards that is played onto the table. The heading is the
4
+ // composition: its first line runs across the top at poster size, its italic second line stands at
5
+ // the bottom right, and the cards cross the type. The stage pins on the held opening frame: the
6
+ // cards, all one size and all photographs, fanned large in real 3D between the two lines, turned on a
7
+ // shared pivot, the hand turned and tipped back, the cards behind deeper in the room. Then the hand is
8
+ // played from the back: each card leaves the hand along an arc through space, swinging toward the lens
9
+ // and turning as it flies, and is laid down at its own scale, so the hand ends as a spread on the table:
10
+ // the lead card large in front, each card behind it smaller and partly under the one before, all of
11
+ // them laid over the first line of type. Each title rises out of its mask as its card lands, the lead
12
+ // paragraph last. The spread holds; when the pin lets go the cards are picked up off the table, each at
13
+ // its own speed and turn, while the type leaves with the page.
14
+ //
15
+ // Before the pin the first line rises out of its mask and the hand rises into the frame and opens, so
16
+ // every entrance is done before the first hold. One clock (Lenis on the GSAP ticker); every value is a
17
+ // plain number written as a transform, nothing else moves. The pin is 300%: the opening hand and the
18
+ // spread own long holds, the deal between them is one move.
19
+ //
20
+ // <FannedCards title="Vier vakken," accent="één hand." lead="..." items={[
21
+ // { image: images.column[0], title: "Interieur", text: "Binnen alles op maat, van trap tot lamp.", href: "#interieur" },
22
+ // ...three or four in all, the lead card first
23
+ // ]} />
24
+ //
25
+ // The layout in the markup is the spread, so without JavaScript and under reduced motion the section is
26
+ // that composed frame. Below 1024px it is the type, then the hand as a still fan of photographs, then
27
+ // the titles. Desktop cards lift a little on hover and focus.
28
+ import Image from "next/image";
29
+ import { useRef } from "react";
30
+ import { gsap, SplitText, useGSAP } from "../kit/gsap";
31
+ import type { StruqImage } from "../kit/images";
32
+
33
+ type Item = {
34
+ image: StruqImage;
35
+ title: string;
36
+ /** One line under the title. */
37
+ text: string;
38
+ href?: string;
39
+ };
40
+
41
+ type Props = {
42
+ /** The heading's first line, set across the top. */
43
+ title: string;
44
+ /** The second line, in the display face's italic, at the bottom right: the one accent. */
45
+ accent?: string;
46
+ /** One or two sentences; they rise in as the last card lands. */
47
+ lead?: string;
48
+ /** Three or four cards, the lead card first. */
49
+ items: Item[];
50
+ className?: string;
51
+ };
52
+
53
+ const DEG = Math.PI / 180;
54
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
55
+ const unit = (v: number) => Math.min(1, Math.max(0, v));
56
+ const move2 = gsap.parseEase("power2.inOut");
57
+ const move3 = gsap.parseEase("power3.inOut");
58
+ const outExpo = gsap.parseEase("expo.out");
59
+
60
+ /**
61
+ * The spread, in svh from the left gutter: each card's height, top and turn on the table. Each card after
62
+ * the first is laid partly under the one before (OVER of that card's width), smaller, so the hand recedes.
63
+ * The tops sit just over the first line's baseline, so every card crosses the type.
64
+ */
65
+ type Slot = { h: number; top: number; r: number };
66
+ const SPREAD: Record<3 | 4, Slot[]> = {
67
+ 4: [
68
+ { h: 60, top: 22.5, r: -2.5 },
69
+ { h: 44, top: 21, r: 2 },
70
+ { h: 35, top: 20.5, r: -1.5 },
71
+ { h: 28, top: 21.5, r: 3 },
72
+ ],
73
+ 3: [
74
+ { h: 60, top: 22.5, r: -2.5 },
75
+ { h: 45, top: 21, r: 2.5 },
76
+ { h: 34, top: 21, r: -2 },
77
+ ],
78
+ };
79
+ const OVER = 0.3;
80
+ /** Photographs are 4:5. */
81
+ const ASPECT = 0.8;
82
+
83
+ /** The hand: its card height (share of the screen), degrees between cards, the pivot below in card heights. */
84
+ const HAND = 0.62;
85
+ const SPREAD_DEG = 12;
86
+ const PIVOT = 1.45;
87
+ /** The hand turned and tipped back, in degrees; each card behind sits this much deeper. */
88
+ const TURN_Y = 30;
89
+ const TIP_X = 12;
90
+ const DEPTH = 130;
91
+ /** How far the deepest card in the hand recedes toward the ground colour. */
92
+ const SHADE = 0.32;
93
+ /** The deal: each card's share of it, how high its arc rises (share of the screen), how far toward the lens. */
94
+ const WINDOW = 0.64;
95
+ const ARC = 0.17;
96
+ const NEAR = 360;
97
+ const SWING = 24;
98
+ /** On release each card is picked up at its own speed (share of the screen) and turn. */
99
+ const LIFT = [0.55, 0.85, 1.05, 1.25];
100
+ const SPIN = [-6, 5, -7, 8];
101
+ /** The pointer turns the held hand by up to this many degrees. */
102
+ const TILT = 5;
103
+
104
+ const svh = (v: number) => `${v.toFixed(2)}svh`;
105
+
106
+ export function FannedCards({ title, accent, lead, items, className = "" }: Props) {
107
+ const stage = useRef<HTMLElement>(null);
108
+ const cards = items.slice(0, 4);
109
+ const n = cards.length;
110
+ const slots = SPREAD[n >= 4 ? 4 : 3].slice(0, n);
111
+
112
+ // The spread's geometry in svh, shared by the markup (the still) and the script (the landing places).
113
+ let x = 0;
114
+ const geo = slots.map((s, i) => {
115
+ const w = s.h * ASPECT;
116
+ const left = x;
117
+ x += w * (1 - OVER);
118
+ return { ...s, i, w, left, right: left + w, bottom: s.top + s.h };
119
+ });
120
+
121
+ useGSAP(
122
+ () => {
123
+ const el = stage.current;
124
+ if (!el) return;
125
+ const one = (s: string) => el.querySelector<HTMLElement>(s);
126
+ const all = (s: string) => Array.from(el.querySelectorAll<HTMLElement>(s));
127
+ const type = one("[data-type]");
128
+ const lineOne = one("[data-line-one]");
129
+ const lineTwo = one("[data-line-two]");
130
+ const deck = one("[data-deck]");
131
+ const leadBox = one("[data-lead]");
132
+ const rise = all("[data-rise]");
133
+ const cardEls = all("[data-card]");
134
+ const shades = cardEls.map((c) => c.querySelector<HTMLElement>("[data-shade]"));
135
+ const caps = all("[data-cap]");
136
+ const capLines = caps.map((c) => Array.from(c.querySelectorAll<HTMLElement>("[data-cap-line]")));
137
+ if (!type || !lineOne || !deck || cardEls.length === 0) return;
138
+
139
+ // The type fits its frame whatever the face: the first line within the width, the second clear of the
140
+ // lead card's title. Measured at the natural size, then scaled.
141
+ const fit = () => {
142
+ el.style.setProperty("--fit", "1");
143
+ const W = el.clientWidth;
144
+ const wide = window.matchMedia("(min-width: 1024px)").matches;
145
+ const one1 = lineOne.offsetWidth;
146
+ const two = lineTwo?.offsetWidth ?? 0;
147
+ const k = Math.min(
148
+ 1,
149
+ one1 > 0 ? (wide ? W * 0.92 : W - 48) / one1 : 1,
150
+ two > 0 ? (wide ? W * 0.64 : W - 48) / two : 1,
151
+ );
152
+ if (k < 0.995) el.style.setProperty("--fit", String(Math.floor(k * 1000) / 1000));
153
+ };
154
+ fit();
155
+ document.fonts?.ready.then(fit);
156
+ window.addEventListener("resize", fit);
157
+
158
+ const mm = gsap.matchMedia();
159
+ mm.add("(min-width: 1024px) and (prefers-reduced-motion: no-preference)", () => {
160
+ el.dataset.motion = "1";
161
+
162
+ // Layout positions, untouched by transforms: offsets walk up to the stage.
163
+ const box = (node: HTMLElement) => {
164
+ let bx = 0;
165
+ let by = 0;
166
+ let cur: HTMLElement | null = node;
167
+ while (cur && cur !== el) {
168
+ bx += cur.offsetLeft;
169
+ by += cur.offsetTop;
170
+ cur = cur.offsetParent as HTMLElement | null;
171
+ }
172
+ return { x: bx, y: by, w: node.offsetWidth, h: node.offsetHeight };
173
+ };
174
+
175
+ const g = {
176
+ W: 1,
177
+ H: 1,
178
+ hand: { x: 0, y: 0 },
179
+ radius: 0,
180
+ cards: [] as { sx: number; sy: number; k: number; a: number; r: number; start: number; dir: number }[],
181
+ };
182
+
183
+ // How far the held hand reaches down over the italic line: its lowest projected edge over that line,
184
+ // against the top of the line's accents. Read from the boxes the browser has drawn, with the hand
185
+ // set to its opening frame for the reading and put back after.
186
+ const overLineTwo = () => {
187
+ if (!lineTwo) return 0;
188
+ const er = el.getBoundingClientRect();
189
+ const lr = lineTwo.getBoundingClientRect();
190
+ const em = parseFloat(getComputedStyle(lineTwo).fontSize) || 100;
191
+ const limit = lr.top - er.top - em * 0.14;
192
+ const saved = { ...s };
193
+ Object.assign(s, { enter: 1, deal: 0, exit: 0, tx: 0, ty: 0 });
194
+ paint();
195
+ let low = -Infinity;
196
+ for (const c of cardEls) {
197
+ const r = c.getBoundingClientRect();
198
+ if (r.right > lr.left && r.left < lr.right) low = Math.max(low, r.bottom - er.top);
199
+ }
200
+ Object.assign(s, saved);
201
+ paint();
202
+ return low - limit;
203
+ };
204
+
205
+ // The hand sits between the two lines and never over the italic one, so "één" keeps its accents in
206
+ // every face: it rises first (a little more of the first line goes under it), then holds smaller.
207
+ const measure = () => {
208
+ fit();
209
+ g.W = el.clientWidth;
210
+ g.H = el.clientHeight;
211
+ layout(1, 0);
212
+ let over = overLineTwo();
213
+ if (over > 0) {
214
+ const lift = Math.min(over + 4, g.H * 0.07);
215
+ layout(1, lift);
216
+ over = overLineTwo();
217
+ if (over > 0) layout(Math.max(0.72, 1 - (over + 6) / (g.H * HAND)), lift);
218
+ }
219
+ };
220
+
221
+ const layout = (scale: number, lift: number) => {
222
+ const hh = g.H * HAND * scale;
223
+ const hw = hh * ASPECT;
224
+ const edge = ((n - 1) / 2) * SPREAD_DEG * DEG;
225
+ g.radius = hh * PIVOT;
226
+ // The hand's reach either side of its centre, a little less than flat since it is turned.
227
+ const half = 0.9 * (g.radius * Math.sin(edge) + (hw / 2) * Math.cos(edge) + (hh / 2) * Math.sin(edge));
228
+ const hx = Math.min(g.W * 0.6, g.W * 0.965 - half);
229
+ const hy = g.H * 0.5 - lift;
230
+ g.hand = { x: hx, y: hy + g.radius };
231
+ g.cards = cardEls.map((c, i) => {
232
+ const b = box(c);
233
+ const sx = b.x + b.w / 2;
234
+ const a = (i - (n - 1) / 2) * SPREAD_DEG;
235
+ const fx = g.hand.x + g.radius * Math.sin(a * DEG);
236
+ return {
237
+ sx,
238
+ sy: b.y + b.h / 2,
239
+ k: hh / Math.max(1, b.h),
240
+ a,
241
+ r: geo[i]?.r ?? 0,
242
+ // The hand is played from the back, so each card is laid down over the one before.
243
+ start: n > 1 ? ((n - 1 - i) * (1 - WINDOW)) / (n - 1) : 0,
244
+ dir: Math.sign(sx - fx) || 1,
245
+ };
246
+ });
247
+ // One shared lens, its vanishing point on the hand.
248
+ const db = box(deck);
249
+ deck.style.perspective = `${Math.round(Math.max(g.W, g.H) * 1.1)}px`;
250
+ deck.style.perspectiveOrigin = `${(hx - db.x).toFixed(1)}px ${(hy - db.y).toFixed(1)}px`;
251
+ };
252
+
253
+ // Every value the scroll and the pointer drive, as plain numbers.
254
+ const s = { enter: 0, deal: 0, exit: 0, tx: 0, ty: 0 };
255
+ let leadLines: HTMLElement[] = [];
256
+
257
+ const paint = () => {
258
+ const { H } = g;
259
+ // Before the pin: the first line rises, the hand rises into the frame and opens.
260
+ const typeIn = outExpo(unit((s.enter - 0.02) / 0.42));
261
+ for (const r of rise) r.style.transform = `translate3d(0, ${((1 - typeIn) * 112).toFixed(2)}%, 0)`;
262
+ const up = outExpo(unit((s.enter - 0.12) / 0.66));
263
+ const open = move3(unit((s.enter - 0.42) / 0.58));
264
+
265
+ for (let i = 0; i < cardEls.length; i += 1) {
266
+ const c = g.cards[i];
267
+ if (!c) continue;
268
+ // The card in the hand.
269
+ const a = c.a * open;
270
+ const x0 = g.hand.x + g.radius * Math.sin(a * DEG) + (1 - open) * i * 8;
271
+ const y0 = g.hand.y - g.radius * Math.cos(a * DEG) + (1 - up) * H * 1.0 - (1 - open) * i * 6;
272
+ const z0 = -i * DEPTH * (0.35 + 0.65 * open);
273
+ // The deal: along an arc through space, swinging toward the lens and turning as it flies.
274
+ const t = unit((s.deal - c.start) / WINDOW);
275
+ const e = move3(t);
276
+ const flat = move2(unit(t / 0.8));
277
+ const bump = Math.sin(Math.PI * e);
278
+ const xc = (x0 + c.sx) / 2;
279
+ const yc = (y0 + c.sy) / 2 - ARC * H;
280
+ const u = 1 - e;
281
+ const px = u * u * x0 + 2 * u * e * xc + e * e * c.sx;
282
+ const py = u * u * y0 + 2 * u * e * yc + e * e * c.sy;
283
+ // The release: picked up off the table, each at its own speed and turn.
284
+ const lift = s.exit * H * (LIFT[i] ?? 1);
285
+ const z = lerp(z0, 0, e) + NEAR * bump + s.exit * 140;
286
+ const ry = (-TURN_Y + s.ty) * (1 - flat) + SWING * bump * c.dir;
287
+ const rx = (TIP_X + s.tx) * (1 - flat) - 12 * bump;
288
+ const rz = lerp(a, c.r, e) + 9 * bump * c.dir + s.exit * (SPIN[i] ?? 0);
289
+ const k = lerp(c.k, 1, e);
290
+ cardEls[i].style.transform = `translate3d(${(px - c.sx).toFixed(2)}px, ${(py - c.sy - lift).toFixed(2)}px, ${z.toFixed(2)}px) rotateY(${ry.toFixed(3)}deg) rotateX(${rx.toFixed(3)}deg) rotateZ(${rz.toFixed(3)}deg) scale(${k.toFixed(4)})`;
291
+ const shade = shades[i];
292
+ if (shade) shade.style.opacity = (n > 1 ? (i / (n - 1)) * SHADE * (1 - e) : 0).toFixed(3);
293
+ // The card's title rises out of its mask as the card lands, and leaves with it.
294
+ const land = move3(unit((t - 0.7) / 0.3));
295
+ const cap = caps[i];
296
+ if (cap) cap.style.transform = `translate3d(0, ${(-lift).toFixed(2)}px, 0)`;
297
+ const ls = capLines[i] ?? [];
298
+ for (let j = 0; j < ls.length; j += 1) {
299
+ const lj = move3(unit((land - j * 0.18) / 0.82));
300
+ ls[j].style.transform = `translate3d(0, ${((1 - lj) * 112).toFixed(2)}%, 0)`;
301
+ }
302
+ }
303
+ // The lead paragraph rises line by line as the last card lands.
304
+ const m = leadLines.length;
305
+ for (let j = 0; j < m; j += 1) {
306
+ const span = m > 1 ? 0.35 / (m - 1) : 0;
307
+ const e = move3(unit((s.deal - 0.66 - j * span * 0.34) / 0.34));
308
+ leadLines[j].style.transform = `translate3d(0, ${((1 - e) * 112).toFixed(2)}%, 0)`;
309
+ }
310
+ };
311
+
312
+ const leadP = leadBox?.querySelector("p");
313
+ const split = leadP
314
+ ? SplitText.create(leadP, {
315
+ type: "lines",
316
+ mask: "lines",
317
+ linesClass: "struq-line",
318
+ autoSplit: true,
319
+ onSplit(self) {
320
+ leadLines = self.lines as HTMLElement[];
321
+ paint();
322
+ },
323
+ })
324
+ : null;
325
+
326
+ for (const c of cardEls) c.style.rotate = "none";
327
+ measure();
328
+ paint();
329
+ for (const node of [type, deck, leadBox, ...caps]) if (node) node.style.visibility = "visible";
330
+
331
+ // Before the pin: the type and the hand arrive with the scroll, done by the time it pins.
332
+ gsap.to(s, {
333
+ enter: 1,
334
+ ease: "none",
335
+ onUpdate: paint,
336
+ scrollTrigger: { trigger: el, start: "top bottom", end: "top top", scrub: 1 },
337
+ });
338
+
339
+ const tl = gsap.timeline({
340
+ defaults: { ease: "none" },
341
+ onUpdate: paint,
342
+ scrollTrigger: {
343
+ trigger: el,
344
+ start: "top top",
345
+ end: "+=300%",
346
+ pin: true,
347
+ scrub: 1,
348
+ onRefresh: () => {
349
+ measure();
350
+ paint();
351
+ },
352
+ },
353
+ });
354
+ // Hold on the open hand, the deal as one move, hold on the spread: the holds take more than the bar's
355
+ // half and three fifths of the move.
356
+ tl.to({}, { duration: 0.95 }).to(s, { deal: 1, duration: 1.65 }).to({}, { duration: 1.15 });
357
+
358
+ // The release: once the pin lets go the cards are picked up off the table.
359
+ const pinned = tl.scrollTrigger!;
360
+ gsap.to(s, {
361
+ exit: 1,
362
+ ease: "none",
363
+ onUpdate: paint,
364
+ scrollTrigger: {
365
+ start: () => pinned.end,
366
+ end: () => pinned.end + window.innerHeight,
367
+ scrub: 0.6,
368
+ },
369
+ });
370
+
371
+ // The pointer turns the held hand a little; the turn leaves each card as it is played.
372
+ const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
373
+ const toX = gsap.quickTo(s, "tx", { duration: 0.9, ease: "power3.out", onUpdate: paint });
374
+ const toY = gsap.quickTo(s, "ty", { duration: 0.9, ease: "power3.out", onUpdate: paint });
375
+ const onMove = (ev: PointerEvent) => {
376
+ toY((ev.clientX / window.innerWidth - 0.5) * 2 * TILT);
377
+ toX((ev.clientY / window.innerHeight - 0.5) * -1.4 * TILT);
378
+ };
379
+ if (fine) window.addEventListener("pointermove", onMove, { passive: true });
380
+
381
+ return () => {
382
+ window.removeEventListener("pointermove", onMove);
383
+ split?.revert();
384
+ delete el.dataset.motion;
385
+ for (const c of cardEls) {
386
+ c.style.transform = "";
387
+ c.style.rotate = "";
388
+ }
389
+ for (const sh of shades) if (sh) sh.style.opacity = "";
390
+ for (const r of rise) r.style.transform = "";
391
+ for (const cap of caps) cap.style.transform = "";
392
+ for (const ls of capLines) for (const l of ls) l.style.transform = "";
393
+ deck.style.perspective = "";
394
+ deck.style.perspectiveOrigin = "";
395
+ };
396
+ });
397
+ return () => {
398
+ window.removeEventListener("resize", fit);
399
+ mm.revert();
400
+ };
401
+ },
402
+ { scope: stage, dependencies: [n] },
403
+ );
404
+
405
+ // Hidden until the script has set the opening frame, on desktop with motion only, and shown after 3s
406
+ // whatever happens, so a failed script can never leave the section empty. The page's pre-paint class is
407
+ // `js` in a client project and `sq-js` on struq.nl.
408
+ const gate =
409
+ "lg:motion-safe:[.js_&]:invisible lg:motion-safe:[.sq-js_&]:invisible lg:motion-safe:animate-[struq-show_0s_3s_forwards]";
410
+ // Masks clip only top and bottom, so a wide face never loses the end of a word.
411
+ const mask = "struq-line-mask block overflow-x-visible overflow-y-clip";
412
+ const display =
413
+ "font-display block w-fit whitespace-nowrap text-[length:calc(var(--d)*var(--fit,1))] leading-[0.92] tracking-[-0.04em]";
414
+ // A soft shadow made from the ground itself, deeper than the ground in light and dark palettes alike.
415
+ const shadow =
416
+ "0 2px 6px oklch(from var(--surface-0) calc(l * 0.3) c h / 0.3), 0 36px 64px -28px oklch(from var(--surface-0) calc(l * 0.22) c h / 0.62)";
417
+
418
+ return (
419
+ <section
420
+ ref={stage}
421
+ data-section="fanned-cards"
422
+ aria-labelledby="fanned-cards-title"
423
+ className={`relative overflow-hidden bg-surface-0 py-16 [--d:15vw] lg:h-svh lg:py-0 lg:[--d:min(12.5vw,19.5svh)] ${className}`}
424
+ >
425
+ <h2
426
+ id="fanned-cards-title"
427
+ data-type=""
428
+ className={`relative z-0 px-6 lg:static lg:px-0 ${gate}`}
429
+ >
430
+ <span className={`${mask} lg:absolute lg:left-[4vw] lg:top-[5svh]`}>
431
+ <span data-rise="" data-line-one="" className={display}>
432
+ {title}
433
+ </span>
434
+ </span>
435
+ {accent ? (
436
+ <span className={`${mask} ml-auto w-fit lg:absolute lg:bottom-[4svh] lg:right-[4vw]`}>
437
+ <span
438
+ data-line-two=""
439
+ className={`${display} font-accent font-light italic tracking-[-0.03em]`}
440
+ >
441
+ {accent}
442
+ </span>
443
+ </span>
444
+ ) : null}
445
+ </h2>
446
+
447
+ {lead ? (
448
+ <div
449
+ data-lead=""
450
+ className={`relative mt-6 max-w-[34ch] px-6 lg:absolute lg:bottom-[calc(4svh+var(--d)*var(--fit,1)*0.92+2.2svh)] lg:right-[4vw] lg:mt-0 lg:w-[min(24vw,24ch)] lg:max-w-none lg:px-0 ${gate}`}
451
+ >
452
+ <p className="text-body text-ink-muted">{lead}</p>
453
+ </div>
454
+ ) : null}
455
+
456
+ <div
457
+ data-deck=""
458
+ className={`relative z-10 mx-auto mt-10 h-[92vw] lg:absolute lg:inset-0 lg:mt-0 lg:h-auto ${gate}`}
459
+ >
460
+ {cards.map((item, i) => {
461
+ const s = geo[i];
462
+ const fan = (i - (n - 1) / 2) * 7;
463
+ const photo = (
464
+ <span
465
+ data-face=""
466
+ className="relative block h-full w-full overflow-hidden rounded-[0.2rem] bg-surface-2 transition-[translate] duration-[650ms] ease-[cubic-bezier(0.16,1,0.3,1)] lg:group-hover/card:-translate-y-3 lg:group-focus-visible/card:-translate-y-3"
467
+ style={{ boxShadow: shadow }}
468
+ >
469
+ <Image
470
+ src={item.image.src}
471
+ alt={item.image.alt}
472
+ fill
473
+ sizes="(min-width: 1024px) 36vw, 50vw"
474
+ unoptimized={item.image.unoptimized}
475
+ className="object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.16,1,0.3,1)] lg:group-hover/card:scale-[1.04]"
476
+ />
477
+ <span data-shade="" aria-hidden className="pointer-events-none absolute inset-0 bg-surface-0 opacity-0" />
478
+ </span>
479
+ );
480
+ const shared = {
481
+ "data-card": "",
482
+ "aria-label": item.href ? item.title : undefined,
483
+ className:
484
+ "group/card absolute left-[21vw] top-[6vw] block aspect-[4/5] w-[58vw] origin-[50%_150%] rotate-[var(--fan)] outline-offset-4 lg:left-[calc(4vw+var(--x))] lg:top-[var(--y)] lg:aspect-auto lg:h-[var(--h)] lg:w-[var(--w)] lg:origin-center lg:rotate-[var(--r)]",
485
+ style: {
486
+ zIndex: n - i,
487
+ "--fan": `${fan}deg`,
488
+ "--x": svh(s.left),
489
+ "--y": svh(s.top),
490
+ "--w": svh(s.w),
491
+ "--h": svh(s.h),
492
+ "--r": `${s.r}deg`,
493
+ } as React.CSSProperties,
494
+ };
495
+ return item.href ? (
496
+ <a key={item.title} href={item.href} {...shared}>
497
+ {photo}
498
+ </a>
499
+ ) : (
500
+ <div key={item.title} {...shared}>
501
+ {photo}
502
+ </div>
503
+ );
504
+ })}
505
+ </div>
506
+
507
+ {/* The titles: a list under the fan on phones; on desktop each under its own card, the last beside it. */}
508
+ <div className="relative z-20 mt-10 grid gap-6 px-6 lg:contents">
509
+ {cards.map((item, i) => {
510
+ const s = geo[i];
511
+ const prev = geo[i - 1];
512
+ const isLast = i === n - 1 && n > 1;
513
+ const lead0 = i === 0;
514
+ const pos = isLast
515
+ ? { "--cx": svh(s.right + 2.5), "--cb": `calc(100svh - ${svh(s.bottom)})` }
516
+ : { "--cx": svh(prev ? prev.right + 1.6 : s.left), "--cy": svh(s.bottom + 1.8) };
517
+ return (
518
+ <div
519
+ key={item.title}
520
+ data-cap=""
521
+ className={`border-t border-line pt-3 lg:absolute lg:left-[calc(4vw+var(--cx))] lg:w-[min(29svh,21vw)] lg:border-0 lg:pt-0 ${
522
+ isLast ? "lg:bottom-[var(--cb)]" : "lg:top-[var(--cy)]"
523
+ } ${lead0 ? "lg:w-[min(34svh,26vw)]" : ""} ${gate}`}
524
+ style={pos as React.CSSProperties}
525
+ >
526
+ <span className={mask}>
527
+ <span
528
+ data-cap-line=""
529
+ className={`font-display block text-h3 text-ink hyphens-auto ${
530
+ lead0 ? "lg:text-[clamp(2rem,1rem_+_1.7vw,3rem)] lg:leading-[1.05]" : ""
531
+ }`}
532
+ >
533
+ {item.title}
534
+ </span>
535
+ </span>
536
+ <span className={`${mask} mt-1.5`}>
537
+ <span data-cap-line="" className="block text-small text-ink-muted">
538
+ {item.text}
539
+ </span>
540
+ </span>
541
+ </div>
542
+ );
543
+ })}
544
+ </div>
545
+ </section>
546
+ );
547
+ }