@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,862 @@
1
+ "use client";
2
+ // hero-still (Struq hero recipe)
3
+ // A film still with a title. One photograph edge to edge, held with a very slow push-in (six percent over twenty
4
+ // seconds); the headline rises line by line through masks at display size on the photo's calm side; under the still,
5
+ // on the page ground like a film's caption strip, a quiet bar with three facts on one line: the studio's place, the
6
+ // live local time, one link. On scroll
7
+ // the photograph moves slower than the page and the headline leaves first, so the hero hands over with depth.
8
+ //
9
+ // <HeroStill image={images.hero[0]} lines={["Ruimte die", "blijft."]} place="Noord, Rotterdam"
10
+ // timeZone="Europe/Amsterdam" link={{ href: "/werk", label: "Bekijk het werk" }} />
11
+ //
12
+ // The photo's box carries data-stack-target, so preloader-stack and page-transition-stack land on it exactly, and the
13
+ // entrance starts only when the cover lifts (whenUncovered), at once when there is none. The push-in never re-rasters:
14
+ // the photograph is laid out six percent larger than its box and shown scaled down to fit, so at rest it is exactly
15
+ // an object-cover of the box, and the push-in only scales that layer back up to its own size (a compositor animation,
16
+ // rastered once at the largest scale it reaches). It starts a moment after the entrance, so a preloader landing on the
17
+ // box always meets the photo at rest.
18
+ //
19
+ // Side and veil come from the photograph itself. Unless a side is given, the headline takes whichever side needs the
20
+ // weaker veil (decided while its lines are still under their masks, so the choice is never seen; the photo's
21
+ // negativeSpace sets the first paint and breaks a tie), and the veil is the weakest that gives the display-size
22
+ // headline 3:1 over what is actually under its glyphs, never past 0.45 (read from a small canvas; a photo on another
23
+ // origin cannot be read and keeps the safe veil the CSS starts with). The caption sits on the ground, in the ink.
24
+ //
25
+ // The first paint is the composed still: the photo and its veil; the headline and the caption wait below their masks
26
+ // and arrive with the entrance, or after 3 seconds if the script never runs (a script that arrives after that leaves
27
+ // them in place) (blocks.css, "--- hero-still ---"). Phones:
28
+ // the same still, the headline sized to fit, the caption on two lines. Reduced motion: no push-in, no rise, no drift.
29
+ // Without scripts: the composed still with everything in place.
30
+ //
31
+ // Three variants of the same still, picked with `variant`:
32
+ // - still (the default, above).
33
+ // - frame: the photograph held inside generous margins of page ground, the headline on the ground beside it in the
34
+ // ink. On the first scroll (pin 100%) the frame opens by registration to the full bleed of the still while the
35
+ // headline leaves upward; the caption strip stays. Phones: a smaller frame with the headline under it, and the
36
+ // still's own hand-over.
37
+ // - horizon: the headline set across the photograph's horizon (found in the photo itself), its upper lines on the sky
38
+ // or wall side and its lower lines on the ground side, each half in the ink that side measures for, over its own
39
+ // light veil band. The upper lines rise to the horizon and the lower lines drop to it; on scroll the headline rides
40
+ // with the photograph, so it stays on its horizon as the still hands over.
41
+ import Image from "next/image";
42
+ import { useEffect, useRef, useState } from "react";
43
+ import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
44
+ import type { StruqImage } from "../kit/images";
45
+ import { photoLuminances, type PhotoBox } from "../kit/photo-read";
46
+ import { covered, whenUncovered } from "../preloaders/preloader-slices-uncovered";
47
+
48
+ export const HERO_STILL_VARIANTS = [
49
+ { id: "still", title: "Still" },
50
+ { id: "frame", title: "Kader" },
51
+ { id: "horizon", title: "Horizon" },
52
+ ] as const;
53
+
54
+ export type HeroStillVariant = (typeof HERO_STILL_VARIANTS)[number]["id"];
55
+
56
+ type Props = Inner & {
57
+ /** The composition: the full-bleed still (default), the photo in a frame of ground, or the headline on the horizon. */
58
+ variant?: HeroStillVariant;
59
+ };
60
+
61
+ type Inner = {
62
+ image: StruqImage;
63
+ /** The headline, one entry per line, display size. Two or three short lines read best. */
64
+ lines: string[];
65
+ /** The studio's place, the caption's first fact. */
66
+ place: string;
67
+ /** IANA zone for the live local time, the caption's second fact. */
68
+ timeZone?: string;
69
+ /** The caption's third fact: one link. */
70
+ link: { href: string; label: string };
71
+ /** Where the headline stands. Left out, the block reads the photo and takes the side that needs the weaker veil
72
+ * (the photo's negativeSpace breaks a tie); the first paint, and a page without scripts, use negativeSpace. */
73
+ side?: "left" | "right";
74
+ /** horizon only: where the photo's horizon lies, as a share of the still's height. Left out, the block looks for
75
+ * the strongest change of brightness across the photo's middle band (a horizon of texture, such as a wall meeting
76
+ * gravel of the same tone, is not found that way and is best given). */
77
+ horizon?: number;
78
+ className?: string;
79
+ };
80
+
81
+ const HEAD_RATIO = 3; // display-size type: WCAG large text
82
+ const MAX_VEIL = 0.45; // past this the photo reads as a dimmed banner, not a still
83
+
84
+ const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
85
+ const encode = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
86
+ const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
87
+
88
+ /** The relative luminance of any CSS colour, through a one-pixel canvas so every colour syntax resolves. */
89
+ function luminanceOf(color: string): number {
90
+ const cv = document.createElement("canvas");
91
+ cv.width = 1;
92
+ cv.height = 1;
93
+ const ctx = cv.getContext("2d", { willReadFrequently: true });
94
+ if (!ctx) return 0;
95
+ ctx.fillStyle = "#000";
96
+ ctx.fillStyle = color;
97
+ ctx.fillRect(0, 0, 1, 1);
98
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
99
+ return 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255);
100
+ }
101
+
102
+ /**
103
+ * The brightest part (the top tenth) of the photo under a box, the box in the photo box's own coordinates, with the
104
+ * photo shown as object-cover of that box. Null when the photo cannot be read.
105
+ */
106
+ function brightUnder(img: HTMLImageElement, box: { w: number; h: number }, at: DOMRect, from: DOMRect): number | null {
107
+ const ys = photoLuminances(
108
+ img,
109
+ { x: from.left, y: from.top, w: box.w, h: box.h },
110
+ { x: at.left, y: at.top, w: at.width, h: at.height },
111
+ );
112
+ return ys ? ys[Math.floor(ys.length * 0.9)] : null;
113
+ }
114
+
115
+ /** The weakest veil (composited in sRGB, as the browser does) that brings `under` to `ratio` against `ink`. */
116
+ function veilFor(under: number, ink: number, veilS: number, ratio: number): number {
117
+ if (contrast(under, ink) >= ratio) return 0;
118
+ const s = encode(under);
119
+ const ok = (a: number) => contrast(linear(s * (1 - a) + veilS * a), ink) >= ratio;
120
+ if (!ok(MAX_VEIL)) return MAX_VEIL;
121
+ let lo = 0;
122
+ let hi = MAX_VEIL;
123
+ for (let i = 0; i < 14; i += 1) {
124
+ const mid = (lo + hi) / 2;
125
+ if (ok(mid)) hi = mid;
126
+ else lo = mid;
127
+ }
128
+ return hi;
129
+ }
130
+
131
+ /** The time in a zone as HH:MM, the same five characters at every minute. */
132
+ function timeIn(zone: string) {
133
+ return new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: zone }).format(
134
+ new Date(),
135
+ );
136
+ }
137
+
138
+ /** The veil under a light ink: the page ground held dark (its own hue, never black), as the CSS sets it. */
139
+ const DIM_VEIL = "oklch(from var(--surface-0) min(l, 0.2) c h)";
140
+
141
+ /** The luminance of a colour as the page resolves it (custom properties, relative colours), through a probe. */
142
+ function resolvedLuminance(host: HTMLElement, css: string) {
143
+ const probe = document.createElement("span");
144
+ probe.style.display = "none";
145
+ probe.style.color = css;
146
+ host.appendChild(probe);
147
+ const c = getComputedStyle(probe).color;
148
+ probe.remove();
149
+ return luminanceOf(c);
150
+ }
151
+
152
+ export type InkPick = { ink: string; tone: "dark" | "light"; veil: number };
153
+
154
+ /**
155
+ * Of the light photo ink and the darkest ink the page has, the one that needs the weaker veil to hold 3:1 (display
156
+ * type) over what the photo holds under `boxes` (in the coordinates of `frame`, the box the photo covers): the light
157
+ * ink against the brightest tenth with the dark veil, the dark ink against the darkest tenth with a pale veil of the
158
+ * light ink. A near tie, or a palette without a dark ink, keeps the light ink. Null when the photo cannot be read.
159
+ */
160
+ export function inkOver(img: HTMLImageElement, frame: PhotoBox, boxes: PhotoBox[], host: HTMLElement): InkPick | null {
161
+ const ys: number[] = [];
162
+ for (const b of boxes) {
163
+ const r = photoLuminances(img, frame, b);
164
+ if (!r) return null;
165
+ ys.push(...r);
166
+ }
167
+ if (!ys.length) return null;
168
+ ys.sort((a, b) => a - b);
169
+ const hi = ys[Math.floor(ys.length * 0.9)];
170
+ const lo = ys[Math.floor(ys.length * 0.1)];
171
+ const light = resolvedLuminance(host, "var(--on-photo)");
172
+ const dark = ["--ink", "--inverse", "--surface-0"]
173
+ .map((v) => ({ v, y: resolvedLuminance(host, `var(${v})`) }))
174
+ .sort((a, b) => a.y - b.y)[0];
175
+ const forLight = veilFor(hi, light, encode(resolvedLuminance(host, DIM_VEIL)), HEAD_RATIO);
176
+ const forDark = veilFor(lo, dark.y, encode(light), HEAD_RATIO);
177
+ if (contrast(dark.y, light) >= HEAD_RATIO && forDark < forLight - 0.04) {
178
+ return { ink: `var(${dark.v})`, tone: "light", veil: forDark };
179
+ }
180
+ return { ink: "var(--on-photo)", tone: "dark", veil: forLight };
181
+ }
182
+
183
+ export function HeroStill({ variant = "still", ...rest }: Props) {
184
+ if (variant === "frame") return <StillFrame {...rest} />;
185
+ if (variant === "horizon") return <StillHorizon {...rest} />;
186
+ return <StillFilm {...rest} />;
187
+ }
188
+
189
+ function StillFilm({ image, lines, place, timeZone = "Europe/Amsterdam", link, side, className = "" }: Inner) {
190
+ const root = useRef<HTMLElement>(null);
191
+ const first = side ?? (image.negativeSpace === "right" ? "right" : "left");
192
+ const [time, setTime] = useState(() => timeIn(timeZone));
193
+
194
+ // The clock: renewed on each minute, in tabular figures, so nothing around it ever moves.
195
+ useEffect(() => {
196
+ let tick = 0;
197
+ const next = () => {
198
+ setTime(timeIn(timeZone));
199
+ tick = window.setTimeout(next, 60_000 - (Date.now() % 60_000) + 50);
200
+ };
201
+ next();
202
+ return () => window.clearTimeout(tick);
203
+ }, [timeZone]);
204
+
205
+ useGSAP(
206
+ (_, contextSafe) => {
207
+ const el = root.current;
208
+ if (!el || !contextSafe) return;
209
+ const q = gsap.utils.selector(el);
210
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
211
+ const frame = q("[data-stack-target]")[0] as HTMLElement;
212
+ const img = frame.querySelector("img") as HTMLImageElement;
213
+ const veil = q("[data-veil]")[0] as HTMLElement;
214
+ const rise = q("[data-rise]");
215
+ const head = q("[data-head]")[0] as HTMLElement;
216
+
217
+ const h1 = head.querySelector("h1") as HTMLElement;
218
+ // Only what is under the type: the glyphs' own width (a range; the rise moves the lines vertically only), over
219
+ // the height of an untransformed box that holds them.
220
+ const under = (n: HTMLElement, holder: HTMLElement = n) => {
221
+ const range = document.createRange();
222
+ range.selectNodeContents(n);
223
+ const text = range.getBoundingClientRect();
224
+ const own = holder.getBoundingClientRect();
225
+ return new DOMRect(text.left, own.top, text.width, own.height);
226
+ };
227
+
228
+ /**
229
+ * The veil from the photo itself, and (unless the side was given, and only while the headline is still under
230
+ * its masks) the side the headline stands on: whichever needs the weaker veil. The headline holds 3:1 (display
231
+ * size), the veil stops at MAX_VEIL; the caption sits on the page ground. The CSS's safe veil stays when the photo
232
+ * cannot be read.
233
+ */
234
+ const grade = () => {
235
+ const ink = luminanceOf(getComputedStyle(el).color);
236
+ const veilS = encode(luminanceOf(getComputedStyle(veil).backgroundColor));
237
+ const from = frame.getBoundingClientRect();
238
+ const box = { w: from.width, h: from.height };
239
+ const read = (n: HTMLElement, holder?: HTMLElement) => brightUnder(img, box, under(n, holder), from);
240
+ const headNeed = () => {
241
+ const r = read(h1);
242
+ return r === null ? null : veilFor(r, ink, veilS, HEAD_RATIO);
243
+ };
244
+ let need = headNeed();
245
+ if (need === null) return;
246
+ if (!side && !shown) {
247
+ const was = el.dataset.side;
248
+ el.dataset.side = was === "right" ? "left" : "right";
249
+ const other = headNeed();
250
+ // The other side only when it is clearly better; a tie keeps the photo's own calm side.
251
+ if (other !== null && other < need - 0.02) need = other;
252
+ else el.dataset.side = was;
253
+ }
254
+ graded = need;
255
+ if (played || !covered()) veil.style.opacity = graded.toFixed(3);
256
+ };
257
+ let graded: number | null = null;
258
+ let played = false;
259
+ // Under a preloader the photo is shown bare, the way the preloader's own print of it lands; the veil then
260
+ // settles in with the headline, so the landing never darkens in one frame.
261
+ if (covered()) {
262
+ veil.style.transition = "none";
263
+ veil.style.opacity = "0";
264
+ }
265
+
266
+ let cancel = () => {};
267
+ // Strict mode runs this twice; a decode that settles after its run was torn down must do nothing.
268
+ let gone = false;
269
+ const decoded = img.decode().catch(() => undefined);
270
+ // A late script: the CSS fallback (3s) may already have shown the lines. Then they stay; the script still grades
271
+ // the veil and starts the push-in, but never hides and raises them a second time.
272
+ // Decided once per element: strict mode's second run must not read the state its own first run left behind.
273
+ // The answer is this element's own fallback clock, which starts when the element is first styled (at the first
274
+ // paint, or when a client-side navigation mounts it): once it has run 2.9s the CSS has shown the lines.
275
+ const clock = rise[0]
276
+ .getAnimations()
277
+ .find((a) => (a as CSSAnimation).animationName === "struq-hs-rise");
278
+ el.dataset.shown ??= String(
279
+ reduce || (clock ? Number(clock.currentTime ?? 0) >= 2900 : getComputedStyle(rise[0]).transform === "none"),
280
+ );
281
+ const shown = el.dataset.shown === "true";
282
+ // Shown means in place, whatever state the element came back in.
283
+ if (shown) gsap.set(rise, { y: 0, yPercent: 0 });
284
+ // y: 0 as well: GSAP would otherwise read the CSS start state (translateY 115%) into a pixel y and keep it.
285
+ if (!shown) gsap.set(rise, { y: 0, yPercent: 115 });
286
+ el.dataset.ready = "";
287
+ decoded.then(() => {
288
+ if (gone) return;
289
+ grade();
290
+ const play = contextSafe(() => {
291
+ played = true;
292
+ veil.style.transition = "opacity 1.2s cubic-bezier(0.16, 1, 0.3, 1)";
293
+ veil.style.opacity = (graded ?? 0.4).toFixed(3);
294
+ if (reduce) {
295
+ gsap.set(rise, { yPercent: 0 });
296
+ return;
297
+ }
298
+ if (!shown) gsap.to(rise, { yPercent: 0, duration: 1.15, ease: "expo.out", stagger: 0.09, delay: 0.1 });
299
+ // The push-in, a moment later, so a preloader landing on the box meets the photo at rest.
300
+ el.dataset.pushing = "";
301
+ });
302
+ // The signal says a cover has started to leave; the headline rises once it can actually be seen (nothing of
303
+ // a cover over its first line any more), at most 1.6s later.
304
+ const seen = () => {
305
+ const line = rise[0]?.parentElement?.getBoundingClientRect();
306
+ if (!line) return true;
307
+ const hit = document.elementFromPoint(line.left + 8, line.top + line.height / 2);
308
+ return !hit || el.contains(hit);
309
+ };
310
+ let watch = 0;
311
+ const whenSeen = () => {
312
+ const until = performance.now() + 1600;
313
+ const look = () => {
314
+ if (gone) return;
315
+ if (seen() || performance.now() > until) play();
316
+ else watch = requestAnimationFrame(look);
317
+ };
318
+ look();
319
+ };
320
+ const stop = whenUncovered(whenSeen);
321
+ cancel = () => {
322
+ stop();
323
+ cancelAnimationFrame(watch);
324
+ };
325
+ });
326
+
327
+ // The hand-over: the photograph drifts down (moves slower than the page), the headline leaves first.
328
+ if (!reduce) {
329
+ gsap
330
+ .timeline({ scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.4 } })
331
+ .to(q("[data-drift]"), { yPercent: 22, ease: "none" }, 0)
332
+ .to(head, { yPercent: -60, ease: "none", duration: 0.55 }, 0);
333
+ }
334
+ const refresh = () => ScrollTrigger.refresh();
335
+ document.fonts?.ready.then(refresh);
336
+ window.addEventListener("resize", grade);
337
+ return () => {
338
+ gone = true;
339
+ cancel();
340
+ window.removeEventListener("resize", grade);
341
+ // data-ready and data-shown stay: a remount must not bring the CSS start state (and its 3s clock) back.
342
+ delete el.dataset.pushing;
343
+ };
344
+ },
345
+ { scope: root },
346
+ );
347
+
348
+ const mask = (child: React.ReactNode, key?: number) => (
349
+ <span key={key} className="struq-line-mask block overflow-hidden">
350
+ <span data-rise="" className="block">
351
+ {child}
352
+ </span>
353
+ </span>
354
+ );
355
+
356
+ return (
357
+ <section
358
+ ref={root}
359
+ data-hero-still=""
360
+ data-side={first}
361
+ suppressHydrationWarning
362
+ className={`group/hs relative flex h-svh min-h-[32rem] flex-col overflow-hidden bg-surface-0 text-on-photo ${className}`}
363
+ >
364
+ {/* The still: the photograph with its headline, above the caption bar. */}
365
+ <div data-still="" className="relative min-h-0 flex-1">
366
+ {/* The photo's box: what a preloader lands on. Inside, the photograph sits six percent larger than the box and
367
+ is shown scaled back to it, so the slow push-in only ever scales it up to its own raster. */}
368
+ <div data-stack-target="" className="absolute inset-0 overflow-hidden">
369
+ <div data-drift="" className="absolute inset-0">
370
+ <div data-push="" className="absolute -inset-[3%]">
371
+ <Image
372
+ src={image.src}
373
+ alt={image.alt}
374
+ fill
375
+ priority
376
+ sizes="100vw"
377
+ unoptimized={image.unoptimized}
378
+ className="object-cover"
379
+ />
380
+ </div>
381
+ </div>
382
+ <div data-veil="" aria-hidden="true" className="absolute inset-0" />
383
+ </div>
384
+
385
+ <div
386
+ data-head=""
387
+ className="absolute inset-x-0 bottom-[5vh] px-6 group-data-[side=right]/hs:flex group-data-[side=right]/hs:justify-end group-data-[side=right]/hs:text-right md:bottom-[6vh] md:px-[4vw]"
388
+ >
389
+ <h1 className="font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] md:text-[clamp(3.5rem,8vw,9.5rem)]">
390
+ {lines.map((line, i) => mask(line, i))}
391
+ </h1>
392
+ </div>
393
+ </div>
394
+
395
+ {/* The caption bar, on the page ground under the still like a film's caption strip: three facts on one line (two
396
+ on phones), the clock in tabular figures at a fixed width. On the ground its ink holds on any photograph. */}
397
+ <div data-bar="" className="text-small relative bg-surface-0 px-6 pb-4 pt-3.5 text-ink md:px-[4vw] md:pb-5 md:pt-4">
398
+ <div>
399
+ {mask(
400
+ <span className="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1 md:grid-cols-[1fr_auto_1fr]">
401
+ <span>{place}</span>
402
+ <span className="tabular-nums lining-nums md:text-center">
403
+ <time suppressHydrationWarning className="inline-block min-w-[5ch] text-right">
404
+ {time}
405
+ </time>{" "}
406
+ lokale tijd
407
+ </span>
408
+ <a href={link.href} className="col-span-2 underline decoration-1 underline-offset-4 md:col-span-1 md:justify-self-end">
409
+ {link.label}
410
+ </a>
411
+ </span>,
412
+ )}
413
+ </div>
414
+ </div>
415
+ </section>
416
+ );
417
+ }
418
+
419
+ // The frame and the horizon share what follows: the clock, the caption strip, the line masks and the entrance.
420
+
421
+ function useClock(timeZone: string) {
422
+ const [time, setTime] = useState(() => timeIn(timeZone));
423
+ useEffect(() => {
424
+ let tick = 0;
425
+ const next = () => {
426
+ setTime(timeIn(timeZone));
427
+ tick = window.setTimeout(next, 60_000 - (Date.now() % 60_000) + 50);
428
+ };
429
+ next();
430
+ return () => window.clearTimeout(tick);
431
+ }, [timeZone]);
432
+ return time;
433
+ }
434
+
435
+ const lineMask = (child: React.ReactNode, key?: number) => (
436
+ <span key={key} className="struq-line-mask block overflow-hidden">
437
+ <span data-rise="" className="block">
438
+ {child}
439
+ </span>
440
+ </span>
441
+ );
442
+
443
+ /** The caption strip on the page ground, as the still has it: place, the live local time, one link. */
444
+ function Caption({ place, time, link }: { place: string; time: string; link: Inner["link"] }) {
445
+ return (
446
+ <div data-bar="" className="text-small relative bg-surface-0 px-6 pb-4 pt-3.5 text-ink md:px-[4vw] md:pb-5 md:pt-4">
447
+ <div>
448
+ {lineMask(
449
+ <span className="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1 md:grid-cols-[1fr_auto_1fr]">
450
+ <span>{place}</span>
451
+ <span className="tabular-nums lining-nums md:text-center">
452
+ <time suppressHydrationWarning className="inline-block min-w-[5ch] text-right">
453
+ {time}
454
+ </time>{" "}
455
+ lokale tijd
456
+ </span>
457
+ <a href={link.href} className="col-span-2 underline decoration-1 underline-offset-4 md:col-span-1 md:justify-self-end">
458
+ {link.label}
459
+ </a>
460
+ </span>,
461
+ )}
462
+ </div>
463
+ </div>
464
+ );
465
+ }
466
+
467
+ type Safe = (fn: () => void) => () => void;
468
+
469
+ /**
470
+ * The still's entrance for the frame and the horizon: once the photo is decoded and nothing covers the headline, its
471
+ * lines rise through their masks (lines under [data-below] drop from above instead) and the push-in starts. A script
472
+ * that arrives after the CSS fallback (3s) leaves the lines in place. `before` runs after the decode, while the lines
473
+ * still wait; `play` as they start. Returns whether the lines were already shown, and the cleanup.
474
+ */
475
+ function entrance(
476
+ el: HTMLElement,
477
+ img: HTMLImageElement,
478
+ safe: Safe,
479
+ opts: { reduce: boolean; before?: () => void; play?: () => void },
480
+ ) {
481
+ const rise = Array.from(el.querySelectorAll<HTMLElement>("[data-rise]"));
482
+ const from = (n: Element) => (n.closest("[data-below]") ? -115 : 115);
483
+ // The element's own fallback clock, read once per element (strict mode's second run must not read its first).
484
+ const clock = rise[0]?.getAnimations().find((a) => (a as CSSAnimation).animationName === "struq-hs-rise");
485
+ el.dataset.shown ??= String(
486
+ opts.reduce || (clock ? Number(clock.currentTime ?? 0) >= 2900 : getComputedStyle(rise[0]).transform === "none"),
487
+ );
488
+ const shown = el.dataset.shown === "true";
489
+ // y: 0 as well: GSAP would otherwise read the CSS start state into a pixel y and keep it.
490
+ gsap.set(rise, { y: 0, yPercent: (_: number, n: Element) => (shown ? 0 : from(n)) });
491
+ el.dataset.ready = "";
492
+ let gone = false;
493
+ let cancel = () => {};
494
+ img
495
+ .decode()
496
+ .catch(() => undefined)
497
+ .then(() => {
498
+ if (gone) return;
499
+ opts.before?.();
500
+ const play = safe(() => {
501
+ opts.play?.();
502
+ if (opts.reduce) {
503
+ gsap.set(rise, { yPercent: 0 });
504
+ return;
505
+ }
506
+ if (!shown) gsap.to(rise, { yPercent: 0, duration: 1.15, ease: "expo.out", stagger: 0.09, delay: 0.1 });
507
+ el.dataset.pushing = "";
508
+ });
509
+ // The lines start once a leaving cover no longer hides the first of them, at most 1.6s after it starts to go.
510
+ const seen = () => {
511
+ const line = rise[0]?.parentElement?.getBoundingClientRect();
512
+ if (!line) return true;
513
+ const hit = document.elementFromPoint(line.left + 8, line.top + line.height / 2);
514
+ return !hit || el.contains(hit);
515
+ };
516
+ let watch = 0;
517
+ const stop = whenUncovered(() => {
518
+ const until = performance.now() + 1600;
519
+ const look = () => {
520
+ if (gone) return;
521
+ if (seen() || performance.now() > until) play();
522
+ else watch = requestAnimationFrame(look);
523
+ };
524
+ look();
525
+ });
526
+ cancel = () => {
527
+ stop();
528
+ cancelAnimationFrame(watch);
529
+ };
530
+ });
531
+ return {
532
+ shown,
533
+ stop: () => {
534
+ gone = true;
535
+ cancel();
536
+ delete el.dataset.pushing;
537
+ },
538
+ };
539
+ }
540
+
541
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
542
+
543
+ /**
544
+ * frame: the photograph inside margins of page ground, the headline on the ground beside it. The frame's box carries
545
+ * data-stack-target and the photo inside it is the whole still at the still's origin (data-stack-photo), so a stack
546
+ * preloader lands on the frame with the photo laid as the still, and opening the frame on the first scroll is a clip
547
+ * from whole-pixel numbers to the full bleed, never a scale. The headline leaves upward ahead of the opening edge.
548
+ */
549
+ function StillFrame({ image, lines, place, timeZone = "Europe/Amsterdam", link, className = "" }: Inner) {
550
+ const root = useRef<HTMLElement>(null);
551
+ const time = useClock(timeZone);
552
+
553
+ useGSAP(
554
+ (_, contextSafe) => {
555
+ const el = root.current;
556
+ if (!el || !contextSafe) return;
557
+ const still = el.querySelector<HTMLElement>("[data-still]");
558
+ const slot = el.querySelector<HTMLElement>("[data-hs-slot]");
559
+ const photo = el.querySelector<HTMLElement>("[data-hs-photo]");
560
+ const head = el.querySelector<HTMLElement>("[data-head]");
561
+ const img = photo?.querySelector("img");
562
+ if (!still || !slot || !photo || !head || !img) return;
563
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
564
+ const run = entrance(el, img, contextSafe as Safe, { reduce });
565
+
566
+ const mm = gsap.matchMedia();
567
+ mm.add(
568
+ {
569
+ wide: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
570
+ phone: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
571
+ },
572
+ (ctx) => {
573
+ if (!ctx.conditions?.wide) {
574
+ // Phones: the still's own hand-over, the photo slower than the page and the headline leaving first.
575
+ gsap
576
+ .timeline({ scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.4 } })
577
+ .to(el.querySelector("[data-drift]"), { yPercent: 22, ease: "none" }, 0)
578
+ .to(head, { yPercent: -60, ease: "none", duration: 0.55 }, 0);
579
+ return;
580
+ }
581
+ el.dataset.live = "";
582
+ const g = { W: 1, H: 1, x: 0, y: 0, w: 1, h: 1, head: 0 };
583
+ const s = { grow: 0 };
584
+ const measure = () => {
585
+ g.W = still.clientWidth;
586
+ g.H = still.clientHeight;
587
+ const b = still.getBoundingClientRect();
588
+ const r = slot.getBoundingClientRect();
589
+ g.x = r.left - b.left;
590
+ g.y = r.top - b.top;
591
+ g.w = r.width;
592
+ g.h = r.height;
593
+ // The photo is the still, at the still's origin, measured (the CSS places it from the viewport units).
594
+ Object.assign(photo.style, {
595
+ left: `${Math.round(-g.x)}px`,
596
+ top: `${Math.round(-g.y)}px`,
597
+ width: `${g.W}px`,
598
+ height: `${g.H}px`,
599
+ });
600
+ g.head = head.offsetTop + head.offsetHeight + 8;
601
+ };
602
+ const paint = () => {
603
+ const t = Math.round(lerp(g.y, 0, s.grow));
604
+ const r = Math.round(lerp(g.W - g.x - g.w, 0, s.grow));
605
+ const b = Math.round(lerp(g.H - g.y - g.h, 0, s.grow));
606
+ const l = Math.round(lerp(g.x, 0, s.grow));
607
+ photo.style.clipPath = `inset(${t}px ${r}px ${b}px ${l}px)`;
608
+ head.style.transform = `translate3d(0, ${Math.round(-g.head * Math.min(1, s.grow / 0.55))}px, 0)`;
609
+ };
610
+ const again = () => {
611
+ measure();
612
+ paint();
613
+ };
614
+ again();
615
+ // Hold a beat on the frame, open it to the full bleed, hold on the full still, hand over.
616
+ gsap
617
+ .timeline({
618
+ defaults: { ease: "none" },
619
+ onUpdate: paint,
620
+ scrollTrigger: { trigger: el, start: "top top", end: "+=100%", pin: true, scrub: 0.4, onRefresh: again },
621
+ })
622
+ .to({}, { duration: 0.15 })
623
+ .to(s, { grow: 1, duration: 1, ease: "power3.inOut" }, 0.15)
624
+ .to({}, { duration: 0.35 }, 1.15);
625
+ window.addEventListener("resize", again);
626
+ return () => {
627
+ window.removeEventListener("resize", again);
628
+ delete el.dataset.live;
629
+ photo.style.clipPath = "";
630
+ head.style.transform = "";
631
+ for (const k of ["left", "top", "width", "height"] as const) photo.style[k] = "";
632
+ };
633
+ },
634
+ );
635
+ document.fonts?.ready.then(() => ScrollTrigger.refresh());
636
+ return () => {
637
+ run.stop();
638
+ mm.revert();
639
+ };
640
+ },
641
+ { scope: root },
642
+ );
643
+
644
+ return (
645
+ <section
646
+ ref={root}
647
+ data-hero-still=""
648
+ data-hs-variant="frame"
649
+ suppressHydrationWarning
650
+ className={`group/hs relative flex h-svh min-h-[32rem] flex-col overflow-hidden bg-surface-0 text-ink ${className}`}
651
+ >
652
+ <div data-still="" className="relative min-h-0 flex-1">
653
+ {/* The frame: what a preloader lands on. The photo inside is the whole still, registered at its origin. */}
654
+ <div data-hs-slot="" data-stack-target="">
655
+ <div data-hs-photo="" data-stack-photo="">
656
+ <div data-drift="" className="absolute inset-0">
657
+ <div data-push="" className="absolute -inset-[3%]">
658
+ <Image
659
+ src={image.src}
660
+ alt={image.alt}
661
+ fill
662
+ priority
663
+ sizes="100vw"
664
+ unoptimized={image.unoptimized}
665
+ className="object-cover"
666
+ />
667
+ </div>
668
+ </div>
669
+ </div>
670
+ </div>
671
+ <div
672
+ data-head=""
673
+ className="absolute inset-x-6 bottom-[4svh] z-10 will-change-transform md:bottom-[3svh] md:left-[4vw] md:right-auto md:max-w-[34vw]"
674
+ >
675
+ <h1 className="font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] md:text-[clamp(3rem,5.4vw,7.5rem)]">
676
+ {lines.map((line, i) => lineMask(line, i))}
677
+ </h1>
678
+ </div>
679
+ </div>
680
+ <Caption place={place} time={time} link={link} />
681
+ </section>
682
+ );
683
+ }
684
+
685
+ /**
686
+ * horizon: the photograph edge to edge as in the still, the headline split at its horizon. The horizon is the
687
+ * strongest change of brightness across the photo's middle band, where both halves of the headline still fit (56%
688
+ * when the photo cannot be read). Each half takes the ink that needs the weaker veil over what is under its own
689
+ * glyphs, over its own veil band that ends at the horizon; the side is measured the same way unless it is given.
690
+ */
691
+ function StillHorizon({ image, lines, place, timeZone = "Europe/Amsterdam", link, side, horizon, className = "" }: Inner) {
692
+ const root = useRef<HTMLElement>(null);
693
+ const time = useClock(timeZone);
694
+ const first = side ?? (image.negativeSpace === "right" ? "right" : "left");
695
+ const up = lines.slice(0, Math.ceil(lines.length / 2));
696
+ const down = lines.slice(up.length);
697
+
698
+ useGSAP(
699
+ (_, contextSafe) => {
700
+ const el = root.current;
701
+ if (!el || !contextSafe) return;
702
+ const still = el.querySelector<HTMLElement>("[data-still]");
703
+ const frame = el.querySelector<HTMLElement>("[data-stack-target]");
704
+ const img = frame?.querySelector("img");
705
+ const above = el.querySelector<HTMLElement>("[data-above]");
706
+ const below = el.querySelector<HTMLElement>("[data-below]");
707
+ const bandUp = el.querySelector<HTMLElement>("[data-band='above']");
708
+ const bandDown = el.querySelector<HTMLElement>("[data-band='below']");
709
+ if (!still || !frame || !img || !above || !bandUp || !bandDown) return;
710
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
711
+ const bands = [bandUp, bandDown];
712
+ const graded: number[] = [];
713
+ let played = false;
714
+ // Under a preloader the photo is shown bare, the way the preloader's print lands; the bands settle in later.
715
+ if (covered()) {
716
+ for (const b of bands) {
717
+ b.style.transition = "none";
718
+ b.style.opacity = "0";
719
+ }
720
+ }
721
+
722
+ const grade = () => {
723
+ const from = frame.getBoundingClientRect();
724
+ const box = { x: 0, y: 0, w: from.width, h: from.height };
725
+ // The horizon: the one given, else the place where the brightness of the four fiftieths above and the four
726
+ // below differ most; either way within the band where both halves of the headline fit.
727
+ const lineH = parseFloat(getComputedStyle(above).fontSize) * 0.92;
728
+ const lo = Math.max(0.3, (box.h * 0.07 + up.length * lineH) / box.h);
729
+ const hi = Math.max(lo, Math.min(0.8, 1 - (box.h * 0.06 + down.length * lineH) / box.h));
730
+ let at = Math.min(hi, Math.max(lo, horizon ?? 0.56));
731
+ if (horizon === undefined) {
732
+ const rows: number[] = [];
733
+ for (let k = 0; k < 50; k += 1) {
734
+ const ys = photoLuminances(img, box, { x: 0, y: (k * box.h) / 50, w: box.w, h: box.h / 50 });
735
+ if (!ys) return;
736
+ rows.push(ys.reduce((a, b) => a + b, 0) / ys.length);
737
+ }
738
+ const mean = (a: number, b: number) => rows.slice(a, b).reduce((s, v) => s + v, 0) / (b - a);
739
+ let edge = -1;
740
+ for (let k = 4; k <= 46; k += 1) {
741
+ const t = k / 50;
742
+ if (t < lo || t > hi) continue;
743
+ const e = Math.abs(mean(k - 4, k) - mean(k, k + 4));
744
+ if (e > edge) {
745
+ edge = e;
746
+ at = t;
747
+ }
748
+ }
749
+ }
750
+ still.style.setProperty("--hs-hz", `${(at * 100).toFixed(2)}%`);
751
+
752
+ // Each half's ink over what is under its own glyphs (the range's width, its untransformed box's height).
753
+ const under = (n: HTMLElement): PhotoBox => {
754
+ const range = document.createRange();
755
+ range.selectNodeContents(n);
756
+ const t = range.getBoundingClientRect();
757
+ const o = n.getBoundingClientRect();
758
+ return { x: t.left - from.left, y: o.top - from.top, w: t.width, h: o.height };
759
+ };
760
+ const read = () => {
761
+ const a = inkOver(img, box, [under(above)], el);
762
+ const b = below ? inkOver(img, box, [under(below)], el) : a;
763
+ return a && b ? { a, b, need: a.veil + b.veil } : null;
764
+ };
765
+ let pick = read();
766
+ if (!pick) return;
767
+ if (!side && !run.shown) {
768
+ const was = el.dataset.side;
769
+ el.dataset.side = was === "right" ? "left" : "right";
770
+ const other = read();
771
+ if (other && other.need < pick.need - 0.04) pick = other;
772
+ else el.dataset.side = was;
773
+ }
774
+ above.style.color = pick.a.ink;
775
+ if (below) below.style.color = pick.b.ink;
776
+ bandUp.dataset.tone = pick.a.tone;
777
+ bandDown.dataset.tone = pick.b.tone;
778
+ graded[0] = pick.a.veil;
779
+ graded[1] = pick.b.veil;
780
+ if (played || !covered()) bands.forEach((b, i) => (b.style.opacity = graded[i].toFixed(3)));
781
+ };
782
+
783
+ const run = entrance(el, img, contextSafe as Safe, {
784
+ reduce,
785
+ before: grade,
786
+ play: () => {
787
+ played = true;
788
+ bands.forEach((b, i) => {
789
+ b.style.transition = "opacity 1.2s cubic-bezier(0.16, 1, 0.3, 1)";
790
+ b.style.opacity = (graded[i] ?? 0.4).toFixed(3);
791
+ });
792
+ },
793
+ });
794
+
795
+ // The hand-over: the photograph moves slower than the page, and the headline rides with it on its horizon.
796
+ if (!reduce) {
797
+ gsap
798
+ .timeline({ scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.4, invalidateOnRefresh: true } })
799
+ .to(el.querySelector("[data-drift]"), { yPercent: 22, ease: "none" }, 0)
800
+ .to(el.querySelector("[data-ride]"), { y: () => Math.round(still.clientHeight * 0.22), ease: "none" }, 0);
801
+ }
802
+ document.fonts?.ready.then(() => ScrollTrigger.refresh());
803
+ window.addEventListener("resize", grade);
804
+ return () => {
805
+ run.stop();
806
+ window.removeEventListener("resize", grade);
807
+ };
808
+ },
809
+ { scope: root },
810
+ );
811
+
812
+ const head =
813
+ "absolute inset-x-6 font-display text-[clamp(3rem,13.5vw,4.75rem)] leading-[0.92] tracking-[-0.035em] group-data-[side=right]/hs:text-right md:inset-x-[4vw] md:text-[clamp(3.5rem,8vw,9.5rem)]";
814
+
815
+ return (
816
+ <section
817
+ ref={root}
818
+ data-hero-still=""
819
+ data-hs-variant="horizon"
820
+ data-side={first}
821
+ suppressHydrationWarning
822
+ className={`group/hs relative flex h-svh min-h-[32rem] flex-col overflow-hidden bg-surface-0 text-on-photo ${className}`}
823
+ >
824
+ <div
825
+ data-still=""
826
+ className="relative min-h-0 flex-1"
827
+ style={{ "--hs-hz": `${((horizon ?? 0.56) * 100).toFixed(2)}%` } as React.CSSProperties}
828
+ >
829
+ <div data-stack-target="" className="absolute inset-0 overflow-hidden">
830
+ <div data-drift="" className="absolute inset-0">
831
+ <div data-push="" className="absolute -inset-[3%]">
832
+ <Image
833
+ src={image.src}
834
+ alt={image.alt}
835
+ fill
836
+ priority
837
+ sizes="100vw"
838
+ unoptimized={image.unoptimized}
839
+ className="object-cover"
840
+ />
841
+ </div>
842
+ {/* One light veil band per half, meeting at the horizon. */}
843
+ <div data-veil="" data-band="above" aria-hidden="true" className="absolute inset-x-0 top-0 h-[var(--hs-hz)]" />
844
+ <div data-veil="" data-band="below" aria-hidden="true" className="absolute inset-x-0 bottom-0 top-[var(--hs-hz)]" />
845
+ </div>
846
+ </div>
847
+ <div data-ride="" data-head="" className="absolute inset-0 will-change-transform">
848
+ <h1 className="sr-only">{lines.join(" ")}</h1>
849
+ <div data-above="" aria-hidden className={`${head} bottom-[calc(100%_-_var(--hs-hz))] pb-[0.03em]`}>
850
+ {up.map((line, i) => lineMask(line, i))}
851
+ </div>
852
+ {down.length ? (
853
+ <div data-below="" aria-hidden className={`${head} top-[var(--hs-hz)] pt-[0.03em]`}>
854
+ {down.map((line, i) => lineMask(line, i))}
855
+ </div>
856
+ ) : null}
857
+ </div>
858
+ </div>
859
+ <Caption place={place} time={time} link={link} />
860
+ </section>
861
+ );
862
+ }