@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,416 @@
1
+ "use client";
2
+ // reveals (Struq section recipe)
3
+ // How a section enters as you scroll. The section itself stays plain and editorial (a statement of two or three
4
+ // lines, a short paragraph, one photograph, one text link) so the entrance is what carries it. Three entrances:
5
+ //
6
+ // - lines: the statement's lines rise through their masks one after another as the section arrives, the paragraph
7
+ // and the link follow in a softer rise, and then the photograph opens from its bottom edge while it settles a
8
+ // little upward inside its frame.
9
+ // - clip: the photograph leads. It opens by registration from a thin horizontal slit in its own final frame (a layer
10
+ // already at full size whose clip opens, never a scale), and the type rises beside it once it is half open.
11
+ // - card: the whole section slides up over the one before it like a card laid on a table, a hairline shadow on its top
12
+ // edge; the section underneath dims and falls behind (it moves at half the scroll's speed) as it is covered, and the
13
+ // content settles into the card as it lands.
14
+ //
15
+ // <Reveals variant="lines" statement="Een huis begint bij het licht dat er al is." text="..." image={images.column[0]}
16
+ // link={{ href: "#studio", label: "Over de studio" }} />
17
+ //
18
+ // Every entrance is scrubbed to the scroll over a short stretch with 0.4 s of smoothing: a fast flick never stacks
19
+ // states, scrolling back runs it backwards, and nothing is a timed tween fired by a toggle. Transforms are written in
20
+ // whole pixels and every clip from plain numbers. The photograph is decoded before any of it can show; until then its
21
+ // clip stays shut. The statement is cut into lines with the display face it is read in (the cut waits for the fonts,
22
+ // capped, and SplitText cuts again when a face lands or the width changes).
23
+ //
24
+ // Start states wait behind the `.js` / `.sq-js` gate and a 3 s CSS fallback (blocks.css, "--- reveals ---"), so a
25
+ // page whose scripts never run still shows the composed section. Reduced motion: the composed section, no travel.
26
+ // The card variant takes the element before it as the section it covers; that one is held only when it is at least a
27
+ // screen tall, so a short one never opens a gap above itself.
28
+ import Image from "next/image";
29
+ import { useId, useRef } from "react";
30
+ import { gsap, ScrollTrigger, SplitText, useGSAP } from "../kit/gsap";
31
+ import type { StruqImage } from "../kit/images";
32
+
33
+ export const REVEALS_VARIANTS = [
34
+ { id: "lines", title: "Regels" },
35
+ { id: "clip", title: "Spleet" },
36
+ { id: "card", title: "Kaart" },
37
+ ] as const;
38
+
39
+ export type RevealsVariant = (typeof REVEALS_VARIANTS)[number]["id"];
40
+
41
+ type Props = {
42
+ variant?: RevealsVariant;
43
+ /** The statement: one sentence that sets in two or three lines. Plain text. */
44
+ statement: string;
45
+ /** A short paragraph, two sentences. */
46
+ text: string;
47
+ image: StruqImage;
48
+ link: { href: string; label: string };
49
+ className?: string;
50
+ };
51
+
52
+ /** Seconds of smoothing on every scrub: a camera settling, never a delay. */
53
+ const SMOOTH = 0.4;
54
+ /** The longest the line cut waits for the display face, in ms. */
55
+ const FONT_CAP = 1200;
56
+ /** A line travels this share of its own height to its place. */
57
+ const RISE = 1.12;
58
+ /** The soft rise of the paragraph and the link, in px. */
59
+ const SOFT = 28;
60
+ /** The clip variant's slit: the share of the frame shut above and below it, and at each side. */
61
+ const SLIT_Y = 49.6;
62
+ const SLIT_X = 7;
63
+ /** The card variant: how dark the covered section gets, and the share of the scroll it still follows. */
64
+ /** How far the covered section dims under the card: lighter on paper (or it turns to a grey slab), deeper on a dark
65
+ * ground (or it does not show at all). */
66
+ const dimFor = (el: HTMLElement) => {
67
+ const g = getComputedStyle(el).getPropertyValue("--surface-0").trim();
68
+ if (!/^#[0-9a-f]{6}$/i.test(g)) return 0.22;
69
+ const ch = (i: number) => parseInt(g.slice(i, i + 2), 16);
70
+ return ch(1) * 0.299 + ch(3) * 0.587 + ch(5) * 0.114 < 128 ? 0.5 : 0.22;
71
+ };
72
+ const HOLD = 0.5;
73
+
74
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
75
+ /** Where p sits inside the window [from, to], as 0 to 1. */
76
+ const within = (p: number, from: number, to: number) => clamp01((p - from) / (to - from));
77
+ const out3 = gsap.parseEase("power3.out");
78
+ const inOut2 = gsap.parseEase("power2.inOut");
79
+ const inOut3 = gsap.parseEase("power3.inOut");
80
+ const out2 = gsap.parseEase("power2.out");
81
+
82
+ /** Resolves once the photograph is loaded and decoded (or failed), never before. */
83
+ function decoded(img: HTMLImageElement) {
84
+ const loaded = img.complete
85
+ ? Promise.resolve()
86
+ : new Promise<void>((res) => {
87
+ img.addEventListener("load", () => res(), { once: true });
88
+ img.addEventListener("error", () => res(), { once: true });
89
+ });
90
+ return loaded.then(() => img.decode()).catch(() => undefined);
91
+ }
92
+
93
+ /** The display face, waited for at most FONT_CAP ms. */
94
+ function faceReady() {
95
+ const fonts = typeof document !== "undefined" ? document.fonts?.ready : undefined;
96
+ return Promise.race([fonts ?? Promise.resolve(), new Promise((res) => window.setTimeout(res, FONT_CAP))]);
97
+ }
98
+
99
+ /** Staggered windows for n items inside 0 to 1: each runs `span`, the last one ends at 1. */
100
+ function windows(n: number, span: number) {
101
+ if (n <= 1) return [[0, 1]];
102
+ const step = (1 - span) / (n - 1);
103
+ return Array.from({ length: n }, (_, i) => [i * step, i * step + span]);
104
+ }
105
+
106
+ export function Reveals({ variant = "lines", statement, text, image, link, className = "" }: Props) {
107
+ const root = useRef<HTMLElement>(null);
108
+ const titleId = useId();
109
+
110
+ useGSAP(
111
+ () => {
112
+ const el = root.current;
113
+ if (!el) return;
114
+ const head = el.querySelector<HTMLElement>("[data-rv-type]");
115
+ const copy = el.querySelector<HTMLElement>("[data-rv-copy]");
116
+ const softs = Array.from(el.querySelectorAll<HTMLElement>("[data-rv-soft]"));
117
+ const settles = Array.from(el.querySelectorAll<HTMLElement>("[data-rv-settle]"));
118
+ const frame = el.querySelector<HTMLElement>("[data-rv-frame]");
119
+ const pic = el.querySelector<HTMLElement>("[data-rv-pic]");
120
+ const img = frame?.querySelector<HTMLImageElement>("img") ?? null;
121
+ const shade = el.querySelector<HTMLElement>("[data-rv-shade]");
122
+ const kind = (el.dataset.reveals ?? "lines") as RevealsVariant;
123
+ const dim = dimFor(el);
124
+
125
+ const mm = gsap.matchMedia();
126
+ mm.add({ wide: "(min-width: 768px)", reduce: "(prefers-reduced-motion: reduce)" }, (ctx) => {
127
+ const { wide, reduce } = ctx.conditions as { wide: boolean; reduce: boolean };
128
+ // Reduced motion: the composed section as it stands; the CSS start states only apply with motion.
129
+ if (reduce) {
130
+ el.dataset.ready = "1";
131
+ return;
132
+ }
133
+
134
+ let alive = true;
135
+ let split: SplitText | null = null;
136
+ let lines: HTMLElement[] = [];
137
+ let heights: number[] = [];
138
+
139
+ // Progress of each scrubbed stretch, 0 to 1, and the photograph's decode gate (0 until it is decoded).
140
+ const prog = { type: 0, soft: 0, photo: 0, slide: 0 };
141
+ const settle = settles.map(() => ({ p: 0 }));
142
+ const gate = { v: 0 };
143
+
144
+ // The card variant's covered section and the measures it needs, taken again on every refresh.
145
+ const prev = kind === "card" ? (el.previousElementSibling as HTMLElement | null) : null;
146
+ const prevTransform = prev?.style.transform ?? "";
147
+ let vh = window.innerHeight;
148
+ let hold = 0;
149
+ let frameH = frame?.offsetHeight ?? 0;
150
+ const measure = () => {
151
+ vh = window.innerHeight;
152
+ hold = prev && prev.offsetHeight >= vh ? HOLD : 0;
153
+ frameH = frame?.offsetHeight ?? 0;
154
+ };
155
+ measure();
156
+
157
+ const paintType = () => {
158
+ const spans = windows(lines.length, 0.62);
159
+ lines.forEach((line, i) => {
160
+ const e = out3(within(prog.type, spans[i][0], spans[i][1]));
161
+ line.style.transform = `translate3d(0, ${Math.round((1 - e) * (heights[i] ?? 0) * RISE)}px, 0)`;
162
+ });
163
+ };
164
+
165
+ const paintSoft = () => {
166
+ const spans = windows(softs.length, 0.78);
167
+ softs.forEach((s, i) => {
168
+ const e = out2(within(prog.soft, spans[i][0], spans[i][1]));
169
+ s.style.transform = `translate3d(0, ${Math.round((1 - e) * SOFT)}px, 0)`;
170
+ s.style.opacity = e.toFixed(3);
171
+ });
172
+ };
173
+
174
+ const paintPhoto = () => {
175
+ if (!frame) return;
176
+ const p = prog.photo * gate.v;
177
+ if (kind === "lines") {
178
+ const e = inOut2(p);
179
+ frame.style.clipPath = `inset(${((1 - e) * 100).toFixed(3)}% 0% 0% 0%)`;
180
+ if (pic) pic.style.transform = `translate3d(0, ${Math.round((1 - out3(p)) * frameH * 0.08)}px, 0)`;
181
+ } else if (kind === "clip") {
182
+ const y = SLIT_Y * (1 - inOut3(p));
183
+ const x = SLIT_X * (1 - out2(within(p, 0, 0.7)));
184
+ frame.style.clipPath = `inset(${y.toFixed(3)}% ${x.toFixed(3)}% ${y.toFixed(3)}% ${x.toFixed(3)}%)`;
185
+ } else if (pic) {
186
+ pic.style.opacity = gate.v.toFixed(3);
187
+ }
188
+ };
189
+
190
+ const paintSlide = () => {
191
+ if (shade) shade.style.opacity = (dim * out2(prog.slide)).toFixed(3);
192
+ if (prev) prev.style.transform = hold ? `translate3d(0, ${Math.round(prog.slide * hold * vh)}px, 0)` : prevTransform;
193
+ };
194
+
195
+ const paintSettle = () => {
196
+ // On wide screens every settle shares the card's landing, staggered; on phones each has its own stretch.
197
+ const spans = wide ? windows(settles.length, 0.7) : settles.map(() => [0, 1]);
198
+ settles.forEach((s, i) => {
199
+ const e = out3(within(settle[i].p, spans[i][0], spans[i][1]));
200
+ s.style.transform = `translate3d(0, ${Math.round((1 - e) * (40 + 20 * i))}px, 0)`;
201
+ s.style.opacity = e.toFixed(3);
202
+ });
203
+ };
204
+
205
+ const paint = () => {
206
+ if (!alive) return;
207
+ if (kind !== "card") {
208
+ paintType();
209
+ paintSoft();
210
+ } else {
211
+ paintSlide();
212
+ paintSettle();
213
+ }
214
+ paintPhoto();
215
+ };
216
+
217
+ const scrub = (target: object, vars: gsap.TweenVars, trigger: Element | null, start: string, end: string) => {
218
+ if (!trigger) return;
219
+ gsap.to(target, { ...vars, ease: "none", onUpdate: paint, scrollTrigger: { trigger, start, end, scrub: SMOOTH } });
220
+ };
221
+
222
+ if (kind === "lines") {
223
+ scrub(prog, { type: 1 }, head, "top 88%", "top 50%");
224
+ scrub(prog, { soft: 1 }, copy, "top 94%", "top 64%");
225
+ // The photograph starts opening from its bottom edge just before that edge is on screen, so it overlaps the
226
+ // paragraph rising instead of waiting for the type to land.
227
+ scrub(prog, { photo: 1 }, frame, "bottom 108%", "bottom 58%");
228
+ } else if (kind === "clip") {
229
+ scrub(prog, { photo: 1 }, frame, "center 92%", "center 48%");
230
+ if (wide) {
231
+ // Beside the photograph: the type takes its cue from the frame, so it rises once the slit is half open.
232
+ scrub(prog, { type: 1 }, frame, "center 70%", "center 36%");
233
+ scrub(prog, { soft: 1 }, frame, "center 62%", "center 28%");
234
+ } else {
235
+ scrub(prog, { type: 1 }, head, "top 90%", "top 58%");
236
+ scrub(prog, { soft: 1 }, copy, "top 94%", "top 66%");
237
+ }
238
+ } else {
239
+ scrub(prog, { slide: 1 }, el, "top bottom", "top top");
240
+ settles.forEach((s, i) => {
241
+ if (wide) scrub(settle[i], { p: 1 }, el, "top 55%", "top top");
242
+ else scrub(settle[i], { p: 1 }, s, "top 96%", "top 68%");
243
+ });
244
+ }
245
+ if (prev && hold) prev.style.willChange = "transform";
246
+ const remeasure = () => {
247
+ measure();
248
+ paint();
249
+ };
250
+ ScrollTrigger.addEventListener("refresh", remeasure);
251
+
252
+ // The photograph is decoded before any of it shows. If the scroll is already past its stretch when it lands,
253
+ // the gate opens it over a moment instead of in one frame. The image loads now, not lazily at the edge.
254
+ if (img) {
255
+ img.loading = "eager";
256
+ decoded(img).then(() => {
257
+ if (!alive) return;
258
+ gsap.to(gate, { v: 1, duration: 0.45, ease: "power2.out", onUpdate: paint });
259
+ });
260
+ } else {
261
+ gate.v = 1;
262
+ }
263
+
264
+ // The statement is cut with the face it is read in. Until then it stays hidden (the CSS gate), and the section
265
+ // is marked ready only once every start state is written.
266
+ if (kind !== "card" && head) {
267
+ faceReady().then(() => {
268
+ if (!alive) return;
269
+ split = SplitText.create(head, {
270
+ type: "lines",
271
+ mask: "lines",
272
+ linesClass: "struq-line",
273
+ autoSplit: true,
274
+ onSplit(self) {
275
+ lines = self.lines as HTMLElement[];
276
+ heights = lines.map((l) => l.offsetHeight);
277
+ paint();
278
+ },
279
+ });
280
+ el.dataset.ready = "1";
281
+ });
282
+ paint();
283
+ } else {
284
+ paint();
285
+ el.dataset.ready = "1";
286
+ }
287
+
288
+ return () => {
289
+ alive = false;
290
+ ScrollTrigger.removeEventListener("refresh", remeasure);
291
+ gsap.killTweensOf(gate);
292
+ split?.revert();
293
+ for (const s of [...softs, ...settles]) {
294
+ s.style.transform = "";
295
+ s.style.opacity = "";
296
+ }
297
+ if (frame) frame.style.clipPath = "";
298
+ if (pic) {
299
+ pic.style.transform = "";
300
+ pic.style.opacity = "";
301
+ }
302
+ if (shade) shade.style.opacity = "";
303
+ if (prev) {
304
+ prev.style.transform = prevTransform;
305
+ prev.style.willChange = "";
306
+ }
307
+ };
308
+ });
309
+ return () => mm.revert();
310
+ },
311
+ { scope: root, dependencies: [variant] },
312
+ );
313
+
314
+ const photo = (sizes: string, frameClass: string) => (
315
+ <div data-rv-frame="" className={`relative overflow-hidden ${frameClass}`}>
316
+ <div data-rv-pic="" className="absolute inset-0">
317
+ <Image src={image.src} alt={image.alt} fill sizes={sizes} unoptimized={image.unoptimized} className="object-cover" />
318
+ </div>
319
+ </div>
320
+ );
321
+
322
+ const textLink = (
323
+ <a
324
+ href={link.href}
325
+ className="inline-block text-body text-ink underline decoration-1 underline-offset-[0.3em] transition-colors duration-200 hover:text-ink-muted"
326
+ >
327
+ {link.label}
328
+ </a>
329
+ );
330
+
331
+ if (variant === "card") {
332
+ return (
333
+ <section
334
+ ref={root}
335
+ data-reveals="card"
336
+ aria-labelledby={titleId}
337
+ className={`relative z-[2] flex min-h-svh flex-col bg-surface-0 px-6 py-20 text-ink md:px-[4vw] md:py-[12svh] ${className}`}
338
+ >
339
+ {/* The dark that falls over the section being covered: it hangs above the card's top edge and travels with it. */}
340
+ <div data-rv-shade="" aria-hidden className="pointer-events-none absolute inset-x-0 bottom-full h-svh" />
341
+ <h2 id={titleId} data-rv-settle="" className="font-display max-w-[17ch] text-h2 text-ink">
342
+ {statement}
343
+ </h2>
344
+ <div className="mt-12 grid gap-10 md:mt-auto md:grid-cols-12 md:items-end md:gap-x-[2vw] md:pt-[8svh]">
345
+ <div data-rv-settle="" className="md:col-span-5">
346
+ {photo("(min-width: 768px) 40vw, 100vw", "aspect-[3/2] w-full md:aspect-auto md:h-[44svh]")}
347
+ </div>
348
+ <div data-rv-settle="" className="md:col-span-4 md:col-start-8">
349
+ <p className="text-lead text-ink-muted">{text}</p>
350
+ <div className="mt-6">{textLink}</div>
351
+ </div>
352
+ </div>
353
+ </section>
354
+ );
355
+ }
356
+
357
+ if (variant === "clip") {
358
+ return (
359
+ <section
360
+ ref={root}
361
+ data-reveals="clip"
362
+ aria-labelledby={titleId}
363
+ className={`relative bg-surface-0 px-6 py-20 text-ink md:px-[4vw] md:py-[14svh] ${className}`}
364
+ >
365
+ <div className="grid gap-10 md:grid-cols-12 md:items-center md:gap-x-[2vw]">
366
+ <div className="md:col-span-6">{photo("(min-width: 768px) 50vw, 100vw", "aspect-[4/5] w-full md:aspect-auto md:h-[76svh]")}</div>
367
+ <div className="md:col-span-5 md:col-start-8">
368
+ <h2
369
+ id={titleId}
370
+ data-rv-type=""
371
+ className="font-display max-w-[15ch] text-[clamp(2.25rem,1.1rem+2.6vw,3.75rem)] leading-[1.06] tracking-[-0.015em] text-ink"
372
+ >
373
+ {statement}
374
+ </h2>
375
+ <div data-rv-copy="" className="mt-8">
376
+ <p data-rv-soft="" className="text-lead text-ink-muted">
377
+ {text}
378
+ </p>
379
+ <div data-rv-soft="" className="mt-6">
380
+ {textLink}
381
+ </div>
382
+ </div>
383
+ </div>
384
+ </div>
385
+ </section>
386
+ );
387
+ }
388
+
389
+ return (
390
+ <section
391
+ ref={root}
392
+ data-reveals="lines"
393
+ aria-labelledby={titleId}
394
+ className={`relative bg-surface-0 px-6 py-20 text-ink md:px-[4vw] md:py-[16svh] ${className}`}
395
+ >
396
+ <div className="grid gap-12 md:grid-cols-12 md:gap-x-[2vw]">
397
+ <div className="md:col-span-7">
398
+ <h2 id={titleId} data-rv-type="" className="font-display max-w-[16ch] text-h2 text-ink">
399
+ {statement}
400
+ </h2>
401
+ <div data-rv-copy="" className="mt-10 max-w-[36ch] md:mt-[8svh]">
402
+ <p data-rv-soft="" className="text-lead text-ink-muted">
403
+ {text}
404
+ </p>
405
+ <div data-rv-soft="" className="mt-6">
406
+ {textLink}
407
+ </div>
408
+ </div>
409
+ </div>
410
+ <div className="md:col-span-4 md:col-start-9">
411
+ {photo("(min-width: 768px) 32vw, 100vw", "aspect-[4/5] w-full md:aspect-auto md:h-[66svh]")}
412
+ </div>
413
+ </div>
414
+ </section>
415
+ );
416
+ }