@struq/skill 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +23 -35
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +148 -212
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/blocks/kit/Cursor.tsx +117 -0
  19. package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
  20. package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
  21. package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
  22. package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
  23. package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
  24. package/skills/struq/blocks/kit/Reveal.tsx +64 -0
  25. package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
  26. package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/gsap.ts +21 -0
  29. package/skills/struq/blocks/kit/images.ts +17 -0
  30. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  31. package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
  32. package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
  33. package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
  34. package/skills/struq/blocks/kit/scenes/types.ts +114 -0
  35. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  36. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  37. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  38. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  39. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  40. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  41. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  42. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  43. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  44. package/skills/struq/blocks/sections/approach.tsx +661 -0
  45. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  46. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  47. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  48. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  49. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  50. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  51. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  52. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  53. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  54. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  55. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  56. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  57. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  58. package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
  59. package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
  60. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
  61. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  62. package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
  63. package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
  64. package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
  65. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  66. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
  67. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  68. package/skills/struq/canon/canon.json +627 -0
  69. package/skills/struq/cinematic/README.md +7 -18
  70. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  71. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  72. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  73. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  74. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  75. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  76. package/skills/struq/recipes/app/fonts.ts +12 -0
  77. package/skills/struq/recipes/app/globals.css +42 -0
  78. package/skills/struq/recipes/app/layout.tsx +30 -0
  79. package/skills/struq/references/craft.md +77 -0
  80. package/skills/struq/references/iconography.md +28 -230
  81. package/skills/struq/references/stack-targets.md +23 -74
  82. package/skills/struq/references/tells.md +52 -0
  83. package/skills/struq/references/triage.md +6 -7
  84. package/skills/struq/scripts/blocks.mjs +156 -0
  85. package/skills/struq/scripts/canon.mjs +134 -0
  86. package/skills/struq/scripts/check.mjs +513 -0
  87. package/skills/struq/scripts/images.mjs +360 -0
  88. package/skills/struq/scripts/log.mjs +75 -0
  89. package/skills/struq/scripts/packs.mjs +156 -0
  90. package/skills/struq/scripts/tokens.mjs +222 -0
  91. package/skills/struq/references/anti-patterns.md +0 -265
  92. package/skills/struq/references/baseline-prompt.md +0 -118
  93. package/skills/struq/references/brand.md +0 -143
  94. package/skills/struq/references/brief-router.md +0 -219
  95. package/skills/struq/references/composition.md +0 -191
  96. package/skills/struq/references/depth-parallax.md +0 -235
  97. package/skills/struq/references/direction.md +0 -188
  98. package/skills/struq/references/feel-layer.md +0 -107
  99. package/skills/struq/references/gsap-api.md +0 -293
  100. package/skills/struq/references/imagery.md +0 -144
  101. package/skills/struq/references/loaders.md +0 -287
  102. package/skills/struq/references/micro-interactions.md +0 -270
  103. package/skills/struq/references/mobile-composition.md +0 -70
  104. package/skills/struq/references/nav-header.md +0 -296
  105. package/skills/struq/references/page-architecture.md +0 -134
  106. package/skills/struq/references/page-entrances.md +0 -198
  107. package/skills/struq/references/performance.md +0 -235
  108. package/skills/struq/references/pinning-scrub.md +0 -341
  109. package/skills/struq/references/pitfalls.md +0 -290
  110. package/skills/struq/references/route-transitions.md +0 -281
  111. package/skills/struq/references/scaling.md +0 -151
  112. package/skills/struq/references/scroll-reveals.md +0 -280
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
@@ -0,0 +1,613 @@
1
+ "use client";
2
+ // zoom-through (Struq transition recipe)
3
+ // A scene change inside a page: the camera pushes into a photo until that photo is the next
4
+ // section's ground. The stage pins on a composed frame: the display line on the ground, a short
5
+ // lead, and the photo in an upright window on the other side with its caption and four corner
6
+ // marks. It holds. Then the camera travels toward the window. Everything on the ground is nearer
7
+ // the lens than the photo, so it scales faster and falls away past the edges (the lead fastest,
8
+ // the display line next, the caption with the wall), while the window opens until all four of its
9
+ // edges leave the screen together: the camera arrives, it does not wipe. The photo sits deeper than
10
+ // the window: it grows far less than the frame, eases out of a small push-in and settles at full
11
+ // bleed, its subject held on the same point throughout. Then the next section stands in it: a strip
12
+ // of the page's ground rises from the bottom edge with the first line, and the heading, at the same
13
+ // display size as the line the camera left, rises out of masks on the photo's calm side. It holds,
14
+ // and the pin lets go with the photo drifting slower than the page, which continues from the strip.
15
+ //
16
+ // The camera is one number (`e`, 0 to 1). The window, the photo, every layer of type and the
17
+ // marks are all derived from it on every frame, about one fixed point (the window's centre), so
18
+ // the photo stays registered on its subject. The window's clip is written as pixel insets from
19
+ // plain numbers, never tweened as a string. The pin is 300%: every key frame holds.
20
+ // Lead for an architect, a hotel, a studio: any brief whose photo is a place to walk into.
21
+ //
22
+ // <ZoomThrough image={images.hero[1]} title="Licht" accent="eerst." lead="..." caption="Patiohuis, Alentejo"
23
+ // heading={["Naar", "binnen."]} line="We tekenen eerst het licht, daarna pas de muren." />
24
+ //
25
+ // The window goes over the photo's subject: by default on the side opposite the photo's calm side
26
+ // (`negativeSpace`), so the copy of both scenes stands where the photo is quiet. Pass `side` when the
27
+ // beeldbank's calm side is wrong for the photo.
28
+ //
29
+ // Contrast, on every path. The small line always sits on the ground strip, in ink on the page's
30
+ // own ground. The heading stands on the photo only where the photo earns it: once the photo has
31
+ // loaded, the block reads its pixels under the heading, low and then high on the calm side, then
32
+ // low and high on the window's side (the beeldbank's calm side can be wrong), and keeps the first
33
+ // place where a dark ink (derived from the ground) or the palette's on-photo ink holds 4.5:1 over
34
+ // the median and 3:1 over 95 in 100 pixels. Where none does, or the photo cannot be read (another
35
+ // origin without CORS), each heading line gets a band of ground running out to the calm edge, stepped
36
+ // on top of the strip: still display size, still in the photo's frame. That stepped frame is also the
37
+ // first paint, so nothing waits on a script to be legible. `headingInk` skips the reading when the
38
+ // author knows the photo. No scrim, no veil, no fixed black or white.
39
+ //
40
+ // Phones and reduced motion: the composed still. The lead and the display line on the ground,
41
+ // then the photo full bleed with the heading standing in it over the same ground strip.
42
+ import Image from "next/image";
43
+ import { useRef, useState } from "react";
44
+ import { gsap, useGSAP } from "../kit/gsap";
45
+ import type { StruqImage } from "../kit/images";
46
+ import { photoLuminances } from "../kit/photo-read";
47
+
48
+ type Side = "left" | "right";
49
+ type Ink = "auto" | "light" | "dark" | "ground";
50
+
51
+ type Props = {
52
+ image: StruqImage;
53
+ /** The composed frame's display word or words, the first line. */
54
+ title: string;
55
+ /** The second line, in the display face's italic: the one accent. */
56
+ accent?: string;
57
+ /** One or two sentences on the ground, small against the display line. */
58
+ lead?: string;
59
+ /** Metadata for the photo: under the window, and on the ground strip at the end. */
60
+ caption?: string;
61
+ /** The next section's heading, one short entry per line: it is set at display size. */
62
+ heading: string[];
63
+ /** The next section's first line, on the ground strip. */
64
+ line?: string;
65
+ /** Where the copy of both scenes stands. Defaults to the photo's calm side; the window takes the other. */
66
+ side?: Side;
67
+ /**
68
+ * The heading's ink over the photo. "auto" (the default) reads the photo; "light" or "dark" is the author's
69
+ * word that the photo is dark or bright under the heading; "ground" always sets it on the ground strip.
70
+ */
71
+ headingInk?: Ink;
72
+ className?: string;
73
+ };
74
+
75
+ /** The photo's push-in inside the window at the start; it eases to 1 as the camera arrives. */
76
+ const PUSH = 1.3;
77
+ /** Sharpness of the soft limit that stops the photo growing once the window clears the screen. */
78
+ const KNEE = 6;
79
+ const soft = (x: number) => Math.pow(Math.pow(x, -KNEE) + 1, -1 / KNEE);
80
+ /** The camera position at which all four window edges reach the screen edges, together; they carry on past them. */
81
+ const CROSS = 0.93;
82
+ /** How near the lens each layer of the composed frame sits (1 is the wall the window is cut into). */
83
+ const DEPTH = { caption: 1, title: 0.97, lead: 0.94 };
84
+ /** The window's centre on the screen, as a share of the stage: over the photo's subject. */
85
+ const AIM_Y = 46;
86
+ /** Half the window's width and height. Upright 4:5, at most 52% of the screen high and 30% wide. */
87
+ const HALF_W = "min(20.8svh, 15vw)";
88
+ const HALF_H = "min(26svh, 18.75vw)";
89
+
90
+ const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
91
+ const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
92
+
93
+ /**
94
+ * Which ink the photo allows under one box: "dark" (the ground-derived dark, luminance under 0.012), "light"
95
+ * (the palette's on-photo ink, taken at a cautious 0.8), "none" when neither holds 4.5:1 over the median and
96
+ * 3:1 over 95 in 100 pixels, and null when the pixels cannot be read.
97
+ */
98
+ function readInk(img: HTMLImageElement, box: { x: number; y: number; w: number; h: number }, W: number, H: number) {
99
+ if (!W || !H) return null;
100
+ const ys = photoLuminances(img, { x: 0, y: 0, w: W, h: H }, box);
101
+ if (!ys) return null;
102
+ const at = (q: number) => ys[Math.min(ys.length - 1, Math.floor(ys.length * q))];
103
+ // Dark ink: (Y + 0.05) / 0.062 is 4.5 at Y 0.229 and 3 at Y 0.136.
104
+ if (at(0.5) >= 0.229 && at(0.05) >= 0.136) return "dark";
105
+ // Light ink: 0.85 / (Y + 0.05) is 4.5 at Y 0.139 and 3 at Y 0.233.
106
+ if (at(0.5) <= 0.139 && at(0.95) <= 0.233) return "light";
107
+ return "none";
108
+ }
109
+
110
+ export function ZoomThrough({
111
+ image,
112
+ title,
113
+ accent,
114
+ lead,
115
+ caption,
116
+ heading,
117
+ line,
118
+ side,
119
+ headingInk = "auto",
120
+ className = "",
121
+ }: Props) {
122
+ const stage = useRef<HTMLElement>(null);
123
+ // Set when the photo is calmer under the heading on the window's side than on its own (the beeldbank's calm side
124
+ // can be wrong): the arrived copy then stands on that side.
125
+ const [flip, setFlip] = useState(false);
126
+ const flipNow = useRef(false);
127
+ const copySide: Side = side ?? (image.negativeSpace === "right" ? "right" : "left");
128
+ const aimX = copySide === "left" ? 70 : 30;
129
+ // The first paint is always legible: the heading on the ground strip unless the author vouches for the photo.
130
+ const firstPlace = headingInk === "light" || headingInk === "dark" ? "low" : "ground";
131
+ const firstInk = headingInk === "dark" ? "dark" : "light";
132
+
133
+ // The composed frame, laid out in CSS so the first paint is already the first frame.
134
+ const win = {
135
+ left: `calc(${aimX}% - ${HALF_W})`,
136
+ top: `calc(${AIM_Y}% - ${HALF_H})`,
137
+ width: `calc(2 * ${HALF_W})`,
138
+ height: `calc(2 * ${HALF_H})`,
139
+ };
140
+ const startClip = `inset(calc(${AIM_Y}% - ${HALF_H}) calc(${100 - aimX}% - ${HALF_W}) calc(${100 - AIM_Y}% - ${HALF_H}) calc(${aimX}% - ${HALF_W}))`;
141
+ const origin = `${aimX}% ${AIM_Y}%`;
142
+ const startScale = "scale(0.73)";
143
+
144
+ useGSAP(
145
+ () => {
146
+ const el = stage.current;
147
+ if (!el) return;
148
+ // Scripts run: the pinned stage replaces the still (CSS reads this, or the layout's pre-paint class).
149
+ el.dataset.live = "";
150
+ const one = <T extends HTMLElement = HTMLElement>(s: string) => el.querySelector<T>(s);
151
+ const tree = one("[data-tree]");
152
+ const windowBox = one("[data-window]");
153
+ const clip = one("[data-clip]");
154
+ const drift = one("[data-drift]");
155
+ const plane = one("[data-plane]");
156
+ const copy = one("[data-copy]");
157
+ const titleBox = one("[data-title]");
158
+ const stillTitle = one("[data-still-title]");
159
+ const still = one("[data-still-photo]");
160
+ if (!tree || !windowBox || !clip || !drift || !plane || !copy || !titleBox || !stillTitle || !still) return;
161
+ const layers = Array.from(el.querySelectorAll<HTMLElement>("[data-depth]"));
162
+ const marks = Array.from(el.querySelectorAll<HTMLElement>("[data-mark]"));
163
+
164
+ // Layout positions, untouched by transforms: offsets walk up to `top`.
165
+ const box = (n: HTMLElement, top: HTMLElement) => {
166
+ let x = 0;
167
+ let y = 0;
168
+ let cur: HTMLElement | null = n;
169
+ while (cur && cur !== top) {
170
+ x += cur.offsetLeft;
171
+ y += cur.offsetTop;
172
+ cur = cur.offsetParent as HTMLElement | null;
173
+ }
174
+ return { x, y, w: n.offsetWidth, h: n.offsetHeight };
175
+ };
176
+
177
+ // Fit: the display line never runs into the window or past the edge, whatever the face; the arrived
178
+ // heading takes the same size and fits the calm half of the photo.
179
+ const fit = () => {
180
+ const set = (name: string, room: number, lines: HTMLElement[]) => {
181
+ const need = Math.max(0, ...lines.map((n) => n.offsetWidth));
182
+ if (need > 0) el.style.setProperty(name, String(Math.floor(Math.min(1, room / need) * 1000) / 1000));
183
+ };
184
+ const all = (root: HTMLElement, s: string) => Array.from(root.querySelectorAll<HTMLElement>(s));
185
+ for (const v of ["--zt-fit", "--zt-sfit", "--zt-hfit"]) el.style.setProperty(v, "1");
186
+ if (tree.offsetParent) {
187
+ const W = tree.clientWidth;
188
+ const wb = box(windowBox, tree);
189
+ set("--zt-fit", copySide === "left" ? wb.x - W * 0.09 : W - (wb.x + wb.w) - W * 0.09, all(titleBox, "[data-line]"));
190
+ set("--zt-hfit", W * 0.44, all(copy, "[data-hline]"));
191
+ } else {
192
+ set("--zt-sfit", stillTitle.clientWidth, all(stillTitle, "[data-line]"));
193
+ const W = still.clientWidth;
194
+ set("--zt-hfit", W >= 768 ? W * 0.44 : W - 48, all(still, "[data-hline]"));
195
+ }
196
+ };
197
+
198
+ // Where the heading stands, read from whichever photo is on screen: low on the calm side, then high, else
199
+ // on the ground strip. In the pinned stage the heading is outside the window until the push, so the choice
200
+ // never shows. The pixels come from a plain CORS copy of the loaded file: a srcset image's natural size is
201
+ // density-corrected, and a photo from another origin without CORS cannot be read at all (it stays on ground).
202
+ let probe: HTMLImageElement | null = null;
203
+ let alive = true;
204
+ let onPlace = () => {};
205
+ const tone = () => {
206
+ if (headingInk !== "auto") return;
207
+ const motion = tree.offsetParent !== null;
208
+ const img = (motion ? plane : still).querySelector("img");
209
+ const src = img?.currentSrc || img?.src;
210
+ if (!img || !src || !img.complete) return;
211
+ if (probe?.src !== new URL(src, location.href).href || !probe.complete) {
212
+ const next = document.createElement("img");
213
+ next.crossOrigin = "anonymous";
214
+ next.decoding = "async";
215
+ next.src = src;
216
+ probe = next;
217
+ next.decode().then(
218
+ () => alive && probe === next && tone(),
219
+ () => undefined,
220
+ );
221
+ return;
222
+ }
223
+ const host = motion ? tree : still;
224
+ const head = (motion ? copy : still).querySelector<HTMLElement>("[data-head]");
225
+ if (!head) return;
226
+ // `other`: the heading mirrored onto the window's side of the photo (the layout is symmetric, so its box
227
+ // is the mirror of the measured one).
228
+ const read = (place: "low" | "high", other: boolean) => {
229
+ el.dataset.place = place;
230
+ const W = host.clientWidth;
231
+ const b = Array.from(head.querySelectorAll<HTMLElement>("[data-hline]")).map((n) => {
232
+ const p = box(n, host);
233
+ return other !== flipNow.current ? { ...p, x: W - p.x - p.w } : p;
234
+ });
235
+ if (!b.length) return null;
236
+ const x0 = Math.min(...b.map((p) => p.x));
237
+ const y0 = Math.min(...b.map((p) => p.y));
238
+ const x1 = Math.max(...b.map((p) => p.x + p.w));
239
+ const y1 = Math.max(...b.map((p) => p.y + p.h));
240
+ const pad = 16;
241
+ return readInk(probe!, { x: x0 - pad, y: y0 - pad, w: x1 - x0 + pad * 2, h: y1 - y0 + pad * 2 }, W, host.clientHeight);
242
+ };
243
+ // The calm side low, then high; then the other side; only then the ground bands.
244
+ let place = "ground";
245
+ let ink = "light";
246
+ let other = false;
247
+ for (const [p, o] of [["low", false], ["high", false], ["low", true], ["high", true]] as const) {
248
+ const r = read(p, o);
249
+ if (r === "dark" || r === "light") {
250
+ place = p;
251
+ ink = r;
252
+ other = o;
253
+ break;
254
+ }
255
+ }
256
+ el.dataset.place = place;
257
+ el.dataset.ink = ink;
258
+ flipNow.current = other;
259
+ setFlip(other);
260
+ onPlace();
261
+ };
262
+ const imgs = Array.from(el.querySelectorAll("img"));
263
+ for (const img of imgs) img.addEventListener("load", tone);
264
+
265
+ const settle = () => {
266
+ fit();
267
+ tone();
268
+ };
269
+ settle();
270
+ document.fonts?.ready.then(settle);
271
+ window.addEventListener("resize", settle);
272
+
273
+ const mm = gsap.matchMedia();
274
+ mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
275
+ const strip = copy.querySelector<HTMLElement>("[data-strip]");
276
+ const headWrap = copy.querySelector<HTMLElement>("[data-head]");
277
+ const g = {
278
+ W: 1, H: 1, px: 0, py: 0, hw: 1, hh: 1,
279
+ // Per side (left, right, top, bottom): the window's start reach from the centre, the screen's, their ratio.
280
+ h0: [1, 1, 1, 1], d: [1, 1, 1, 1], r: [1, 1, 1, 1],
281
+ R: 1, Kc: 1, slab: 0,
282
+ };
283
+ const slab = () => {
284
+ const ground = el.dataset.place === "ground";
285
+ g.slab = (strip?.offsetHeight ?? 0) + (ground && headWrap ? headWrap.offsetHeight : 0);
286
+ };
287
+ const measure = () => {
288
+ fit();
289
+ g.W = tree.clientWidth;
290
+ g.H = tree.clientHeight;
291
+ const b = box(windowBox, tree);
292
+ g.hw = b.w / 2;
293
+ g.hh = b.h / 2;
294
+ g.px = b.x + g.hw;
295
+ g.py = b.y + g.hh;
296
+ g.h0 = [g.hw, g.hw, g.hh, g.hh];
297
+ g.d = [g.px, g.W - g.px, g.py, g.H - g.py];
298
+ g.r = g.h0.map((h, i) => h / g.d[i]);
299
+ g.R = Math.max(...g.r);
300
+ // The camera's own travel, for the type on the wall: as far as the window's farthest edge goes.
301
+ g.Kc = Math.pow(1 / Math.min(...g.r), 1 / CROSS);
302
+ slab();
303
+ const o = `${g.px.toFixed(1)}px ${g.py.toFixed(1)}px`;
304
+ for (const n of [plane, copy, ...layers]) n.style.transformOrigin = o;
305
+ };
306
+
307
+ // The camera (0 at the composed frame, 1 when the photo is the ground) and the ground strip's rise.
308
+ const s = { e: 0, strip: 0 };
309
+ const paint = () => {
310
+ // Each edge of the window opens from its own start to just past its own screen edge, on the same
311
+ // exponential curve, so all four leave the screen together however far off centre the window sits.
312
+ const reach = g.h0.map((h, i) => h * Math.pow(g.d[i] / h, s.e / CROSS));
313
+ const l = g.px - reach[0];
314
+ const r = g.px + reach[1];
315
+ const t = g.py - reach[2];
316
+ const b = g.py + reach[3];
317
+ const px = (v: number) => `${Math.max(0, v).toFixed(2)}px`;
318
+ clip.style.clipPath = `inset(${px(t)} ${px(g.W - r)} ${px(g.H - b)} ${px(l)})`;
319
+ // The photo: it grows less than the window, settles out of its push-in, and is exactly full bleed when
320
+ // the camera arrives. It always covers what the window shows.
321
+ const push = 1 + (PUSH - 1) * Math.pow(1 - s.e, 1.2);
322
+ const base = Math.pow(g.R, 1 - s.e / CROSS);
323
+ let sig = (soft(base) / soft(Math.pow(g.R, 1 - 1 / CROSS))) * push;
324
+ for (let i = 0; i < 4; i += 1) sig = Math.max(sig, Math.min(reach[i] / g.d[i], 1));
325
+ const ps = `scale(${sig.toFixed(5)})`;
326
+ plane.style.transform = ps;
327
+ copy.style.transform = ps;
328
+ // The composed frame, nearer the lens: each layer scales as its distance closes, and is put away
329
+ // once it is past the edges.
330
+ const m = 1 - 1 / Math.pow(g.Kc, s.e);
331
+ for (const n of layers) {
332
+ const dd = Number(n.dataset.depth) || 1;
333
+ const k = dd / Math.max(dd - m, 0.001);
334
+ n.style.transform = `scale(${k.toFixed(4)})`;
335
+ n.style.visibility = k > 14 ? "hidden" : "";
336
+ }
337
+ // The marks ride out on the window's corners.
338
+ for (const n of marks) {
339
+ const at = n.dataset.mark ?? "";
340
+ const x = at.includes("l") ? g.hw - reach[0] : reach[1] - g.hw;
341
+ const y = at.includes("t") ? g.hh - reach[2] : reach[3] - g.hh;
342
+ n.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0)`;
343
+ }
344
+ // The ground strip (and the heading's ground, when it stands there) rises from the bottom edge.
345
+ const lift = `translate3d(0, ${((1 - s.strip) * g.slab).toFixed(2)}px, 0)`;
346
+ if (strip) strip.style.transform = lift;
347
+ if (headWrap) headWrap.style.transform = el.dataset.place === "ground" ? lift : "";
348
+ };
349
+ onPlace = () => {
350
+ slab();
351
+ paint();
352
+ };
353
+
354
+ measure();
355
+ paint();
356
+
357
+ const heads = Array.from(copy.querySelectorAll<HTMLElement>("[data-rise]"));
358
+ const after = Array.from(copy.querySelectorAll<HTMLElement>("[data-after]"));
359
+ const PUSH_AT = 1.2;
360
+ const PUSH_FOR = 2.4;
361
+ const tl = gsap.timeline({
362
+ defaults: { ease: "none" },
363
+ onUpdate: paint,
364
+ scrollTrigger: {
365
+ trigger: el,
366
+ start: "top top",
367
+ end: "+=300%",
368
+ pin: true,
369
+ scrub: 1,
370
+ onRefresh: () => {
371
+ measure();
372
+ paint();
373
+ },
374
+ },
375
+ });
376
+ // Hold on the composed frame: half the push.
377
+ tl.to({}, { duration: PUSH_AT })
378
+ // The push: the composed frame falls away, the window opens past every edge at once, the photo settles.
379
+ .to(s, { e: 1, duration: PUSH_FOR, ease: "power2.inOut" })
380
+ // As the last edges leave, the ground strip rises with the line, and the heading stands up in the photo.
381
+ .to(s, { strip: 1, duration: 0.9, ease: "power3.inOut" }, PUSH_AT + PUSH_FOR * 0.8)
382
+ .from(heads, { yPercent: 118, duration: 0.95, stagger: 0.09, ease: "expo.out" }, PUSH_AT + PUSH_FOR * 0.84)
383
+ .from(after, { yPercent: 118, duration: 0.95, stagger: 0.06, ease: "expo.out" }, "<0.2")
384
+ // Hold on the arrived scene (more than half the push); the pin then lets it go.
385
+ .to({}, { duration: 1.5 });
386
+
387
+ // Depth on the way in and out: the photo lags the page as the stage arrives and as it leaves.
388
+ const pin = tl.scrollTrigger!;
389
+ gsap.fromTo(
390
+ drift,
391
+ { yPercent: -6 },
392
+ { yPercent: 0, ease: "none", scrollTrigger: { trigger: el, start: "top bottom", end: "top top", scrub: true } },
393
+ );
394
+ gsap.fromTo(
395
+ drift,
396
+ { yPercent: 0 },
397
+ {
398
+ yPercent: 32,
399
+ ease: "none",
400
+ immediateRender: false,
401
+ scrollTrigger: { start: () => pin.end, end: () => pin.end + el.offsetHeight, scrub: true },
402
+ },
403
+ );
404
+
405
+ return () => {
406
+ onPlace = () => {};
407
+ clip.style.clipPath = startClip;
408
+ for (const n of [plane, copy]) {
409
+ n.style.transform = startScale;
410
+ n.style.transformOrigin = origin;
411
+ }
412
+ for (const n of layers) {
413
+ n.style.transform = "";
414
+ n.style.visibility = "";
415
+ n.style.transformOrigin = "";
416
+ }
417
+ for (const n of [...marks, strip, headWrap]) if (n) n.style.transform = "";
418
+ };
419
+ });
420
+
421
+ return () => {
422
+ alive = false;
423
+ delete el.dataset.live;
424
+ window.removeEventListener("resize", settle);
425
+ for (const img of imgs) img.removeEventListener("load", tone);
426
+ mm.revert();
427
+ };
428
+ },
429
+ { scope: stage, dependencies: [copySide, headingInk] },
430
+ );
431
+
432
+ const right = copySide === "right";
433
+ const display = `font-display block w-fit whitespace-nowrap leading-[0.86] tracking-[-0.04em] [word-spacing:0.08em]`;
434
+ const italic = "font-accent font-light italic tracking-[-0.02em]";
435
+
436
+ const photo = () => (
437
+ <>
438
+ <Image
439
+ src={image.src}
440
+ alt={image.alt}
441
+ fill
442
+ sizes="100vw"
443
+ unoptimized={image.unoptimized}
444
+ className="object-cover [filter:saturate(0.9)_contrast(1.06)]"
445
+ />
446
+ {/* The grade: shadows lifted toward the page's own ground. */}
447
+ <div aria-hidden className="absolute inset-0 bg-surface-0 opacity-[0.07] mix-blend-screen" />
448
+ </>
449
+ );
450
+
451
+ // The arrived scene, the same in the pinned stage and the still: the heading over the photo and the strip with
452
+ // the line and the caption. `motion` adds the masks the heading and line rise from. Where the photo cannot carry
453
+ // the heading, each heading line gets its own band of ground running out to the calm edge, stepped on top of
454
+ // the strip: one cut-paper shape of the page's ground set into the photo, the heading still at display size and
455
+ // still in the photo's frame, never a block of copy under a picture.
456
+ const headRight = right !== flip;
457
+ const band = headRight
458
+ ? "group-data-[place=ground]/zt:-mr-6 group-data-[place=ground]/zt:pr-6 md:group-data-[place=ground]/zt:-mr-[5vw] md:group-data-[place=ground]/zt:pr-[5vw] group-data-[place=ground]/zt:pl-[0.2em]"
459
+ : "group-data-[place=ground]/zt:-ml-6 group-data-[place=ground]/zt:pl-6 md:group-data-[place=ground]/zt:-ml-[5vw] md:group-data-[place=ground]/zt:pl-[5vw] group-data-[place=ground]/zt:pr-[0.2em]";
460
+ const arrived = (motion: boolean, size: string) => (
461
+ <div className="absolute inset-0 flex flex-col justify-end">
462
+ <div
463
+ data-head=""
464
+ className={`px-6 pb-[3svh] md:px-[5vw] text-on-photo group-data-[ink=dark]/zt:text-[var(--zt-dark)] group-data-[place=high]/zt:absolute group-data-[place=high]/zt:inset-x-0 group-data-[place=high]/zt:top-[11svh] group-data-[place=ground]/zt:pb-0 group-data-[place=ground]/zt:text-ink`}
465
+ >
466
+ <h2 className={`font-display ${size} leading-[0.88] tracking-[-0.04em] [word-spacing:0.08em]`}>
467
+ {heading.map((h) => (
468
+ <span
469
+ key={h}
470
+ className={`-mx-6 flex px-6 md:-mx-[5vw] md:px-[5vw] ${headRight ? "justify-end" : "justify-start"} ${
471
+ motion ? "overflow-hidden pb-[0.1em] -mb-[0.1em] group-data-[place=ground]/zt:pb-0 group-data-[place=ground]/zt:mb-0" : ""
472
+ }`}
473
+ >
474
+ <span className={`block group-data-[place=ground]/zt:bg-surface-0 group-data-[place=ground]/zt:pb-[0.12em] group-data-[place=ground]/zt:pt-[0.08em] ${band}`}>
475
+ <span data-rise={motion ? "" : undefined} data-hline="" className="block whitespace-nowrap">
476
+ {h}
477
+ </span>
478
+ </span>
479
+ </span>
480
+ ))}
481
+ </h2>
482
+ </div>
483
+ {line || caption ? (
484
+ <div
485
+ data-strip=""
486
+ className={`flex flex-col gap-3 bg-surface-0 px-6 py-6 md:flex-row md:items-end md:justify-between md:gap-10 md:px-[5vw] md:py-[3.4svh] ${
487
+ headRight ? "items-end text-right md:flex-row-reverse" : ""
488
+ }`}
489
+ >
490
+ {line ? (
491
+ <span className={`block max-w-[38ch] ${motion ? "overflow-hidden" : ""}`}>
492
+ <span data-after={motion ? "" : undefined} className="block text-balance text-lead text-ink">
493
+ {line}
494
+ </span>
495
+ </span>
496
+ ) : null}
497
+ {caption ? (
498
+ <span className={`block ${motion ? "overflow-hidden" : ""}`}>
499
+ <span data-after={motion ? "" : undefined} className="block text-small text-ink-muted">
500
+ {caption}
501
+ </span>
502
+ </span>
503
+ ) : null}
504
+ </div>
505
+ ) : null}
506
+ </div>
507
+ );
508
+
509
+ const mark = (at: "tl" | "tr" | "bl" | "br") => (
510
+ <span
511
+ key={at}
512
+ data-mark={at}
513
+ aria-hidden
514
+ className={`absolute size-[0.9rem] border-ink ${at[0] === "t" ? "-top-3 border-t" : "-bottom-3 border-b"} ${
515
+ at[1] === "l" ? "-left-3 border-l" : "-right-3 border-r"
516
+ }`}
517
+ />
518
+ );
519
+
520
+ return (
521
+ <section
522
+ ref={stage}
523
+ data-transition="zoom-through"
524
+ data-place={firstPlace}
525
+ data-ink={firstInk}
526
+ style={
527
+ {
528
+ "--zt-dark": "oklch(from var(--surface-0) min(calc(l * 0.42), 0.19) c h)",
529
+ } as React.CSSProperties
530
+ }
531
+ className={`group/zt relative bg-surface-0 ${className}`}
532
+ >
533
+ {/* The pinned stage (wide screens with motion). Shown only once the page runs scripts. */}
534
+ <div data-tree="" className="relative hidden h-svh overflow-hidden md:motion-safe:[:is(.js,.sq-js)_&]:block md:motion-safe:group-data-[live]/zt:block">
535
+ <div data-clip="" className="absolute inset-0" style={{ clipPath: startClip }}>
536
+ <div data-drift="" className="absolute inset-0">
537
+ <div data-plane="" className="absolute inset-0" style={{ transform: startScale, transformOrigin: origin }}>
538
+ {photo()}
539
+ </div>
540
+ </div>
541
+ <div data-copy="" className="absolute inset-0" style={{ transform: startScale, transformOrigin: origin }}>
542
+ {arrived(true, "text-[length:calc(min(15vw,25svh)*var(--zt-hfit,1))]")}
543
+ </div>
544
+ </div>
545
+
546
+ {/* The composed frame: three layers at their own distance from the lens. */}
547
+ {lead ? (
548
+ <div data-depth={DEPTH.lead} className="pointer-events-none absolute inset-0">
549
+ <p className={`absolute top-[10svh] max-w-[30ch] text-body text-ink ${right ? "right-[5vw] text-right" : "left-[5vw]"}`}>
550
+ {lead}
551
+ </p>
552
+ </div>
553
+ ) : null}
554
+ <div data-depth={DEPTH.title} className="pointer-events-none absolute inset-0">
555
+ <p
556
+ data-title=""
557
+ className={`absolute bottom-[7svh] flex flex-col text-[length:calc(min(15vw,25svh)*var(--zt-fit,1))] text-ink ${
558
+ right ? "right-[5vw] items-end" : "left-[5vw] items-start"
559
+ }`}
560
+ >
561
+ <span data-line="" className={display}>
562
+ {title}
563
+ </span>
564
+ {accent ? (
565
+ <span data-line="" className={`${display} ${italic}`}>
566
+ {accent}
567
+ </span>
568
+ ) : null}
569
+ </p>
570
+ </div>
571
+ {caption ? (
572
+ <div data-depth={DEPTH.caption} className="pointer-events-none absolute inset-0">
573
+ <p
574
+ className="absolute text-small text-ink-muted"
575
+ style={{ left: win.left, top: `calc(${AIM_Y}% + ${HALF_H} + 1.1rem)` }}
576
+ >
577
+ {caption}
578
+ </p>
579
+ </div>
580
+ ) : null}
581
+ <div data-window="" aria-hidden className="pointer-events-none absolute" style={win}>
582
+ {(["tl", "tr", "bl", "br"] as const).map(mark)}
583
+ </div>
584
+ </div>
585
+
586
+ {/* The composed still: phones, reduced motion, and pages without scripts. */}
587
+ <div className="md:motion-safe:[:is(.js,.sq-js)_&]:hidden md:motion-safe:group-data-[live]/zt:hidden">
588
+ <div className={`flex flex-col gap-8 px-6 pb-10 pt-16 md:px-[5vw] md:pb-[8svh] md:pt-[12svh] ${right ? "items-end text-right" : ""}`}>
589
+ {lead ? <p className="max-w-[30ch] text-body text-ink">{lead}</p> : null}
590
+ <p
591
+ data-still-title=""
592
+ className={`flex w-full flex-col text-[length:calc(19vw*var(--zt-sfit,1))] text-ink md:text-[length:calc(min(13vw,22svh)*var(--zt-sfit,1))] ${
593
+ right ? "items-end" : "items-start"
594
+ }`}
595
+ >
596
+ <span data-line="" className={display}>
597
+ {title}
598
+ </span>
599
+ {accent ? (
600
+ <span data-line="" className={`${display} ${italic}`}>
601
+ {accent}
602
+ </span>
603
+ ) : null}
604
+ </p>
605
+ </div>
606
+ <div data-still-photo="" className="relative h-[92svh] overflow-hidden md:h-svh">
607
+ {photo()}
608
+ {arrived(false, "text-[length:calc(19vw*var(--zt-hfit,1))] md:text-[length:calc(min(13vw,22svh)*var(--zt-hfit,1))]")}
609
+ </div>
610
+ </div>
611
+ </section>
612
+ );
613
+ }