@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,661 @@
1
+ "use client";
2
+ // approach (Struq section recipe)
3
+ // How the studio works: the stages of a project from the first conversation to the handover, told as a sequence
4
+ // through layout and movement, never through numbers. Three ways to tell it:
5
+ //
6
+ // - draad: one line runs down the page and draws itself as the reader goes, its tip at a fixed height on the screen.
7
+ // The stages hang off it, alternating left and right on wide screens (all on its right on phones). When the tip
8
+ // reaches a stage, a short tick draws out from the line to it, the stage's name rises through its mask and its two
9
+ // lines of copy follow.
10
+ // - zin: the whole process as one sentence, one clause per stage, set large on a short pin. The clauses come to ink
11
+ // one after another as the reader scrolls, the rest waiting faint; under the sentence the current stage's name and
12
+ // note rise into a mask while the last one leaves upward.
13
+ // - schuif: an index you scrub. A rule runs under a large photograph with one mark per stage and a handle; drag along
14
+ // it, click a stage or use the arrow keys, and the photographs wipe across one another in register, in the direction
15
+ // of travel, while the stage's copy swaps beside them. Letting go settles on the nearest stage.
16
+ //
17
+ // <Approach variant="draad" title="Van de eerste koffie tot de sleutel." stages={[{ name: "Kennismaken", line: "...",
18
+ // text: "...", clause: "We lopen eerst samen door wat er al staat,", image: images.card[1] }, ...]} />
19
+ //
20
+ // Scroll moves are scrubbed with 0.4 s of smoothing (a fast flick never stacks states, going back reverses); the
21
+ // scrubber follows the hand in about 0.18 s and a swap overtaken mid-way turns back from where it is. Transforms are
22
+ // written in whole pixels, clips from plain numbers. Every photograph is decoded before it can show. Names and titles
23
+ // are cut into lines with the face they are read in (the cut waits for the fonts, capped).
24
+ //
25
+ // Start states wait behind the `.js` / `.sq-js` gate and a 3 s CSS fallback (blocks.css, "--- approach ---"), so a
26
+ // page whose scripts never run still shows every stage as a plain list. Reduced motion: the same states without
27
+ // travel (zin shows the whole sentence over its notes, schuif cuts between photographs).
28
+ import Image from "next/image";
29
+ import { useId, useRef, useState, type KeyboardEvent } from "react";
30
+ import { gsap, ScrollTrigger, SplitText, useGSAP } from "../kit/gsap";
31
+ import type { StruqImage } from "../kit/images";
32
+
33
+ export const APPROACH_VARIANTS = [
34
+ { id: "draad", title: "Draad" },
35
+ { id: "zin", title: "Zin" },
36
+ { id: "schuif", title: "Schuif" },
37
+ ] as const;
38
+
39
+ export type ApproachVariant = (typeof APPROACH_VARIANTS)[number]["id"];
40
+
41
+ export type ApproachStage = {
42
+ /** The stage in one or two words: its name. */
43
+ name: string;
44
+ /** What happens, in one sentence. */
45
+ line: string;
46
+ /** One or two plain sentences more. */
47
+ text: string;
48
+ /** zin: this stage's clause of the one sentence, with its own punctuation. Left out, `line` stands in. */
49
+ clause?: string;
50
+ /** schuif: the stage's photograph. */
51
+ image?: StruqImage;
52
+ };
53
+
54
+ type Props = {
55
+ variant?: ApproachVariant;
56
+ /** draad and schuif: the section's title. zin's sentence is its own title. */
57
+ title: string;
58
+ stages: ApproachStage[];
59
+ className?: string;
60
+ };
61
+
62
+ const SMOOTH = 0.4;
63
+ const FONT_CAP = 1200;
64
+ const RISE = 1.12;
65
+ const SOFT = 16;
66
+ /** draad: the height on the screen where the line's tip rides, as a share from the top. */
67
+ const TIP = 0.62;
68
+ /** zin: screens of scroll per clause, and the beat the finished sentence holds. */
69
+ const STEP = 0.5;
70
+ const HOLD = 0.4;
71
+ /** zin: how faint a clause waits. */
72
+ const FAINT = 0.2;
73
+ /** schuif: the follow while dragging, and the settle after a release or a click, in seconds. */
74
+ const FOLLOW = 0.18;
75
+ const SETTLE = 0.6;
76
+
77
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
78
+ const within = (p: number, from: number, to: number) => clamp01((p - from) / (to - from));
79
+ const out2 = gsap.parseEase("power2.out");
80
+ const out3 = gsap.parseEase("power3.out");
81
+ const inOut2 = gsap.parseEase("power2.inOut");
82
+
83
+ function windows(n: number, span: number) {
84
+ if (n <= 1) return [[0, 1]];
85
+ const step = (1 - span) / (n - 1);
86
+ return Array.from({ length: n }, (_, i) => [i * step, i * step + span]);
87
+ }
88
+
89
+ function faceReady() {
90
+ const fonts = typeof document !== "undefined" ? document.fonts?.ready : undefined;
91
+ return Promise.race([fonts ?? Promise.resolve(), new Promise((res) => window.setTimeout(res, FONT_CAP))]);
92
+ }
93
+
94
+ function decoded(img: HTMLImageElement) {
95
+ const loaded = img.complete
96
+ ? Promise.resolve()
97
+ : new Promise<void>((res) => {
98
+ img.addEventListener("load", () => res(), { once: true });
99
+ img.addEventListener("error", () => res(), { once: true });
100
+ });
101
+ return loaded.then(() => img.decode()).catch(() => undefined);
102
+ }
103
+
104
+ const px = (n: number) => `${Math.round(n)}px`;
105
+
106
+ /** Cuts every head into masked lines once the face is in; `done` runs on each cut. */
107
+ function cutHeads(heads: HTMLElement[], done: (h: number, lines: HTMLElement[]) => void) {
108
+ return heads.map((head, h) =>
109
+ SplitText.create(head, {
110
+ type: "lines",
111
+ mask: "lines",
112
+ linesClass: "struq-line",
113
+ autoSplit: true,
114
+ onSplit(self) {
115
+ done(h, self.lines as HTMLElement[]);
116
+ },
117
+ }),
118
+ );
119
+ }
120
+
121
+ /** Each line of a head rises through its mask over the head's own progress. */
122
+ function riseLines(lines: HTMLElement[], heights: number[], p: number) {
123
+ const spans = windows(lines.length, 0.66);
124
+ lines.forEach((line, i) => {
125
+ const e = out3(within(p, spans[i][0], spans[i][1]));
126
+ line.style.transform = `translate3d(0, ${px((1 - e) * (heights[i] ?? 0) * RISE)}, 0)`;
127
+ });
128
+ }
129
+
130
+ // --- draad ----------------------------------------------------------------------------------------------------------
131
+
132
+ function runDraad(el: HTMLElement, wide: boolean, reduce: boolean) {
133
+ const path = el.querySelector<HTMLElement>("[data-ap-path]");
134
+ const ink = el.querySelector<HTMLElement>("[data-ap-ink]");
135
+ const title = el.querySelector<HTMLElement>("[data-ap-title]");
136
+ const stages = [...el.querySelectorAll<HTMLElement>("[data-ap-stage]")];
137
+ if (reduce || !path || !ink) {
138
+ el.dataset.ready = "1";
139
+ return;
140
+ }
141
+ const heads = [title, ...stages.map((s) => s.querySelector<HTMLElement>("[data-ap-head]"))].filter(Boolean) as HTMLElement[];
142
+ const ticks = stages.map((s) => s.querySelector<HTMLElement>("[data-ap-tick]"));
143
+ const softs = stages.map((s) => [...s.querySelectorAll<HTMLElement>("[data-ap-soft]")]);
144
+ let alive = true;
145
+ let splits: SplitText[] = [];
146
+ const lines: HTMLElement[][] = heads.map(() => []);
147
+ const heights: number[][] = heads.map(() => []);
148
+ const prog = { title: 0, draw: 0 };
149
+ const at = stages.map(() => ({ p: 0 }));
150
+
151
+ const paint = () => {
152
+ if (!alive) return;
153
+ ink.style.clipPath = `inset(0px 0px ${((1 - prog.draw) * 100).toFixed(3)}% 0px)`;
154
+ heads.forEach((_, h) => riseLines(lines[h], heights[h], h === 0 && title ? prog.title : at[h - (title ? 1 : 0)]?.p ?? 1));
155
+ stages.forEach((_, i) => {
156
+ const p = at[i].p;
157
+ const tick = ticks[i];
158
+ if (tick) {
159
+ // On wide screens the stages on the left draw their tick leftwards, out of the line.
160
+ const e = inOut2(within(p, 0, 0.45));
161
+ const left = wide && i % 2 === 0;
162
+ const rest = ((1 - e) * 100).toFixed(3);
163
+ tick.style.clipPath = left ? `inset(0px 0px 0px ${rest}%)` : `inset(0px ${rest}% 0px 0px)`;
164
+ }
165
+ const spans = windows(softs[i].length, 0.7);
166
+ softs[i].forEach((s, j) => {
167
+ const e = out2(within(p, 0.3 + spans[j][0] * 0.7, 0.3 + spans[j][1] * 0.7));
168
+ s.style.transform = `translate3d(0, ${px((1 - e) * SOFT)}, 0)`;
169
+ s.style.opacity = e.toFixed(3);
170
+ });
171
+ });
172
+ };
173
+
174
+ const tip = `${Math.round(TIP * 100)}%`;
175
+ const scrub = (target: object, vars: gsap.TweenVars, trigger: Element, start: string, end: string) =>
176
+ gsap.to(target, { ...vars, ease: "none", onUpdate: paint, scrollTrigger: { trigger, start, end, scrub: SMOOTH } });
177
+ if (title) scrub(prog, { title: 1 }, title, "top 90%", "top 55%");
178
+ scrub(prog, { draw: 1 }, path, `top ${tip}`, `bottom ${tip}`);
179
+ // A stage's stretch starts as the tip reaches its top.
180
+ stages.forEach((s, i) => scrub(at[i], { p: 1 }, s, `top ${tip}`, `top ${Math.round(TIP * 100 - 26)}%`));
181
+
182
+ faceReady().then(() => {
183
+ if (!alive) return;
184
+ splits = cutHeads(heads, (h, l) => {
185
+ lines[h] = l;
186
+ heights[h] = l.map((n) => n.offsetHeight);
187
+ paint();
188
+ });
189
+ el.dataset.ready = "1";
190
+ });
191
+ paint();
192
+
193
+ return () => {
194
+ alive = false;
195
+ for (const s of splits) s.revert();
196
+ ink.style.clipPath = "";
197
+ for (const t of ticks) if (t) t.style.clipPath = "";
198
+ for (const s of softs.flat()) {
199
+ s.style.transform = "";
200
+ s.style.opacity = "";
201
+ }
202
+ };
203
+ }
204
+
205
+ // --- zin ------------------------------------------------------------------------------------------------------------
206
+
207
+ function runZin(el: HTMLElement, reduce: boolean) {
208
+ const stage = el.querySelector<HTMLElement>("[data-ap-pin]");
209
+ const clauses = [...el.querySelectorAll<HTMLElement>("[data-ap-clause]")];
210
+ const notes = [...el.querySelectorAll<HTMLElement>("[data-ap-note-in]")];
211
+ if (reduce || !stage || !clauses.length) {
212
+ el.dataset.ready = "1";
213
+ return;
214
+ }
215
+ // From here the notes share one cell (blocks.css), so only the current one stands under the sentence.
216
+ el.dataset.stacked = "1";
217
+ const n = clauses.length;
218
+ const length = (n - 1) * STEP + HOLD;
219
+ const prog = { p: 0 };
220
+ let alive = true;
221
+ let heights = notes.map((nt) => nt.offsetHeight);
222
+
223
+ const paint = () => {
224
+ if (!alive) return;
225
+ const s = prog.p * length;
226
+ // When clause i comes to ink: over the second half of the step before it.
227
+ const lit = clauses.map((_, i) => (i === 0 ? 1 : within(s, (i - 1) * STEP + STEP * 0.35, i * STEP)));
228
+ clauses.forEach((c, i) => {
229
+ c.style.opacity = (FAINT + (1 - FAINT) * lit[i]).toFixed(3);
230
+ });
231
+ notes.forEach((nt, i) => {
232
+ const enter = out3(lit[i]);
233
+ const leave = i + 1 < n ? inOut2(lit[i + 1]) : 0;
234
+ const h = heights[i] || 1;
235
+ nt.style.transform = `translate3d(0, ${px((1 - enter) * h * 1.05 - leave * h * 1.05)}, 0)`;
236
+ nt.style.visibility = enter > 0 && leave < 1 ? "inherit" : "hidden";
237
+ });
238
+ };
239
+
240
+ gsap.to(prog, {
241
+ p: 1,
242
+ ease: "none",
243
+ onUpdate: paint,
244
+ scrollTrigger: {
245
+ trigger: stage,
246
+ start: "top top",
247
+ end: () => `+=${Math.round(length * window.innerHeight)}`,
248
+ pin: true,
249
+ scrub: SMOOTH,
250
+ onRefresh: () => {
251
+ heights = notes.map((nt) => nt.offsetHeight);
252
+ paint();
253
+ },
254
+ },
255
+ });
256
+
257
+ faceReady().then(() => {
258
+ if (!alive) return;
259
+ heights = notes.map((nt) => nt.offsetHeight);
260
+ paint();
261
+ el.dataset.ready = "1";
262
+ ScrollTrigger.refresh();
263
+ });
264
+ paint();
265
+
266
+ return () => {
267
+ alive = false;
268
+ delete el.dataset.stacked;
269
+ for (const c of clauses) c.style.opacity = "";
270
+ for (const nt of notes) {
271
+ nt.style.transform = "";
272
+ nt.style.visibility = "";
273
+ }
274
+ };
275
+ }
276
+
277
+ // --- schuif ---------------------------------------------------------------------------------------------------------
278
+
279
+ type Scrubber = { go: (i: number) => void };
280
+
281
+ function runSchuif(el: HTMLElement, reduce: boolean, onActive: (i: number) => void, api: { current: Scrubber | null }) {
282
+ const title = el.querySelector<HTMLElement>("[data-ap-title]");
283
+ const track = el.querySelector<HTMLElement>("[data-ap-track]");
284
+ const ink = el.querySelector<HTMLElement>("[data-ap-ink]");
285
+ const handle = el.querySelector<HTMLElement>("[data-ap-handle]");
286
+ const marks = [...el.querySelectorAll<HTMLElement>("[data-ap-mark]")];
287
+ const layers = [...el.querySelectorAll<HTMLElement>("[data-ap-layer]")];
288
+ const notes = [...el.querySelectorAll<HTMLElement>("[data-ap-note]")];
289
+ const n = notes.length;
290
+ if (!track || !n) return;
291
+ el.dataset.stacked = "1";
292
+
293
+ let alive = true;
294
+ let split: SplitText[] = [];
295
+ let tLines: HTMLElement[] = [];
296
+ let tHeights: number[] = [];
297
+ const prog = { title: reduce ? 1 : 0 };
298
+ const state = { q: 0 };
299
+ const shown = layers.map(() => ({ v: 0 }));
300
+ const vis = notes.map((_, i) => ({ v: i === 0 ? 1 : 0 }));
301
+ let active = 0;
302
+ // The marks' centres along the track, in px from its left edge.
303
+ let xs: number[] = [];
304
+ const measure = () => {
305
+ const tr = track.getBoundingClientRect();
306
+ xs = marks.map((m) => {
307
+ const r = m.getBoundingClientRect();
308
+ return r.left + r.width / 2 - tr.left;
309
+ });
310
+ };
311
+
312
+ const xAt = (q: number) => {
313
+ if (!xs.length) return 0;
314
+ const i = Math.min(xs.length - 2, Math.max(0, Math.floor(q)));
315
+ if (xs.length === 1) return xs[0];
316
+ return xs[i] + (xs[i + 1] - xs[i]) * (q - i);
317
+ };
318
+ const qAt = (x: number) => {
319
+ if (xs.length < 2) return 0;
320
+ if (x <= xs[0]) return 0;
321
+ for (let i = 0; i < xs.length - 1; i += 1) {
322
+ if (x <= xs[i + 1]) return i + (x - xs[i]) / (xs[i + 1] - xs[i] || 1);
323
+ }
324
+ return xs.length - 1;
325
+ };
326
+
327
+ const paint = () => {
328
+ if (!alive) return;
329
+ riseLines(tLines, tHeights, prog.title);
330
+ const q = state.q;
331
+ // Layer i opens from the right edge as q travels from i - 1 to i: the next photograph wipes in with the hand.
332
+ layers.forEach((layer, i) => {
333
+ const f = i === 0 ? 1 : clamp01(q - (i - 1));
334
+ layer.style.clipPath = `inset(0% 0% 0% ${((1 - f) * 100).toFixed(3)}%)`;
335
+ layer.style.opacity = shown[i].v.toFixed(3);
336
+ });
337
+ const x = xAt(q);
338
+ if (handle) handle.style.transform = `translate3d(${px(x - 1)}, 0, 0)`;
339
+ if (ink) ink.style.clipPath = `inset(0px ${Math.max(0, track.offsetWidth - x).toFixed(1)}px 0px 0px)`;
340
+ notes.forEach((nt, i) => {
341
+ const v = vis[i].v;
342
+ nt.style.opacity = v.toFixed(3);
343
+ nt.style.transform = `translate3d(0, ${px((1 - v) * SOFT)}, 0)`;
344
+ nt.style.visibility = v > 0.001 ? "inherit" : "hidden";
345
+ });
346
+ };
347
+
348
+ const setActive = (i: number) => {
349
+ if (i === active) return;
350
+ active = i;
351
+ onActive(i);
352
+ // The outgoing copy leaves quickly, the incoming one settles; a swap overtaken mid-way turns back from where it is.
353
+ vis.forEach((v, j) => {
354
+ const on = j === i;
355
+ gsap.to(v, {
356
+ v: on ? 1 : 0,
357
+ duration: reduce ? 0.15 : on ? 0.55 : 0.22,
358
+ delay: on && !reduce ? 0.08 : 0,
359
+ ease: on ? "power3.out" : "power2.in",
360
+ overwrite: true,
361
+ onUpdate: paint,
362
+ });
363
+ });
364
+ };
365
+
366
+ const moveTo = (q: number, duration: number, ease: string) => {
367
+ gsap.to(state, {
368
+ q,
369
+ duration,
370
+ ease,
371
+ overwrite: true,
372
+ onUpdate: () => {
373
+ setActive(Math.round(state.q));
374
+ paint();
375
+ },
376
+ });
377
+ };
378
+
379
+ api.current = {
380
+ go: (i: number) => moveTo(Math.min(n - 1, Math.max(0, i)), reduce ? 0 : SETTLE, "power3.inOut"),
381
+ };
382
+
383
+ // The hand on the rule: a press that moves more than a few pixels becomes a drag, which the scrubber follows; letting
384
+ // go settles on the nearest stage. A press that stays put is a click, left to the stage's own button.
385
+ let pressed = false;
386
+ let dragging = false;
387
+ let startX = 0;
388
+ let target = 0;
389
+ const fromEvent = (e: PointerEvent) => qAt(e.clientX - track.getBoundingClientRect().left);
390
+ const down = (e: PointerEvent) => {
391
+ if (e.button !== 0) return;
392
+ pressed = true;
393
+ dragging = false;
394
+ startX = e.clientX;
395
+ measure();
396
+ };
397
+ const move = (e: PointerEvent) => {
398
+ if (!pressed) return;
399
+ if (!dragging) {
400
+ if (Math.abs(e.clientX - startX) < 5) return;
401
+ dragging = true;
402
+ track.setPointerCapture(e.pointerId);
403
+ }
404
+ target = fromEvent(e);
405
+ moveTo(target, FOLLOW, "power2.out");
406
+ };
407
+ const up = (e: PointerEvent) => {
408
+ pressed = false;
409
+ if (!dragging) return;
410
+ dragging = false;
411
+ if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId);
412
+ moveTo(Math.round(target), reduce ? 0.15 : 0.45, "power3.out");
413
+ };
414
+ track.addEventListener("pointerdown", down);
415
+ track.addEventListener("pointermove", move);
416
+ track.addEventListener("pointerup", up);
417
+ track.addEventListener("pointercancel", up);
418
+
419
+ const remeasure = () => {
420
+ measure();
421
+ paint();
422
+ };
423
+ ScrollTrigger.addEventListener("refresh", remeasure);
424
+ window.addEventListener("resize", remeasure);
425
+ if (title && !reduce) {
426
+ gsap.to(prog, { title: 1, ease: "none", onUpdate: paint, scrollTrigger: { trigger: title, start: "top 90%", end: "top 55%", scrub: SMOOTH } });
427
+ }
428
+
429
+ // Every photograph is decoded before it can show; the first fades in once it is.
430
+ layers.forEach((layer, i) => {
431
+ const img = layer.querySelector("img");
432
+ if (!img) {
433
+ shown[i].v = 1;
434
+ return;
435
+ }
436
+ img.loading = "eager";
437
+ decoded(img).then(() => {
438
+ if (!alive) return;
439
+ gsap.to(shown[i], { v: 1, duration: reduce ? 0 : 0.45, ease: "power2.out", onUpdate: paint });
440
+ });
441
+ });
442
+
443
+ faceReady().then(() => {
444
+ if (!alive) return;
445
+ measure();
446
+ if (title && !reduce) {
447
+ split = cutHeads([title], (_, l) => {
448
+ tLines = l;
449
+ tHeights = l.map((x) => x.offsetHeight);
450
+ paint();
451
+ });
452
+ }
453
+ paint();
454
+ el.dataset.ready = "1";
455
+ });
456
+ measure();
457
+ paint();
458
+
459
+ return () => {
460
+ alive = false;
461
+ api.current = null;
462
+ track.removeEventListener("pointerdown", down);
463
+ track.removeEventListener("pointermove", move);
464
+ track.removeEventListener("pointerup", up);
465
+ track.removeEventListener("pointercancel", up);
466
+ ScrollTrigger.removeEventListener("refresh", remeasure);
467
+ window.removeEventListener("resize", remeasure);
468
+ gsap.killTweensOf(state);
469
+ for (const v of [...vis, ...shown]) gsap.killTweensOf(v);
470
+ for (const s of split) s.revert();
471
+ delete el.dataset.stacked;
472
+ for (const node of [...layers, ...notes]) {
473
+ node.style.clipPath = "";
474
+ node.style.opacity = "";
475
+ node.style.transform = "";
476
+ node.style.visibility = "";
477
+ }
478
+ if (handle) handle.style.transform = "";
479
+ if (ink) ink.style.clipPath = "";
480
+ };
481
+ }
482
+
483
+ // --- the block ------------------------------------------------------------------------------------------------------
484
+
485
+ const DISPLAY = "font-display leading-[1.04] tracking-[-0.022em]";
486
+ const TITLE = `${DISPLAY} max-w-[16ch] text-[clamp(2.25rem,1rem+3.4vw,5.25rem)]`;
487
+
488
+ export function Approach({ variant = "draad", title, stages, className = "" }: Props) {
489
+ const root = useRef<HTMLElement>(null);
490
+ const api = useRef<Scrubber | null>(null);
491
+ const [active, setActive] = useState(0);
492
+ const uid = useId();
493
+
494
+ useGSAP(
495
+ () => {
496
+ const el = root.current;
497
+ if (!el) return;
498
+ const mm = gsap.matchMedia();
499
+ // Every side of every query is named, so the moves run on phones as well as on wide screens.
500
+ mm.add(
501
+ {
502
+ wide: "(min-width: 768px)",
503
+ reduce: "(prefers-reduced-motion: reduce)",
504
+ motion: "(prefers-reduced-motion: no-preference)",
505
+ },
506
+ (ctx) => {
507
+ const { wide, reduce } = ctx.conditions as { wide: boolean; reduce: boolean };
508
+ if (variant === "zin") return runZin(el, reduce);
509
+ if (variant === "schuif") {
510
+ setActive(0);
511
+ return runSchuif(el, reduce, setActive, api);
512
+ }
513
+ return runDraad(el, wide, reduce);
514
+ },
515
+ );
516
+ return () => mm.revert();
517
+ },
518
+ { scope: root, dependencies: [variant] },
519
+ );
520
+
521
+ if (variant === "zin") {
522
+ return (
523
+ <section ref={root} data-approach="zin" className={`relative bg-surface-0 text-ink ${className}`}>
524
+ <div data-ap-pin="" className="flex min-h-svh flex-col justify-center px-6 py-[10svh] md:px-[8vw]">
525
+ <h2 className="font-display max-w-[30ch] text-[clamp(1.75rem,min(0.8rem+2.4vw,6.4svh),4rem)] leading-[1.12] tracking-[-0.018em]">
526
+ {stages.map((s, i) => (
527
+ <span key={i} data-ap-clause="">
528
+ {s.clause ?? s.line}
529
+ {i < stages.length - 1 ? " " : ""}
530
+ </span>
531
+ ))}
532
+ </h2>
533
+ <div data-ap-notes="" className="mt-[7svh] grid gap-8 md:mt-[9svh] md:max-w-[46rem]">
534
+ {stages.map((s, i) => (
535
+ <div key={i} data-ap-note="" className="overflow-hidden">
536
+ <div data-ap-note-in="" className="grid gap-2 md:grid-cols-[11rem_1fr] md:gap-x-8">
537
+ <h3 className="text-ink">{s.name}</h3>
538
+ <p className="max-w-[46ch] text-small text-ink-muted">{s.text}</p>
539
+ </div>
540
+ </div>
541
+ ))}
542
+ </div>
543
+ </div>
544
+ </section>
545
+ );
546
+ }
547
+
548
+ if (variant === "schuif") {
549
+ const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
550
+ const keys: Record<string, number> = { ArrowRight: active + 1, ArrowDown: active + 1, ArrowLeft: active - 1, ArrowUp: active - 1, Home: 0, End: stages.length - 1 };
551
+ if (!(e.key in keys)) return;
552
+ e.preventDefault();
553
+ const next = Math.min(stages.length - 1, Math.max(0, keys[e.key]));
554
+ api.current?.go(next);
555
+ setActive(next);
556
+ e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]?.focus();
557
+ };
558
+ return (
559
+ <section ref={root} data-approach="schuif" className={`relative bg-surface-0 px-6 py-[14svh] text-ink md:px-[4vw] ${className}`}>
560
+ <h2 data-ap-title="" className={TITLE}>
561
+ {title}
562
+ </h2>
563
+ <div className="mt-[7svh] grid gap-y-8 md:mt-[9svh] md:grid-cols-12 md:gap-x-[2vw]">
564
+ {/* Wide, the frame's height is set by the screen, so the photograph and the rule read in one view. */}
565
+ <div className="relative aspect-[4/5] overflow-hidden md:col-span-7 md:aspect-auto md:h-[min(56svh,36vw)] md:min-h-[18rem]">
566
+ {stages.map((s, i) => (
567
+ <div key={i} data-ap-layer="" className="absolute inset-0">
568
+ {s.image ? (
569
+ <Image src={s.image.src} alt={s.image.alt} fill sizes="(min-width: 768px) 58vw, 100vw" unoptimized={s.image.unoptimized} className="object-cover" />
570
+ ) : null}
571
+ </div>
572
+ ))}
573
+ </div>
574
+ <div data-ap-notes="" className="grid content-end gap-10 md:col-span-4 md:col-start-9">
575
+ {stages.map((s, i) => (
576
+ <div
577
+ key={i}
578
+ data-ap-note=""
579
+ role="tabpanel"
580
+ id={`${uid}-p${i}`}
581
+ aria-labelledby={`${uid}-t${i}`}
582
+ aria-hidden={i !== active}
583
+ >
584
+ <h3 className="font-display text-[clamp(1.75rem,1rem+1.8vw,3rem)] leading-[1.05] tracking-[-0.018em]">{s.name}</h3>
585
+ <p className="mt-4 max-w-[34ch] text-lead">{s.line}</p>
586
+ <p className="mt-3 max-w-[40ch] text-small text-ink-muted">{s.text}</p>
587
+ </div>
588
+ ))}
589
+ </div>
590
+ </div>
591
+ {/* The rule: drag along it, or use its stages as tabs. */}
592
+ <div data-ap-track="" className="relative mt-[6svh] cursor-ew-resize touch-pan-y select-none md:mt-[5svh]">
593
+ <span aria-hidden="true" className="absolute inset-x-0 top-[22px] h-px bg-line" />
594
+ <span aria-hidden="true" data-ap-ink="" className="absolute inset-x-0 top-[22px] h-px bg-ink" />
595
+ <span aria-hidden="true" data-ap-handle="" className="absolute left-0 top-[11px] h-[23px] w-[2px] bg-accent" />
596
+ <div role="tablist" aria-label={title} onKeyDown={onKey} className="relative flex justify-between">
597
+ {stages.map((s, i) => {
598
+ const last = i === stages.length - 1;
599
+ return (
600
+ <button
601
+ key={i}
602
+ type="button"
603
+ role="tab"
604
+ id={`${uid}-t${i}`}
605
+ aria-controls={`${uid}-p${i}`}
606
+ aria-selected={i === active}
607
+ tabIndex={i === active ? 0 : -1}
608
+ onClick={() => {
609
+ api.current?.go(i);
610
+ setActive(i);
611
+ }}
612
+ className={`flex min-h-11 min-w-11 cursor-pointer flex-col pb-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent ${last ? "items-end text-right" : "items-start text-left"}`}
613
+ >
614
+ <span aria-hidden="true" data-ap-mark="" className="mt-[17px] block h-[11px] w-px bg-ink" />
615
+ <span className={`sr-only text-small md:not-sr-only md:mt-4 ${i === active ? "text-ink" : "text-ink-muted"}`}>{s.name}</span>
616
+ </button>
617
+ );
618
+ })}
619
+ </div>
620
+ </div>
621
+ </section>
622
+ );
623
+ }
624
+
625
+ return (
626
+ <section ref={root} data-approach="draad" className={`relative bg-surface-0 px-6 py-[14svh] text-ink md:px-[4vw] md:py-[18svh] ${className}`}>
627
+ <h2 data-ap-title="" className={TITLE}>
628
+ {title}
629
+ </h2>
630
+ <div data-ap-path="" className="relative mt-[10svh] md:mt-[14svh]">
631
+ <span aria-hidden="true" className="absolute bottom-0 left-0 top-0 w-px bg-line md:left-1/2" />
632
+ <span aria-hidden="true" data-ap-ink="" className="absolute bottom-0 left-0 top-0 w-px bg-ink md:left-1/2" />
633
+ <ol className="relative">
634
+ {stages.map((s, i) => {
635
+ const left = i % 2 === 0;
636
+ return (
637
+ <li key={i} data-ap-stage="" className="relative grid pb-[9svh] pl-8 last:pb-0 md:grid-cols-2 md:pb-[13svh] md:pl-0">
638
+ <span
639
+ aria-hidden="true"
640
+ data-ap-tick=""
641
+ className={`absolute left-0 top-[clamp(1.05rem,0.525rem+1.37vw,2.1rem)] h-px w-6 bg-ink md:w-[3vw] ${left ? "md:left-[calc(50%-3vw)]" : "md:left-1/2"}`}
642
+ />
643
+ <div className={left ? "md:col-start-1 md:pr-[4vw] md:text-right" : "md:col-start-2 md:pl-[4vw]"}>
644
+ <h3 data-ap-head="" className="font-display text-[clamp(2rem,1rem+2.6vw,4rem)] leading-[1.05] tracking-[-0.02em]">
645
+ {s.name}
646
+ </h3>
647
+ <p data-ap-soft="" className={`mt-5 max-w-[30ch] text-lead ${left ? "md:ml-auto" : ""}`}>
648
+ {s.line}
649
+ </p>
650
+ <p data-ap-soft="" className={`mt-3 max-w-[40ch] text-small text-ink-muted ${left ? "md:ml-auto" : ""}`}>
651
+ {s.text}
652
+ </p>
653
+ </div>
654
+ </li>
655
+ );
656
+ })}
657
+ </ol>
658
+ </div>
659
+ </section>
660
+ );
661
+ }