@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,1086 @@
1
+ "use client";
2
+ // hero-poster (Struq hero recipe)
3
+ // The first screen as a poster on a wall. Two lines of type, each fitted edge to edge at poster scale, and between
4
+ // them a narrow letterbox window onto a photo; a quiet foot line (the place, one link) at the bottom. On load the
5
+ // lines rise through their masks one after the other and the window opens from a hairline to its slot. On the first
6
+ // scroll the poster parts: the upper line goes up and out, the lower line and the foot go down and out, and the window
7
+ // opens into the full photo, which holds full bleed while the next section takes over.
8
+ //
9
+ // Registration: the photo is one full-screen layer (data-stack-photo) inside the window (data-stack-target), placed
10
+ // at the screen's own origin, so the window always shows that part of the full-screen picture: in the composed
11
+ // poster, without scripts, and while it opens. Opening it is a clip from whole-pixel numbers, never a scale. A stack
12
+ // preloader or page transition lands its print on the window with the photo laid as the full-screen layer.
13
+ //
14
+ // The type is fitted to the width with the display face loaded, and fitted again when the face arrives late. The
15
+ // sizes are two custom properties on the section, and the photo's place in the window is computed from them, so the
16
+ // registration holds in CSS too. Before the script, and without it, a cautious estimate per line keeps the poster on
17
+ // screen. With scripts and motion the lines wait below their masks and the window is a hairline from the first
18
+ // paint (blocks.css, "--- hero-poster ---"); both open after 3s if the script never comes. Under a closing cover the
19
+ // window stands open and the lines rise in the cover's wake. The pin is 150%, holds as long as the move.
20
+ // Lead for a studio, an architect, a brand with a strong name: any brief where the words are the picture.
21
+ //
22
+ // <HeroPoster image={images.hero[1]} lines={["Noord bouwt", "met daglicht"]} place="Architectuur en bouw, Rotterdam"
23
+ // link={{ label: "Bekijk het werk", href: "#werk" }} />
24
+ //
25
+ // Phones: one word per line, the window between at the height that is left, showing the photo's own crop; the
26
+ // entrance only (no pin, no open).
27
+ //
28
+ // Three variants of the same poster, picked with `variant`:
29
+ // - window (the default, above): one letterbox window between the two lines.
30
+ // - column: the two lines centred, fitted edge to edge, and a tall narrow slit to one side that cuts through them from
31
+ // the top edge to the foot line. Inside the slit the type goes on over the photo in the ink the photo measures for
32
+ // (with the lightest veil that holds 3:1). On load the slit draws down as the lines rise; on the first scroll it
33
+ // opens sideways by registration into the full photo while the upper line leaves left and the lower line right.
34
+ // - stack: the lines broken into one word per row, all at one fitted size, and a narrow window between each pair of
35
+ // rows, each a different crop of the same photo. On the first scroll the rows part up and down while every window
36
+ // slides to registration and opens to the full screen, so the crops merge into one photograph.
37
+ import Image from "next/image";
38
+ import { useRef } from "react";
39
+ import { gsap, useGSAP } from "../kit/gsap";
40
+ import type { StruqImage } from "../kit/images";
41
+ import { covered, whenUncovered } from "../preloaders/preloader-slices-uncovered";
42
+ import { inkOver } from "./hero-still";
43
+
44
+ export const HERO_POSTER_VARIANTS = [
45
+ { id: "window", title: "Venster" },
46
+ { id: "column", title: "Spleet" },
47
+ { id: "stack", title: "Stapel" },
48
+ ] as const;
49
+
50
+ export type HeroPosterVariant = (typeof HERO_POSTER_VARIANTS)[number]["id"];
51
+
52
+ type Props = Inner & {
53
+ /** The composition: one letterbox window (default), a slit through the type, or a window between every row. */
54
+ variant?: HeroPosterVariant;
55
+ };
56
+
57
+ type Inner = {
58
+ image: StruqImage;
59
+ /** The two lines of the poster: the studio's name, or its line split in two. */
60
+ lines: [string, string];
61
+ /** The foot line: place, discipline. */
62
+ place?: string;
63
+ /** One link in the foot line. */
64
+ link?: { label: string; href: string };
65
+ className?: string;
66
+ };
67
+
68
+ /** The longest the fit waits for the display face before measuring on what it has (the CSS fallback is at 3s). */
69
+ const FONT_WAIT = 2600;
70
+ /** The script-free estimate: an average glyph of a wide display face, in em. Narrower faces come out a little small. */
71
+ const EM_PER_CHAR = 0.66;
72
+ /** Line height of the poster type. */
73
+ const LEADING = 0.84;
74
+
75
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
76
+
77
+ export function HeroPoster({ variant = "window", ...rest }: Props) {
78
+ if (variant === "column") return <PosterColumn {...rest} />;
79
+ if (variant === "stack") return <PosterStack {...rest} />;
80
+ return <PosterWindow {...rest} />;
81
+ }
82
+
83
+ function PosterWindow({ image, lines, place, link, className = "" }: Inner) {
84
+ const stage = useRef<HTMLElement>(null);
85
+ const words = lines.map((l) => l.split(" "));
86
+ const longest = (ws: string[]) => Math.max(...ws.map((w) => w.length));
87
+
88
+ useGSAP(
89
+ () => {
90
+ const el = stage.current;
91
+ if (!el) return;
92
+ const q = <T extends HTMLElement = HTMLElement>(s: string) => Array.from(el.querySelectorAll<T>(s));
93
+ const wordEls = q("[data-hp-word]");
94
+ const slot = el.querySelector<HTMLElement>("[data-hp-slot]");
95
+ const photo = el.querySelector<HTMLElement>("[data-stack-photo]");
96
+ if (wordEls.length !== 2 || !slot || !photo) return;
97
+ let dead = false;
98
+
99
+ // The CSS fallback has shown the poster when its first line rests at 0 before the script let it go. Read once
100
+ // per element and kept on it: a second mount (development mounts twice) finds the gate restarted.
101
+ if (!el.dataset.hpFirst) {
102
+ const first = el.querySelector<HTMLElement>("[data-hp-line]");
103
+ const rest = first ? getComputedStyle(first).translate : "none";
104
+ const y = rest === "none" ? 0 : parseFloat(rest.trim().split(/\s+/)[1] ?? "0");
105
+ el.dataset.hpFirst = y === 0 ? "late" : "gated";
106
+ }
107
+ const late = el.dataset.hpFirst === "late";
108
+
109
+ // Fit: each line to the width at its natural size, then both together so the window keeps at least an eighth
110
+ // of the screen. Measured on a fresh copy at 100px (the line itself may be mid-transition), written as the two
111
+ // custom properties every size and the photo's registration are computed from.
112
+ const fit = () => {
113
+ const W = el.clientWidth;
114
+ const H = el.clientHeight;
115
+ const wide = W >= 768;
116
+ const room = W - (wide ? W * 0.08 : 48);
117
+ const widthOf = (w: HTMLElement) => {
118
+ const c = w.cloneNode(true) as HTMLElement;
119
+ Object.assign(c.style, { position: "absolute", left: "0", top: "0", visibility: "hidden", fontSize: "100px" });
120
+ w.parentElement?.appendChild(c);
121
+ const width = wide
122
+ ? c.offsetWidth
123
+ : Math.max(0, ...Array.from(c.querySelectorAll<HTMLElement>("[data-hp-part]")).map((p) => p.offsetWidth));
124
+ c.remove();
125
+ return width;
126
+ };
127
+ const sizes = wordEls.map((w) => {
128
+ const width = widthOf(w);
129
+ return width > 0 ? (100 * room) / width : 100;
130
+ });
131
+ const rows = wide ? [1, 1] : words.map((ws) => ws.length);
132
+ const foot = el.querySelector<HTMLElement>("[data-hp-foot]")?.offsetHeight ?? 0;
133
+ const free = H - foot - (wide ? H * 0.1 : 64) - H * 0.12;
134
+ const k = Math.min(1, free / (LEADING * (sizes[0] * rows[0] + sizes[1] * rows[1])));
135
+ return [Math.floor(sizes[0] * k), Math.floor(sizes[1] * k)];
136
+ };
137
+ const sizeNow = { a: 0, b: 0 };
138
+ const write = () => {
139
+ el.style.setProperty("--hp-s1", `${Math.round(sizeNow.a)}px`);
140
+ el.style.setProperty("--hp-s2", `${Math.round(sizeNow.b)}px`);
141
+ };
142
+ // On screen already (the CSS fallback showed the estimate before the script came), a new size is eased in
143
+ // rather than cut; otherwise it is simply set.
144
+ let shown = late;
145
+ const settle = (onFrame: () => void) => {
146
+ const [a, b] = fit();
147
+ if (!shown || !sizeNow.a) {
148
+ if (!sizeNow.a && shown) {
149
+ const masksNow = el.querySelectorAll<HTMLElement>("[data-hp-word]");
150
+ sizeNow.a = parseFloat(getComputedStyle(masksNow[0]).fontSize) || a;
151
+ sizeNow.b = parseFloat(getComputedStyle(masksNow[1]).fontSize) || b;
152
+ } else {
153
+ sizeNow.a = a;
154
+ sizeNow.b = b;
155
+ write();
156
+ onFrame();
157
+ return;
158
+ }
159
+ }
160
+ gsap.to(sizeNow, {
161
+ a,
162
+ b,
163
+ duration: 0.7,
164
+ ease: "power3.inOut",
165
+ overwrite: true,
166
+ onUpdate: () => {
167
+ write();
168
+ onFrame();
169
+ },
170
+ });
171
+ };
172
+
173
+ // The photo's place in the window: the screen's origin, measured (the CSS computes the same from the sizes).
174
+ const place = () => {
175
+ // Phones never open the window past the slot, so there the photo simply fills the slot (its own crop).
176
+ if (el.clientWidth < 768) {
177
+ for (const p of ["left", "top", "width", "height"] as const) photo.style[p] = "";
178
+ return;
179
+ }
180
+ const r = slot.getBoundingClientRect();
181
+ const b = el.getBoundingClientRect();
182
+ Object.assign(photo.style, {
183
+ left: `${Math.round(b.left - r.left)}px`,
184
+ top: `${Math.round(b.top - r.top)}px`,
185
+ width: `${el.clientWidth}px`,
186
+ height: `${el.clientHeight}px`,
187
+ });
188
+ };
189
+
190
+ const listeners: Array<() => void> = [];
191
+ const frame = () => {
192
+ place();
193
+ for (const f of listeners) f();
194
+ };
195
+ const refit = () => {
196
+ if (dead) return;
197
+ settle(frame);
198
+ };
199
+ // With the face in, or on what there is after FONT_WAIT; and again whenever a face arrives later than that. The
200
+ // poster counts as shown once the entrance has started (or the fallback did it), so a late face eases in.
201
+ const faceIn = Promise.race([document.fonts?.ready ?? Promise.resolve(), new Promise((r) => setTimeout(r, FONT_WAIT))]);
202
+ settle(frame);
203
+ faceIn.then(() => {
204
+ refit();
205
+ shown = true;
206
+ });
207
+ const onFonts = () => refit();
208
+ document.fonts?.addEventListener?.("loadingdone", onFonts);
209
+ window.addEventListener("resize", refit);
210
+
211
+ // The photo decoded ahead of its first show.
212
+ for (const img of q<HTMLImageElement>("img")) {
213
+ const go = () => img.decode?.().catch(() => {});
214
+ if (img.complete) go();
215
+ else img.addEventListener("load", go, { once: true });
216
+ }
217
+
218
+ const masks = q("[data-hp-rise]");
219
+ const mm = gsap.matchMedia();
220
+ mm.add(
221
+ {
222
+ wide: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
223
+ phone: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
224
+ },
225
+ (ctx) => {
226
+ const wide = Boolean(ctx.conditions?.wide);
227
+ const top = el.querySelector<HTMLElement>("[data-hp-top]");
228
+ const bottom = el.querySelector<HTMLElement>("[data-hp-bottom]");
229
+ if (!top || !bottom) return;
230
+
231
+ // Under a cover the window stands open at once: the cover lands on it, and the lines rise in its wake.
232
+ const s = { open: late || covered() ? 1 : 0, grow: 0 };
233
+ const g = { W: 1, H: 1, slot: { x: 0, y: 0, w: 1, h: 1 } };
234
+ const measure = () => {
235
+ g.W = el.clientWidth;
236
+ g.H = el.clientHeight;
237
+ const r = slot.getBoundingClientRect();
238
+ const b = el.getBoundingClientRect();
239
+ g.slot = { x: r.left - b.left, y: r.top - b.top, w: r.width, h: r.height };
240
+ };
241
+ // The window, in the photo's own coordinates (it sits at the screen's origin): from a hairline at the slot's
242
+ // middle to the slot, then to the whole screen; whole pixels.
243
+ const paint = () => {
244
+ if (!wide) {
245
+ // Phones: the photo fills the slot; the window opens from a hairline at its middle.
246
+ const v = ((1 - s.open) * 50).toFixed(3);
247
+ photo.style.clipPath = `inset(${v}% 0% ${v}% 0%)`;
248
+ return;
249
+ }
250
+ const mid = g.slot.y + g.slot.h / 2;
251
+ const t0 = lerp(mid - 0.5, g.slot.y, s.open);
252
+ const b0 = lerp(mid + 0.5, g.slot.y + g.slot.h, s.open);
253
+ const T = Math.round(lerp(t0, 0, s.grow));
254
+ const B = Math.round(lerp(g.H - b0, 0, s.grow));
255
+ const L = Math.round(lerp(g.slot.x, 0, s.grow));
256
+ const R = Math.round(lerp(g.W - (g.slot.x + g.slot.w), 0, s.grow));
257
+ photo.style.clipPath = `inset(${T}px ${R}px ${B}px ${L}px)`;
258
+ // The poster parts: each half travels just far enough to clear the screen, in whole pixels.
259
+ const up = Math.round(-(top.offsetTop + top.offsetHeight + 8) * s.grow);
260
+ const down = Math.round((g.H - bottom.offsetTop + 8) * s.grow);
261
+ top.style.transform = `translate3d(0, ${up}px, 0)`;
262
+ bottom.style.transform = `translate3d(0, ${down}px, 0)`;
263
+ };
264
+
265
+ // The CSS gate lets go first: GSAP would otherwise read the gate's `translate` into its own y and keep it.
266
+ el.dataset.live = wide ? "wide" : "phone";
267
+ gsap.set(masks, { y: 0, yPercent: late ? 0 : 112 });
268
+ measure();
269
+ paint();
270
+ const again = () => {
271
+ measure();
272
+ paint();
273
+ };
274
+ listeners.push(again);
275
+
276
+ // The entrance, once the face is in (so the lines rise fitted) and nothing covers the page: the lines rise
277
+ // one after the other and the window opens from its hairline as the second lands.
278
+ let intro: gsap.core.Timeline | null = null;
279
+ let cancel = () => {};
280
+ if (!late) {
281
+ intro = gsap.timeline({ delay: 0.15, paused: true });
282
+ intro.to(masks, { yPercent: 0, duration: 1.15, stagger: 0.12, ease: "expo.out" }, 0);
283
+ if (s.open < 1) intro.to(s, { open: 1, duration: 1.1, ease: "power3.inOut", onUpdate: paint }, 0.35);
284
+ faceIn.then(() => {
285
+ if (!dead) cancel = whenUncovered(() => intro?.play());
286
+ });
287
+ }
288
+
289
+ if (wide) {
290
+ // The first scroll: hold on the poster, the poster parts as the window opens by registration, hold on the
291
+ // full photo, and the next section takes over.
292
+ const tl = gsap.timeline({
293
+ defaults: { ease: "none" },
294
+ onUpdate: paint,
295
+ scrollTrigger: { trigger: el, start: "top top", end: "+=150%", pin: true, scrub: 1, onRefresh: again },
296
+ });
297
+ tl.to({}, { duration: 0.6 })
298
+ .to(s, { grow: 1, duration: 1.2, ease: "power3.inOut" }, 0.6)
299
+ .to({}, { duration: 0.6 }, 1.8);
300
+ }
301
+
302
+ return () => {
303
+ cancel();
304
+ intro?.kill();
305
+ listeners.splice(listeners.indexOf(again), 1);
306
+ delete el.dataset.live;
307
+ photo.style.clipPath = "";
308
+ top.style.transform = "";
309
+ bottom.style.transform = "";
310
+ };
311
+ },
312
+ );
313
+
314
+ return () => {
315
+ dead = true;
316
+ document.fonts?.removeEventListener?.("loadingdone", onFonts);
317
+ window.removeEventListener("resize", refit);
318
+ mm.revert();
319
+ };
320
+ },
321
+ { scope: stage, dependencies: [lines[0], lines[1]] },
322
+ );
323
+
324
+ // The script-free estimate per line: the width over its characters, and on wide screens a height cap so two lines
325
+ // and a window always fit. Phones stack one word per line, so the longest word sets it.
326
+ const est = (i: 0 | 1) =>
327
+ `min(18vw, calc(92vw / ${(lines[i].length * EM_PER_CHAR).toFixed(2)}), 30svh)`;
328
+ const estPhone = (i: 0 | 1) => `calc((100vw - 48px) / ${(longest(words[i]) * EM_PER_CHAR).toFixed(2)})`;
329
+
330
+ // The line's box is LEADING of its size per row; the mask carries the descender room in the display em.
331
+ const word = "font-display block w-fit whitespace-nowrap leading-[0.84] tracking-[-0.045em] text-ink";
332
+ const mask = "block overflow-hidden pb-[0.24em] -mb-[0.24em]";
333
+ const small = "block overflow-hidden pb-[0.15em] -mb-[0.15em]";
334
+ // Inline, because the kit balances display type (text-wrap), which would let a poster line wrap; and no transition,
335
+ // or a page that transitions every property hands the fit a size from mid-change.
336
+ const nowrap = { whiteSpace: "nowrap", textWrap: "nowrap", transition: "none" } as React.CSSProperties;
337
+ const stack = (parts: string[]) =>
338
+ parts.map((part, i) => (
339
+ <span key={i} data-hp-part="" className="inline-block max-md:block">
340
+ {part}
341
+ {i < parts.length - 1 ? " " : ""}
342
+ </span>
343
+ ));
344
+
345
+ return (
346
+ <section
347
+ ref={stage}
348
+ data-hero-poster=""
349
+ aria-label={`${lines[0]} ${lines[1]}`}
350
+ className={`group/hp relative h-svh overflow-hidden bg-surface-0 ${className}`}
351
+ style={
352
+ {
353
+ "--hp-e1": est(0),
354
+ "--hp-e2": est(1),
355
+ "--hp-p1": estPhone(0),
356
+ "--hp-p2": estPhone(1),
357
+ } as React.CSSProperties
358
+ }
359
+ >
360
+ <h1 className="sr-only">{`${lines[0]} ${lines[1]}`}</h1>
361
+
362
+ {/* The poster: line, window, line, foot. The lines stand above the photo as it opens past them. */}
363
+ <div className="relative flex h-full flex-col px-6 pb-6 pt-8 md:px-[4vw] md:pb-[3svh] md:pt-[4svh]">
364
+ <div data-hp-top="" className="relative z-10 will-change-transform">
365
+ <p aria-hidden className="m-0">
366
+ <span
367
+ className={`${mask} text-[length:var(--hp-s1,var(--hp-p1))] md:text-[length:var(--hp-s1,var(--hp-e1))]`}
368
+ >
369
+ <span data-hp-rise="" data-hp-line="" className="block">
370
+ <span data-hp-word="" className={word} style={nowrap}>
371
+ {stack(words[0])}
372
+ </span>
373
+ </span>
374
+ </span>
375
+ </p>
376
+ </div>
377
+
378
+ {/* The window: the box a stack preloader lands on. The photo inside it is the full screen, registered. */}
379
+ <div className="relative my-[1.2svh] min-h-0 flex-1">
380
+ <div
381
+ data-hp-slot=""
382
+ data-stack-target=""
383
+ className="absolute inset-0 overflow-hidden bg-surface-2 group-data-[live=wide]/hp:overflow-visible group-data-[live=wide]/hp:bg-transparent"
384
+ >
385
+ <div
386
+ data-stack-photo=""
387
+ className="absolute inset-0 md:inset-auto md:left-[-4vw] md:top-[calc(-4svh_-_0.84_*_var(--hp-s1,var(--hp-e1))_-_1.2svh)] md:h-svh md:w-screen"
388
+ >
389
+ <Image
390
+ src={image.src}
391
+ alt={image.alt}
392
+ fill
393
+ priority
394
+ sizes="100vw"
395
+ unoptimized={image.unoptimized}
396
+ className="object-cover [filter:saturate(0.9)_contrast(1.05)]"
397
+ />
398
+ </div>
399
+ </div>
400
+ </div>
401
+
402
+ <div data-hp-bottom="" className="relative z-10 will-change-transform">
403
+ <p aria-hidden className="m-0">
404
+ <span
405
+ className={`${mask} text-[length:var(--hp-s2,var(--hp-p2))] md:text-[length:var(--hp-s2,var(--hp-e2))]`}
406
+ >
407
+ <span data-hp-rise="" data-hp-line="" className="block">
408
+ <span data-hp-word="" className={`${word} ml-auto text-right`} style={nowrap}>
409
+ {stack(words[1])}
410
+ </span>
411
+ </span>
412
+ </span>
413
+ </p>
414
+ {place || link ? (
415
+ <div data-hp-foot="" className="mt-[2svh] flex items-baseline justify-between gap-6 text-small text-ink-muted">
416
+ {place ? (
417
+ <span className={small}>
418
+ <span data-hp-rise="" data-hp-line="" className="block">
419
+ {place}
420
+ </span>
421
+ </span>
422
+ ) : (
423
+ <span />
424
+ )}
425
+ {link ? (
426
+ <span className={small}>
427
+ <span data-hp-rise="" data-hp-line="" className="block">
428
+ <a
429
+ href={link.href}
430
+ className="text-ink underline decoration-1 underline-offset-[0.3em] transition-[text-decoration-color] duration-500 hover:decoration-accent"
431
+ >
432
+ {link.label}
433
+ </a>
434
+ </span>
435
+ </span>
436
+ ) : null}
437
+ </div>
438
+ ) : null}
439
+ </div>
440
+ </div>
441
+ </section>
442
+ );
443
+ }
444
+
445
+ // The column and the stack share what follows: the late read, the measure at 100px, the eased refit, the type.
446
+
447
+ /** Whether the CSS fallback showed the poster before the script came (its first line rests at 0). Read once per
448
+ * element and kept on it: a second mount (development mounts twice) finds the gate restarted. */
449
+ function lateOf(el: HTMLElement) {
450
+ if (!el.dataset.hpFirst) {
451
+ const first = el.querySelector<HTMLElement>("[data-hp-line]");
452
+ const rest = first ? getComputedStyle(first).translate : "none";
453
+ const y = rest === "none" ? 0 : parseFloat(rest.trim().split(/\s+/)[1] ?? "0");
454
+ el.dataset.hpFirst = y === 0 ? "late" : "gated";
455
+ }
456
+ return el.dataset.hpFirst === "late";
457
+ }
458
+
459
+ /** A line's width at 100px on a fresh copy (the line itself may be mid-rise); `parts` takes its widest word. */
460
+ function widthAt100(w: HTMLElement, parts: boolean) {
461
+ const c = w.cloneNode(true) as HTMLElement;
462
+ Object.assign(c.style, { position: "absolute", left: "0", top: "0", visibility: "hidden", fontSize: "100px" });
463
+ w.parentElement?.appendChild(c);
464
+ const width = parts
465
+ ? Math.max(0, ...Array.from(c.querySelectorAll<HTMLElement>("[data-hp-part]")).map((p) => p.offsetWidth))
466
+ : c.offsetWidth;
467
+ c.remove();
468
+ return width;
469
+ }
470
+
471
+ /**
472
+ * Type sizes as custom properties on the section: set at once while the poster is not yet shown, eased in once it is
473
+ * (a late face, a resize), so nothing on screen jumps. `onScreen` reads the sizes showing when the first one eases.
474
+ */
475
+ function sizer(el: HTMLElement, names: string[], onScreen: () => number[], shownAtStart: boolean) {
476
+ const now: Record<string, number> = {};
477
+ const keys = names.map((_, i) => `k${i}`);
478
+ let shown = shownAtStart;
479
+ const write = () => names.forEach((n, i) => el.style.setProperty(n, `${Math.round(now[keys[i]])}px`));
480
+ return {
481
+ show: () => {
482
+ shown = true;
483
+ },
484
+ set(next: number[], onFrame: () => void) {
485
+ const has = now.k0 !== undefined;
486
+ if (shown && !has) onScreen().forEach((v, i) => (now[keys[i]] = v || next[i]));
487
+ if (!shown) {
488
+ keys.forEach((k, i) => (now[k] = next[i]));
489
+ write();
490
+ onFrame();
491
+ return;
492
+ }
493
+ gsap.to(now, {
494
+ ...Object.fromEntries(keys.map((k, i) => [k, next[i]])),
495
+ duration: 0.7,
496
+ ease: "power3.inOut",
497
+ overwrite: true,
498
+ onUpdate: () => {
499
+ write();
500
+ onFrame();
501
+ },
502
+ });
503
+ },
504
+ };
505
+ }
506
+
507
+ /** The display face in, or what there is after FONT_WAIT. */
508
+ const faceReady = () =>
509
+ Promise.race([document.fonts?.ready ?? Promise.resolve(), new Promise((r) => setTimeout(r, FONT_WAIT))]);
510
+
511
+ const WORD = "font-display block w-fit whitespace-nowrap leading-[0.84] tracking-[-0.045em]";
512
+ const MASK = "block overflow-hidden pb-[0.24em] -mb-[0.24em]";
513
+ const SMALL = "block overflow-hidden pb-[0.15em] -mb-[0.15em]";
514
+ // Inline, because the kit balances display type (text-wrap), which would let a poster line wrap; and no transition,
515
+ // or a page that transitions every property hands the fit a size from mid-change.
516
+ const NOWRAP = { whiteSpace: "nowrap", textWrap: "nowrap", transition: "none" } as React.CSSProperties;
517
+ const LINK =
518
+ "underline decoration-1 underline-offset-[0.3em] transition-[text-decoration-color] duration-500 hover:decoration-accent";
519
+
520
+ /** The foot line: place, one link. The copy under the slit keeps its place in the layout but shows nothing. */
521
+ function Foot({ place, link, copy = false }: { place?: string; link?: Inner["link"]; copy?: boolean }) {
522
+ if (!place && !link) return null;
523
+ return (
524
+ <div
525
+ data-hp-foot=""
526
+ className={`mt-[2svh] flex items-baseline justify-between gap-6 text-small text-ink-muted will-change-transform ${copy ? "invisible" : ""}`}
527
+ >
528
+ {place ? (
529
+ <span className={SMALL}>
530
+ <span data-hp-rise="" data-hp-line="" className="block">
531
+ {place}
532
+ </span>
533
+ </span>
534
+ ) : (
535
+ <span />
536
+ )}
537
+ {link ? (
538
+ <span className={SMALL}>
539
+ <span data-hp-rise="" data-hp-line="" className="block">
540
+ {copy ? (
541
+ <span className={`text-ink ${LINK}`}>{link.label}</span>
542
+ ) : (
543
+ <a href={link.href} className={`text-ink ${LINK}`}>
544
+ {link.label}
545
+ </a>
546
+ )}
547
+ </span>
548
+ </span>
549
+ ) : null}
550
+ </div>
551
+ );
552
+ }
553
+
554
+ /**
555
+ * column: the two lines centred and fitted edge to edge, a tall narrow slit to one side cutting through them from the
556
+ * top edge down to the foot. The slit is a cut layer the size of the screen: the photo, a light veil and a second copy
557
+ * of the type, clipped to the slit, so inside it the type goes on over the photo in the ink the photo measures for.
558
+ * The cut layer sits in the slit's box (data-stack-target) at the screen's origin, so a stack preloader lands on the
559
+ * slit with the photo laid as the full screen. On load the slit draws down from the top edge as the lines rise; on the
560
+ * first scroll (pin 150%) it opens sideways by registration while the upper line leaves left, the lower line right
561
+ * and the foot down. Phones: one word per row, the same slit, the entrance only.
562
+ */
563
+ function PosterColumn({ image, lines, place, link, className = "" }: Inner) {
564
+ const stage = useRef<HTMLElement>(null);
565
+ const words = lines.map((l) => l.split(" "));
566
+ const longest = (ws: string[]) => Math.max(...ws.map((w) => w.length));
567
+
568
+ useGSAP(
569
+ () => {
570
+ const el = stage.current;
571
+ if (!el) return;
572
+ const ground = el.querySelector<HTMLElement>("[data-hp-layer='ground']");
573
+ const copy = el.querySelector<HTMLElement>("[data-hp-layer='copy']");
574
+ const cut = el.querySelector<HTMLElement>("[data-hp-cut]");
575
+ const veil = el.querySelector<HTMLElement>("[data-hp-veil]");
576
+ const img = cut?.querySelector("img");
577
+ if (!ground || !copy || !cut || !veil || !img) return;
578
+ const lineWords = Array.from(ground.querySelectorAll<HTMLElement>("[data-hp-word]"));
579
+ const foot = ground.querySelector<HTMLElement>("[data-hp-foot]");
580
+ if (lineWords.length !== 2) return;
581
+ const q = (s: string) => Array.from(el.querySelectorAll<HTMLElement>(s));
582
+ let dead = false;
583
+ const late = lateOf(el);
584
+
585
+ const fit = () => {
586
+ const W = el.clientWidth;
587
+ const H = el.clientHeight;
588
+ const wide = W >= 768;
589
+ const room = W - (wide ? W * 0.08 : 48);
590
+ const sizes = lineWords.map((w) => {
591
+ const width = widthAt100(w, !wide);
592
+ return width > 0 ? (100 * room) / width : 100;
593
+ });
594
+ const rows = wide ? [1, 1] : words.map((ws) => ws.length);
595
+ const free = H - (foot?.offsetHeight ?? 0) - (wide ? H * 0.2 : 140);
596
+ const k = Math.min(1, free / (LEADING * (sizes[0] * rows[0] + sizes[1] * rows[1])));
597
+ return [Math.floor(sizes[0] * k), Math.floor(sizes[1] * k)];
598
+ };
599
+
600
+ // The slit in pixels: its edges from the shares the CSS sets, its foot from where the foot line starts.
601
+ const g = { W: 1, H: 1, L: 0, R: 0, B: 0, l1: 0, l2: 0, f: 0 };
602
+ const measure = () => {
603
+ const cs = getComputedStyle(el);
604
+ g.W = el.clientWidth;
605
+ g.H = el.clientHeight;
606
+ g.L = Math.round(g.W * (parseFloat(cs.getPropertyValue("--hp-cl")) || 0.6));
607
+ g.R = Math.round(g.W * (parseFloat(cs.getPropertyValue("--hp-cr")) || 0.24));
608
+ const footTop = foot ? foot.offsetTop : g.H;
609
+ g.B = Math.max(0, Math.round(g.H - footTop + g.H * 0.02));
610
+ el.style.setProperty("--hp-cb", `${g.B}px`);
611
+ // How far each half travels to clear the screen: the upper line to the left, the lower to the right.
612
+ const b = el.getBoundingClientRect();
613
+ const r1 = lineWords[0].getBoundingClientRect();
614
+ const r2 = lineWords[1].getBoundingClientRect();
615
+ g.l1 = Math.round(r1.right - b.left + 8);
616
+ g.l2 = Math.round(g.W - (r2.left - b.left) + 8);
617
+ g.f = foot ? Math.round(g.H - foot.offsetTop + 8) : 0;
618
+ };
619
+
620
+ // The ink inside the slit, and its veil, from what the photo holds under the type (the whole lines, since the
621
+ // slit opens across them); the CSS's light ink over a safe veil stays when the photo cannot be read.
622
+ const grade = () => {
623
+ const b = el.getBoundingClientRect();
624
+ const boxes = Array.from(copy.querySelectorAll<HTMLElement>("[data-hp-word]")).map((w) => {
625
+ const r = w.getBoundingClientRect();
626
+ const m = (w.closest("[data-hp-mask]") as HTMLElement | null)?.getBoundingClientRect() ?? r;
627
+ return { x: r.left - b.left, y: m.top - b.top, w: r.width, h: m.height };
628
+ });
629
+ const pick = inkOver(img, { x: 0, y: 0, w: el.clientWidth, h: el.clientHeight }, boxes, el);
630
+ if (!pick) return;
631
+ copy.style.color = pick.ink;
632
+ veil.dataset.tone = pick.tone;
633
+ veil.style.opacity = pick.veil.toFixed(3);
634
+ };
635
+
636
+ const listeners: Array<() => void> = [];
637
+ const frame = () => {
638
+ measure();
639
+ for (const f of listeners) f();
640
+ };
641
+ const sizes = sizer(
642
+ el,
643
+ ["--hp-s1", "--hp-s2"],
644
+ () => lineWords.map((w) => parseFloat(getComputedStyle(w).fontSize)),
645
+ late,
646
+ );
647
+ const decoded = img.decode().catch(() => undefined);
648
+ const refit = () => {
649
+ if (dead) return;
650
+ sizes.set(fit(), frame);
651
+ decoded.then(() => !dead && grade());
652
+ };
653
+ const faceIn = faceReady();
654
+ sizes.set(fit(), frame);
655
+ faceIn.then(() => {
656
+ refit();
657
+ sizes.show();
658
+ });
659
+ const onFonts = () => refit();
660
+ document.fonts?.addEventListener?.("loadingdone", onFonts);
661
+ window.addEventListener("resize", refit);
662
+ decoded.then(() => !dead && grade());
663
+
664
+ const masks = q("[data-hp-rise]");
665
+ const mm = gsap.matchMedia();
666
+ mm.add(
667
+ {
668
+ wide: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
669
+ phone: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
670
+ },
671
+ (ctx) => {
672
+ const wide = Boolean(ctx.conditions?.wide);
673
+ const ones = q("[data-hp-l1]");
674
+ const twos = q("[data-hp-l2]");
675
+ const feet = q("[data-hp-foot]");
676
+ // Under a cover the slit stands drawn at once: the cover lands on it, and the lines rise in its wake.
677
+ const s = { open: late || covered() ? 1 : 0, grow: 0 };
678
+ const paint = () => {
679
+ const bottom = Math.round(lerp(lerp(g.H, g.B, s.open), 0, s.grow));
680
+ const left = Math.round(lerp(g.L, 0, s.grow));
681
+ const right = Math.round(lerp(g.R, 0, s.grow));
682
+ cut.style.clipPath = `inset(0px ${right}px ${bottom}px ${left}px)`;
683
+ if (!wide) return;
684
+ const x1 = Math.round(-g.l1 * s.grow);
685
+ const x2 = Math.round(g.l2 * s.grow);
686
+ const y = Math.round(g.f * s.grow);
687
+ for (const o of ones) o.style.transform = `translate3d(${x1}px, 0, 0)`;
688
+ for (const t of twos) t.style.transform = `translate3d(${x2}px, 0, 0)`;
689
+ for (const f of feet) f.style.transform = `translate3d(0, ${y}px, 0)`;
690
+ };
691
+
692
+ // The CSS gate lets go first: GSAP would otherwise read the gate's `translate` into its own y and keep it.
693
+ el.dataset.live = wide ? "wide" : "phone";
694
+ gsap.set(masks, { y: 0, yPercent: late ? 0 : 112 });
695
+ measure();
696
+ paint();
697
+ listeners.push(paint);
698
+
699
+ let intro: gsap.core.Timeline | null = null;
700
+ let cancel = () => {};
701
+ if (!late) {
702
+ intro = gsap.timeline({ delay: 0.15, paused: true });
703
+ intro.to(masks, { yPercent: 0, duration: 1.15, stagger: 0.1, ease: "expo.out" }, 0);
704
+ if (s.open < 1) intro.to(s, { open: 1, duration: 1.2, ease: "power3.inOut", onUpdate: paint }, 0.2);
705
+ faceIn.then(() => {
706
+ if (!dead) cancel = whenUncovered(() => intro?.play());
707
+ });
708
+ }
709
+
710
+ if (wide) {
711
+ // Hold on the poster, the slit opens sideways as the lines leave, hold on the full photo, hand over.
712
+ const tl = gsap.timeline({
713
+ defaults: { ease: "none" },
714
+ onUpdate: paint,
715
+ scrollTrigger: {
716
+ trigger: el,
717
+ start: "top top",
718
+ end: "+=150%",
719
+ pin: true,
720
+ scrub: 0.4,
721
+ onRefresh: () => {
722
+ measure();
723
+ paint();
724
+ },
725
+ },
726
+ });
727
+ tl.to({}, { duration: 0.5 })
728
+ .to(s, { grow: 1, duration: 1.3, ease: "power3.inOut" }, 0.5)
729
+ .to({}, { duration: 0.6 }, 1.8);
730
+ }
731
+
732
+ return () => {
733
+ cancel();
734
+ intro?.kill();
735
+ listeners.splice(listeners.indexOf(paint), 1);
736
+ delete el.dataset.live;
737
+ cut.style.clipPath = "";
738
+ for (const n of [...ones, ...twos, ...feet]) n.style.transform = "";
739
+ };
740
+ },
741
+ );
742
+
743
+ return () => {
744
+ dead = true;
745
+ document.fonts?.removeEventListener?.("loadingdone", onFonts);
746
+ window.removeEventListener("resize", refit);
747
+ mm.revert();
748
+ };
749
+ },
750
+ { scope: stage, dependencies: [lines[0], lines[1]] },
751
+ );
752
+
753
+ const est = (i: 0 | 1) => `min(18vw, calc(92vw / ${(lines[i].length * EM_PER_CHAR).toFixed(2)}), 26svh)`;
754
+ const estPhone = (i: 0 | 1) => `calc((100vw - 48px) / ${(longest(words[i]) * EM_PER_CHAR).toFixed(2)})`;
755
+ // The space stands between the words, outside them: a space at the end of an inline block is dropped.
756
+ const parts = (ps: string[]) =>
757
+ ps.map((part, i) => (
758
+ <span key={i}>
759
+ <span data-hp-part="" className="inline-block max-md:block">
760
+ {part}
761
+ </span>
762
+ {i < ps.length - 1 ? " " : ""}
763
+ </span>
764
+ ));
765
+ const line = (i: 0 | 1) => (
766
+ <p aria-hidden className="m-0">
767
+ <span
768
+ data-hp-mask=""
769
+ className={`${MASK} ${i === 0 ? "text-[length:var(--hp-s1,var(--hp-p1))] md:text-[length:var(--hp-s1,var(--hp-e1))]" : "text-[length:var(--hp-s2,var(--hp-p2))] md:text-[length:var(--hp-s2,var(--hp-e2))]"}`}
770
+ >
771
+ <span data-hp-rise="" data-hp-line="" className="block">
772
+ <span data-hp-word="" className={`${WORD} ${i === 1 ? "ml-auto text-right" : ""}`} style={NOWRAP}>
773
+ {parts(words[i])}
774
+ </span>
775
+ </span>
776
+ </span>
777
+ </p>
778
+ );
779
+ // The same layout twice: on the ground in the ink, and as the copy inside the slit.
780
+ const layer = (copy: boolean) => (
781
+ <div
782
+ data-hp-layer={copy ? "copy" : "ground"}
783
+ aria-hidden={copy || undefined}
784
+ className={`absolute inset-0 flex flex-col px-6 pb-6 pt-8 md:px-[4vw] md:pb-[3svh] md:pt-[4svh] ${copy ? "text-on-photo" : "text-ink"}`}
785
+ >
786
+ <div className="flex-1" />
787
+ <div data-hp-l1="" className="will-change-transform">
788
+ {line(0)}
789
+ </div>
790
+ <div data-hp-l2="" className="mt-[1.2svh] will-change-transform">
791
+ {line(1)}
792
+ </div>
793
+ <div className="flex-1" />
794
+ <Foot place={place} link={link} copy={copy} />
795
+ </div>
796
+ );
797
+
798
+ return (
799
+ <section
800
+ ref={stage}
801
+ data-hero-poster=""
802
+ data-hp-variant="column"
803
+ aria-label={`${lines[0]} ${lines[1]}`}
804
+ className={`group/hp relative h-svh overflow-hidden bg-surface-0 ${className}`}
805
+ style={
806
+ {
807
+ "--hp-e1": est(0),
808
+ "--hp-e2": est(1),
809
+ "--hp-p1": estPhone(0),
810
+ "--hp-p2": estPhone(1),
811
+ } as React.CSSProperties
812
+ }
813
+ >
814
+ <h1 className="sr-only">{`${lines[0]} ${lines[1]}`}</h1>
815
+ {layer(false)}
816
+ {/* The slit's box, the box a stack preloader lands on; the cut layer inside it is the whole screen, registered. */}
817
+ <div data-hp-cutslot="" data-stack-target="">
818
+ <div data-hp-cut="">
819
+ <div data-stack-photo="" className="absolute inset-0">
820
+ <Image
821
+ src={image.src}
822
+ alt={image.alt}
823
+ fill
824
+ priority
825
+ sizes="100vw"
826
+ unoptimized={image.unoptimized}
827
+ className="object-cover [filter:saturate(0.9)_contrast(1.05)]"
828
+ />
829
+ </div>
830
+ <div data-hp-veil="" aria-hidden className="absolute inset-0" />
831
+ {layer(true)}
832
+ </div>
833
+ </div>
834
+ </section>
835
+ );
836
+ }
837
+
838
+ /**
839
+ * stack: the poster's words one per row, all at one size (the longest fitted to the width, the whole capped by the
840
+ * height), alternating left and right, with a narrow window between each pair of rows. Each window shows a different
841
+ * crop of the same photograph: the middle one (the box a stack preloader lands on) the registered screen, the ones
842
+ * above it higher in the picture and the ones below lower. On load the rows rise and the windows open from a hairline;
843
+ * on the first scroll (pin 150%) the upper rows go up and the lower rows and the foot go down while each window slides
844
+ * to registration and opens to the full screen, so the crops merge into one photograph. Only whole pixels move.
845
+ * Phones: the same stack, each window its own crop, the entrance only. Without scripts or under reduced motion the
846
+ * composed poster shows with every crop in place.
847
+ */
848
+ function PosterStack({ image, lines, place, link, className = "" }: Inner) {
849
+ const stage = useRef<HTMLElement>(null);
850
+ const rows = lines.flatMap((l) => l.split(" ")).filter(Boolean);
851
+ const n = rows.length;
852
+ const m = Math.max(0, n - 1);
853
+ const middle = Math.floor((m - 1) / 2);
854
+ // Each window's crop as a share down the picture; the middle window is the registered one.
855
+ const crops = Array.from({ length: m }, (_, j) => (j === middle || m === 1 ? 0.5 : 0.1 + (0.8 * j) / (m - 1)));
856
+ const longest = Math.max(...rows.map((w) => w.length));
857
+
858
+ useGSAP(
859
+ () => {
860
+ const el = stage.current;
861
+ if (!el) return;
862
+ const q = (s: string) => Array.from(el.querySelectorAll<HTMLElement>(s));
863
+ const wordEls = q("[data-hp-word]");
864
+ const rowEls = q("[data-hp-row]");
865
+ const slots = q("[data-hp-slot]");
866
+ const photos = q("[data-hp-photo]");
867
+ const foot = el.querySelector<HTMLElement>("[data-hp-foot]");
868
+ if (!wordEls.length || slots.length !== photos.length) return;
869
+ let dead = false;
870
+ const late = lateOf(el);
871
+
872
+ const fit = () => {
873
+ const W = el.clientWidth;
874
+ const H = el.clientHeight;
875
+ const wide = W >= 768;
876
+ const room = W - (wide ? W * 0.08 : 48);
877
+ const widest = Math.max(1, ...wordEls.map((w) => widthAt100(w, false)));
878
+ const free = H - (foot?.offsetHeight ?? 0) - (wide ? H * 0.1 : 72) - m * H * (wide ? 0.075 : 0.08);
879
+ return [Math.floor(Math.min((100 * room) / widest, free / (LEADING * n)))];
880
+ };
881
+
882
+ const listeners: Array<() => void> = [];
883
+ const frame = () => {
884
+ for (const f of listeners) f();
885
+ };
886
+ const sizes = sizer(el, ["--hp-s"], () => [parseFloat(getComputedStyle(wordEls[0]).fontSize)], late);
887
+ const refit = () => {
888
+ if (dead) return;
889
+ sizes.set(fit(), frame);
890
+ };
891
+ const faceIn = faceReady();
892
+ sizes.set(fit(), frame);
893
+ faceIn.then(() => {
894
+ refit();
895
+ sizes.show();
896
+ });
897
+ const onFonts = () => refit();
898
+ document.fonts?.addEventListener?.("loadingdone", onFonts);
899
+ window.addEventListener("resize", refit);
900
+ for (const img of q("img") as HTMLImageElement[]) img.decode?.().catch(() => {});
901
+
902
+ const masks = q("[data-hp-rise]");
903
+ const mm = gsap.matchMedia();
904
+ mm.add(
905
+ {
906
+ wide: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
907
+ phone: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
908
+ },
909
+ (ctx) => {
910
+ const wide = Boolean(ctx.conditions?.wide);
911
+ const s = { open: late || covered() ? 1 : 0, grow: 0 };
912
+ type Box = { x: number; y: number; w: number; h: number };
913
+ const g = { W: 1, H: 1, slots: [] as Box[], d: [] as number[], travel: [] as number[], foot: 0 };
914
+ const measure = () => {
915
+ g.W = el.clientWidth;
916
+ g.H = el.clientHeight;
917
+ const b = el.getBoundingClientRect();
918
+ g.slots = slots.map((sl) => {
919
+ const r = sl.getBoundingClientRect();
920
+ return { x: r.left - b.left, y: r.top - b.top, w: r.width, h: r.height };
921
+ });
922
+ // Each window's photo is the screen, placed at the screen's origin and moved so the window shows its
923
+ // crop: the registered middle not at all, the others by the distance to their part of the picture.
924
+ g.d = g.slots.map((sl, j) => (j === middle ? 0 : Math.round(sl.y - crops[j] * (g.H - sl.h))));
925
+ if (wide) {
926
+ photos.forEach((p, j) =>
927
+ Object.assign(p.style, {
928
+ left: `${Math.round(-g.slots[j].x)}px`,
929
+ top: `${Math.round(-g.slots[j].y)}px`,
930
+ width: `${g.W}px`,
931
+ height: `${g.H}px`,
932
+ right: "auto",
933
+ bottom: "auto",
934
+ }),
935
+ );
936
+ }
937
+ // How far each row travels to clear the screen: the upper half up, the lower half down.
938
+ g.travel = rowEls.map((r, i) =>
939
+ i < n / 2 ? -(r.offsetTop + r.offsetHeight + 8) : g.H - r.offsetTop + 8,
940
+ );
941
+ g.foot = foot ? g.H - foot.offsetTop + 8 : 0;
942
+ };
943
+ const paint = () => {
944
+ if (!wide) {
945
+ const v = ((1 - s.open) * 50).toFixed(3);
946
+ for (const p of photos) p.style.clipPath = `inset(${v}% 0% ${v}% 0%)`;
947
+ return;
948
+ }
949
+ g.slots.forEach((sl, j) => {
950
+ const mid = sl.y + sl.h / 2;
951
+ const y0 = lerp(lerp(mid - 0.5, sl.y, s.open), 0, s.grow);
952
+ const y1 = lerp(lerp(mid + 0.5, sl.y + sl.h, s.open), g.H, s.grow);
953
+ const x0 = lerp(sl.x, 0, s.grow);
954
+ const x1 = lerp(sl.x + sl.w, g.W, s.grow);
955
+ const d = Math.round(g.d[j] * (1 - s.grow));
956
+ const p = photos[j];
957
+ p.style.transform = `translate3d(0, ${d}px, 0)`;
958
+ p.style.clipPath = `inset(${Math.round(y0 - d)}px ${Math.round(g.W - x1)}px ${Math.round(g.H - y1 + d)}px ${Math.round(x0)}px)`;
959
+ });
960
+ rowEls.forEach((r, i) => {
961
+ r.style.transform = `translate3d(0, ${Math.round(g.travel[i] * s.grow)}px, 0)`;
962
+ });
963
+ if (foot) foot.style.transform = `translate3d(0, ${Math.round(g.foot * s.grow)}px, 0)`;
964
+ };
965
+ const again = () => {
966
+ measure();
967
+ paint();
968
+ };
969
+
970
+ el.dataset.live = wide ? "wide" : "phone";
971
+ gsap.set(masks, { y: 0, yPercent: late ? 0 : 112 });
972
+ again();
973
+ listeners.push(again);
974
+
975
+ let intro: gsap.core.Timeline | null = null;
976
+ let cancel = () => {};
977
+ if (!late) {
978
+ intro = gsap.timeline({ delay: 0.15, paused: true });
979
+ intro.to(masks, { yPercent: 0, duration: 1.1, stagger: 0.08, ease: "expo.out" }, 0);
980
+ if (s.open < 1) intro.to(s, { open: 1, duration: 1.1, ease: "power3.inOut", onUpdate: paint }, 0.35);
981
+ faceIn.then(() => {
982
+ if (!dead) cancel = whenUncovered(() => intro?.play());
983
+ });
984
+ }
985
+
986
+ if (wide) {
987
+ const tl = gsap.timeline({
988
+ defaults: { ease: "none" },
989
+ onUpdate: paint,
990
+ scrollTrigger: { trigger: el, start: "top top", end: "+=150%", pin: true, scrub: 0.4, onRefresh: again },
991
+ });
992
+ tl.to({}, { duration: 0.5 })
993
+ .to(s, { grow: 1, duration: 1.3, ease: "power3.inOut" }, 0.5)
994
+ .to({}, { duration: 0.6 }, 1.8);
995
+ }
996
+
997
+ return () => {
998
+ cancel();
999
+ intro?.kill();
1000
+ listeners.splice(listeners.indexOf(again), 1);
1001
+ delete el.dataset.live;
1002
+ for (const p of photos) {
1003
+ p.style.clipPath = "";
1004
+ p.style.transform = "";
1005
+ for (const k of ["left", "top", "width", "height", "right", "bottom"] as const) p.style[k] = "";
1006
+ }
1007
+ for (const r of rowEls) r.style.transform = "";
1008
+ if (foot) foot.style.transform = "";
1009
+ };
1010
+ },
1011
+ );
1012
+
1013
+ return () => {
1014
+ dead = true;
1015
+ document.fonts?.removeEventListener?.("loadingdone", onFonts);
1016
+ window.removeEventListener("resize", refit);
1017
+ mm.revert();
1018
+ };
1019
+ },
1020
+ { scope: stage, dependencies: [lines[0], lines[1]] },
1021
+ );
1022
+
1023
+ // The script-free estimate: the longest word over the width, capped so the rows and their windows fit the height.
1024
+ const per = (longest * EM_PER_CHAR).toFixed(2);
1025
+ const est = `min(calc(92vw / ${per}), calc((90svh - 2rem - ${m} * 7.5svh) / ${(n * LEADING).toFixed(2)}))`;
1026
+ const estPhone = `min(calc((100vw - 48px) / ${per}), calc((100svh - 7rem - ${m} * 8svh) / ${(n * LEADING).toFixed(2)}))`;
1027
+
1028
+ return (
1029
+ <section
1030
+ ref={stage}
1031
+ data-hero-poster=""
1032
+ data-hp-variant="stack"
1033
+ aria-label={`${lines[0]} ${lines[1]}`}
1034
+ className={`group/hp relative h-svh overflow-hidden bg-surface-0 text-ink ${className}`}
1035
+ style={{ "--hp-e": est, "--hp-p": estPhone } as React.CSSProperties}
1036
+ >
1037
+ <h1 className="sr-only">{`${lines[0]} ${lines[1]}`}</h1>
1038
+ <div className="relative flex h-full flex-col px-6 pb-6 pt-8 md:px-[4vw] md:pb-[3svh] md:pt-[4svh]">
1039
+ {rows.map((w, i) => (
1040
+ <div key={i} className="contents">
1041
+ <div data-hp-row="" className="relative z-10 will-change-transform">
1042
+ <p aria-hidden className="m-0">
1043
+ <span className={`${MASK} text-[length:var(--hp-s,var(--hp-p))] md:text-[length:var(--hp-s,var(--hp-e))]`}>
1044
+ <span data-hp-rise="" data-hp-line="" className="block">
1045
+ <span data-hp-word="" className={`${WORD} ${i % 2 ? "ml-auto text-right" : ""}`} style={NOWRAP}>
1046
+ {w}
1047
+ </span>
1048
+ </span>
1049
+ </span>
1050
+ </p>
1051
+ </div>
1052
+ {i < m ? (
1053
+ <div className="relative my-[1svh] min-h-[5svh] flex-1">
1054
+ <div
1055
+ data-hp-slot=""
1056
+ {...(i === middle ? { "data-stack-target": "" } : {})}
1057
+ className="absolute inset-0 overflow-hidden bg-surface-2 group-data-[live=wide]/hp:overflow-visible group-data-[live=wide]/hp:bg-transparent"
1058
+ >
1059
+ <div
1060
+ data-hp-photo=""
1061
+ {...(i === middle ? { "data-stack-photo": "" } : {})}
1062
+ className="absolute inset-0 will-change-transform"
1063
+ style={{ "--hp-crop": `50% ${Math.round(crops[i] * 100)}%` } as React.CSSProperties}
1064
+ >
1065
+ <Image
1066
+ src={image.src}
1067
+ alt={i === middle ? image.alt : ""}
1068
+ fill
1069
+ priority
1070
+ sizes="100vw"
1071
+ unoptimized={image.unoptimized}
1072
+ className="object-cover [object-position:var(--hp-crop)] [filter:saturate(0.9)_contrast(1.05)] group-data-[live=wide]/hp:[object-position:50%_50%]"
1073
+ />
1074
+ </div>
1075
+ </div>
1076
+ </div>
1077
+ ) : null}
1078
+ </div>
1079
+ ))}
1080
+ <div className="mt-auto">
1081
+ <Foot place={place} link={link} />
1082
+ </div>
1083
+ </div>
1084
+ </section>
1085
+ );
1086
+ }