@struq/skill 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -36
- package/package.json +4 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -0,0 +1,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
|
+
}
|