@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,685 @@
1
+ "use client";
2
+ // hero-split (Struq hero recipe)
3
+ // The first screen in two halves, and one headline that crosses them. A little under half is the page ground with the
4
+ // studio's name small at the top and a line and a link at the foot; the rest is a tall photograph edge to edge to the
5
+ // top, the outer side and the bottom. Across both runs the headline, fitted to about seventy percent of the screen:
6
+ // "Wij bouwen" and a last word that cycles through the studio's disciplines ("huizen.", "interieurs.", "tuinen."),
7
+ // one word rising out of its mask as the next rises in. It is set twice in the same place: in the ink where it stands
8
+ // on the ground, in the photo ink where it runs over the photograph, each copy clipped to its half in whole pixels, so
9
+ // the line reads as one and changes on both halves at once. Each discipline has its own photograph; the next is
10
+ // uncovered from the bottom edge over the last, at full size (registration), in step with the word, and each carries
11
+ // its own light veil, the weakest that gives the headline 3:1 over that photo. On scroll the photograph runs at its
12
+ // own speed and the type leaves first.
13
+ //
14
+ // <HeroSplit name="Noord" lead="Wij bouwen" items={[{ word: "huizen.", image }, { word: "interieurs.", image }, ...]}
15
+ // line="Aan de Maashaven in Rotterdam." link={{ href: "/werk", label: "Bekijk het werk" }} />
16
+ //
17
+ // One clock: the words (in every copy) and the photos are tweens in one looping timeline, so they can never drift; it
18
+ // starts once every photograph is decoded and pauses off screen or in a hidden tab. The cycling words share one grid
19
+ // cell, so a change of word never moves anything; the headline is fitted with the loaded face and refit when it lands
20
+ // (a late refit eases to the new size instead of jumping). The photo box carries data-stack-target and the enlarged
21
+ // layer data-stack-photo, for the stack preloader and page transition; the entrance waits for whenUncovered and reads
22
+ // its own fallback clock (blocks.css, "--- hero-split ---"). Phones: the photo on top at about 60svh, the headline
23
+ // under it on the ground, the cycle kept. Reduced motion: no cycle, the first word and its photo. Without scripts: the
24
+ // same still, the seam at its share of the width.
25
+ //
26
+ // Three variants of the one idea, the headline set twice across a seam:
27
+ // - naad (default): as above, the seam upright, the ground on one side and the photograph on the other.
28
+ // - liggend: the seam lies down. The photograph takes the top of the screen edge to edge, the ground the foot with the
29
+ // name, the line and the link in one row, and the headline stands on the seam so that it cuts through the cycling
30
+ // word: each new word rises out of the ground ink into the photo ink. The type leaves on scroll, the headline and
31
+ // the seam stay together.
32
+ // - opening: at rest the ground holds nearly the whole screen and the photograph is a strip at the outer edge, the
33
+ // headline almost wholly in the ink. The hero holds for the first scroll, which opens the photograph's window toward
34
+ // the split (the photo stays at full size behind it, so nothing scales), and the headline turns to the photo ink as
35
+ // the edge passes under it; scrolling back closes it. Phones, reduced motion and no scripts: the split.
36
+ import Image from "next/image";
37
+ import { useRef } from "react";
38
+ import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
39
+ import type { StruqImage } from "../kit/images";
40
+ import { photoLuminances } from "../kit/photo-read";
41
+ import { covered, whenUncovered } from "../preloaders/preloader-slices-uncovered";
42
+
43
+ type Item = {
44
+ word: string;
45
+ image: StruqImage;
46
+ /** Where the photo sits in its box (CSS object-position), so a calm part can run under the headline. */
47
+ focus?: string;
48
+ };
49
+
50
+ type Props = {
51
+ /** The studio's name, small at the top of the type side. */
52
+ name: string;
53
+ /** The headline's first line; the cycling word completes it. */
54
+ lead: string;
55
+ /** Two to five disciplines, each with its photograph. */
56
+ items: Item[];
57
+ /** One short line at the foot of the type side. */
58
+ line?: string;
59
+ link: { href: string; label: string };
60
+ /** Which side the photograph takes on wide screens. */
61
+ photo?: "left" | "right";
62
+ /** The composition; the first is the default. */
63
+ variant?: HeroSplitVariant;
64
+ className?: string;
65
+ };
66
+
67
+ export const HERO_SPLIT_VARIANTS = [
68
+ { id: "naad", title: "Staande naad" },
69
+ { id: "liggend", title: "Liggende naad" },
70
+ { id: "opening", title: "Opening" },
71
+ ] as const;
72
+
73
+ export type HeroSplitVariant = (typeof HERO_SPLIT_VARIANTS)[number]["id"];
74
+
75
+ /** Opening: the strip at rest, as a share of the photograph's full width (16% of the screen out of 58%). */
76
+ const CLOSED = 16 / 58;
77
+
78
+ const T = {
79
+ hold: 2.6, // how long a word and its photo stay
80
+ change: 0.95, // the word swap and the photo wipe, together
81
+ span: 0.7, // the headline's widest line, as a share of the screen width
82
+ maxSize: 190, // the headline never grows past this (px)
83
+ } as const;
84
+
85
+ /** Where a waiting word sits (yPercent): past the mask's descender padding, so not a sliver of it shows. */
86
+ const AWAY = 140;
87
+ const RATIO = 3; // display-size type over the photograph
88
+ const MAX_VEIL = 0.3; // past this a photo reads dulled: pick a calmer photo or focus instead
89
+
90
+ const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
91
+ const encode = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
92
+ const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
93
+
94
+ /** The relative luminance of any CSS colour, through a one-pixel canvas so every colour syntax resolves. */
95
+ function luminanceOf(color: string): number {
96
+ const cv = document.createElement("canvas");
97
+ cv.width = 1;
98
+ cv.height = 1;
99
+ const ctx = cv.getContext("2d", { willReadFrequently: true });
100
+ if (!ctx) return 0;
101
+ ctx.fillStyle = "#000";
102
+ ctx.fillStyle = color;
103
+ ctx.fillRect(0, 0, 1, 1);
104
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
105
+ return 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255);
106
+ }
107
+
108
+ /** The weakest veil (composited in sRGB, as the browser does) that brings `under` to RATIO against `ink`. */
109
+ function veilFor(under: number, ink: number, veilS: number): number {
110
+ if (contrast(under, ink) >= RATIO) return 0;
111
+ const s = encode(under);
112
+ const ok = (a: number) => contrast(linear(s * (1 - a) + veilS * a), ink) >= RATIO;
113
+ if (!ok(1)) return 1;
114
+ let lo = 0;
115
+ let hi = 1;
116
+ for (let i = 0; i < 14; i += 1) {
117
+ const mid = (lo + hi) / 2;
118
+ if (ok(mid)) hi = mid;
119
+ else lo = mid;
120
+ }
121
+ return hi;
122
+ }
123
+
124
+ export function HeroSplit({ name, lead, items, line, link, photo = "right", variant = "naad", className = "" }: Props) {
125
+ const root = useRef<HTMLElement>(null);
126
+ const left = photo === "left";
127
+ const flat = variant === "liggend";
128
+ const opening = variant === "opening";
129
+
130
+ useGSAP(
131
+ (_, contextSafe) => {
132
+ const el = root.current;
133
+ if (!el || !contextSafe) return;
134
+ const q = gsap.utils.selector(el);
135
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
136
+ const wide = window.matchMedia("(min-width: 768px)");
137
+ const column = q("[data-type]")[0] as HTMLElement;
138
+ const box = q("[data-photo-box]")[0] as HTMLElement;
139
+ // The photograph's grid cell: the box itself, except in the opening, where the box is a window inside it.
140
+ const cell = q("[data-photo-cell]")[0] as HTMLElement;
141
+ const drift = q("[data-drift]")[0] as HTMLElement;
142
+ const ground = q('[data-copy="ground"]')[0] as HTMLElement;
143
+ const over = q('[data-copy="photo"]')[0] as HTMLElement;
144
+ const mobile = q('[data-copy="phone"]')[0] as HTMLElement;
145
+ const layers = q("[data-layer]") as HTMLElement[];
146
+ const veils = q("[data-veil]") as HTMLElement[];
147
+ const rise = q("[data-rise]");
148
+ const n = items.length;
149
+ const wordsAt = (i: number) => q(`[data-word="${i}"]`) as HTMLElement[];
150
+
151
+ // The seam, and the two copies clipped to their halves in whole pixels.
152
+ const seam = () => {
153
+ const s = el.getBoundingClientRect();
154
+ const p = box.getBoundingClientRect();
155
+ if (flat) {
156
+ // Lying down: the ground copy below the photograph's foot, the photo copy above it.
157
+ const y = Math.round(p.bottom - s.top);
158
+ const h = Math.round(s.height);
159
+ ground.style.clipPath = `inset(${y}px 0px 0px 0px)`;
160
+ over.style.clipPath = `inset(0px 0px ${h - y}px 0px)`;
161
+ return;
162
+ }
163
+ const x = Math.round((left ? p.right : p.left) - s.left);
164
+ const w = Math.round(s.width);
165
+ ground.style.clipPath = left ? `inset(0px 0px 0px ${x}px)` : `inset(0px ${w - x}px 0px 0px)`;
166
+ over.style.clipPath = left ? `inset(0px ${w - x}px 0px 0px)` : `inset(0px 0px 0px ${x}px)`;
167
+ };
168
+
169
+ // The opening: the window's width in whole pixels, from the strip (0) to the whole cell (1); the photograph
170
+ // behind it keeps the cell's full width, anchored to the outer edge, so it is uncovered, never scaled. Phones
171
+ // and reduced motion show the split.
172
+ const open = { p: opening && !reduce ? 0 : 1 };
173
+ const openUp = () => {
174
+ if (opening && wide.matches) {
175
+ const full = cell.clientWidth;
176
+ box.style.width = `${Math.round(full * (CLOSED + (1 - CLOSED) * open.p))}px`;
177
+ drift.style.width = `${full}px`;
178
+ } else if (opening) {
179
+ box.style.width = "";
180
+ drift.style.width = "";
181
+ }
182
+ seam();
183
+ };
184
+
185
+ // The headline fitted with the face in use: on wide screens its widest line spans T.span of the screen, on
186
+ // phones it fills the type column. A refit after the type has been seen eases to the new size.
187
+ let shownOnce = false;
188
+ const fitTo = (stage: HTMLElement, avail: number) => {
189
+ const h = stage.querySelector("h1, [data-head]") as HTMLElement;
190
+ const lines = Array.from(stage.querySelectorAll<HTMLElement>("[data-fit]"));
191
+ const was = h.style.fontSize;
192
+ h.style.fontSize = "100px";
193
+ const widest = Math.max(0, ...lines.map((f) => f.scrollWidth));
194
+ h.style.fontSize = was;
195
+ if (widest <= 0) return T.maxSize; // hidden at this width: the other copy is the one in use
196
+ return Math.max(24, Math.min(T.maxSize, Math.floor((avail / widest) * 100 * 0.99)));
197
+ };
198
+ const sizes = { wide: 0, phone: 0 };
199
+ const fit = (ease = false) => {
200
+ const cs = getComputedStyle(column);
201
+ // Lying down, the two lines (0.92 each) also stay within about half the screen's height.
202
+ const wideSize = Math.min(
203
+ fitTo(ground, el.clientWidth * T.span),
204
+ flat ? Math.floor((el.clientHeight * 0.48) / 1.84) : T.maxSize,
205
+ );
206
+ const phoneSize = fitTo(mobile, column.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight));
207
+ const apply = () => {
208
+ el.style.setProperty("--hsp-size", `${sizes.wide}px`);
209
+ el.style.setProperty("--hsp-phone", `${sizes.phone}px`);
210
+ openUp();
211
+ };
212
+ if (ease && shownOnce && sizes.wide && !reduce) {
213
+ gsap.to(sizes, {
214
+ wide: wideSize,
215
+ phone: phoneSize,
216
+ duration: 0.65,
217
+ ease: "power3.inOut",
218
+ onUpdate: apply,
219
+ });
220
+ } else {
221
+ sizes.wide = wideSize;
222
+ sizes.phone = phoneSize;
223
+ apply();
224
+ }
225
+ };
226
+
227
+ // The entrance, decided by this element's own fallback clock (it starts when the element is styled).
228
+ const clock = rise[0]?.getAnimations().find((a) => (a as CSSAnimation).animationName === "struq-hsp-rise");
229
+ el.dataset.shown ??= String(
230
+ reduce || (clock ? Number(clock.currentTime ?? 0) >= 2900 : getComputedStyle(rise[0]).transform === "none"),
231
+ );
232
+ const shown = el.dataset.shown === "true";
233
+ shownOnce = shown;
234
+ // A late script meets type already shown at the safe size: it eases to the fitted size.
235
+ if (shown) {
236
+ const cs = getComputedStyle(ground.querySelector("[data-head]") as HTMLElement);
237
+ sizes.wide = parseFloat(cs.fontSize);
238
+ const pcs = getComputedStyle(mobile.querySelector("h1") as HTMLElement);
239
+ sizes.phone = parseFloat(pcs.fontSize);
240
+ }
241
+ fit(shown);
242
+ gsap.set(rise, { y: 0, yPercent: shown ? 0 : 115 });
243
+ el.dataset.ready = "";
244
+ const refit = () => fit(true);
245
+ document.fonts?.ready.then(() => {
246
+ refit();
247
+ ScrollTrigger.refresh();
248
+ });
249
+ document.fonts?.addEventListener?.("loadingdone", refit);
250
+ const resize = new ResizeObserver(() => fit(false));
251
+ resize.observe(el);
252
+
253
+ // Each photograph's own ink and veil. Two ways to give the headline 3:1 where it runs over a photo: the photo ink
254
+ // (light) over a veil of the ground held dark, or the palette's dark (the ink on a light palette, the ground on a
255
+ // dark one) over a veil of its light. Per photo the weaker veil wins, never past MAX_VEIL; a photo that needs
256
+ // more is reported in development, not hidden under a grey. Phones carry no veil: the headline is not on the photo.
257
+ const inks: string[] = [];
258
+ let darkVeil = "";
259
+ const grade = () => {
260
+ if (!wide.matches) {
261
+ veils.forEach((v) => (v.style.opacity = "0"));
262
+ return;
263
+ }
264
+ const css = getComputedStyle(el);
265
+ const photoInk = css.getPropertyValue("--on-photo").trim() || getComputedStyle(over).color;
266
+ const inkToken = css.getPropertyValue("--ink").trim() || css.color;
267
+ const groundToken = css.getPropertyValue("--surface-0").trim() || css.backgroundColor;
268
+ const inkIsDark = luminanceOf(inkToken) < luminanceOf(groundToken);
269
+ const groundInk = inkIsDark ? inkToken : groundToken;
270
+ const groundColor = inkIsDark ? groundToken : inkToken;
271
+ darkVeil ||= getComputedStyle(veils[0]).backgroundColor;
272
+ // The whole cell: in the opening the headline runs over all of it once the window is open.
273
+ const pr = cell.getBoundingClientRect();
274
+ const lines = Array.from(over.querySelectorAll<HTMLElement>("[data-fit]"));
275
+ const frame = drift.getBoundingClientRect();
276
+ const ways = [
277
+ { ink: photoInk, veil: darkVeil },
278
+ { ink: groundInk, veil: groundColor },
279
+ ].map((w) => ({ ...w, inkY: luminanceOf(w.ink), veilS: encode(luminanceOf(w.veil)) }));
280
+ layers.forEach((layer, i) => {
281
+ const img = layer.querySelector("img") as HTMLImageElement;
282
+ const ys: number[] = [];
283
+ for (const ln of lines) {
284
+ const r = ln.getBoundingClientRect();
285
+ // The line's mask is untransformed (the rise moves what is inside it), so it gives the true height.
286
+ const m = (ln.parentElement?.parentElement ?? ln).getBoundingClientRect();
287
+ const x0 = Math.max(r.left, pr.left);
288
+ const x1 = Math.min(r.right, pr.right);
289
+ // Lying down, only the part of a line above the seam is on the photograph.
290
+ const y0 = Math.max(m.top, pr.top);
291
+ const y1 = Math.min(m.bottom, pr.bottom);
292
+ if (x1 <= x0 || y1 <= y0) continue;
293
+ const read = photoLuminances(
294
+ img,
295
+ { x: frame.left, y: frame.top, w: frame.width, h: frame.height },
296
+ { x: x0, y: y0, w: x1 - x0, h: y1 - y0 },
297
+ );
298
+ if (!read) return; // cannot be read: the safe CSS veil and ink stay
299
+ ys.push(...read);
300
+ }
301
+ if (!ys.length) return;
302
+ ys.sort((a, b) => a - b);
303
+ const hi = ys[Math.floor(ys.length * 0.9)];
304
+ const lo = ys[Math.floor(ys.length * 0.1)];
305
+ // A light ink must hold over the photo's bright end, a dark ink over its dark end.
306
+ const needs = ways.map((w) => veilFor(w.inkY > 0.18 ? hi : lo, w.inkY, w.veilS));
307
+ const k = needs[0] <= needs[1] ? 0 : 1;
308
+ if (needs[k] > MAX_VEIL && process.env.NODE_ENV !== "production") {
309
+ console.warn(
310
+ `hero-split: ${img.currentSrc || img.src} needs a veil of ${needs[k].toFixed(2)} (photo ink ${needs[0].toFixed(2)}, ground ink ${needs[1].toFixed(2)}); held at ${MAX_VEIL}. Pick a calmer photo or a focus.`,
311
+ );
312
+ }
313
+ graded[i] = Math.min(MAX_VEIL, needs[k]);
314
+ // What each way needed, on the veil itself, for whoever picks this photo to check.
315
+ veils[i].dataset.need = needs.map((v) => v.toFixed(2)).join(" ");
316
+ inks[i] = ways[k].ink;
317
+ veils[i].style.backgroundColor = ways[k].veil;
318
+ });
319
+ if (inks[0]) gsap.set(over, { color: inks[0] });
320
+ cycle.invalidate();
321
+ if (!held) veils.forEach((v, i) => graded[i] !== undefined && (v.style.opacity = graded[i]!.toFixed(3)));
322
+ };
323
+ // Under a preloader the photograph shows bare, as the preloader's own print of it lands; the veils settle in once
324
+ // the cover has really gone, so the landing never darkens in one frame.
325
+ const graded: (number | undefined)[] = [];
326
+ let held = covered();
327
+ if (held) veils.forEach((v) => Object.assign(v.style, { transition: "none", opacity: "0" }));
328
+ const settle = () => {
329
+ if (!held) return;
330
+ held = false;
331
+ veils.forEach((v, i) => {
332
+ v.style.transition = "opacity 1.2s cubic-bezier(0.16, 1, 0.3, 1)";
333
+ v.style.opacity = graded[i] !== undefined ? graded[i]!.toFixed(3) : "";
334
+ });
335
+ };
336
+
337
+ // The photo windows, written from numbers in whole pixels: each layer's hidden share from the top.
338
+ const hidden = layers.map((_, i) => ({ v: i === 0 ? 0 : 1 }));
339
+ const paint = () => {
340
+ const h = layers[0].clientHeight;
341
+ layers.forEach((l, i) => {
342
+ l.style.clipPath = `inset(${Math.round(hidden[i].v * h)}px 0px 0px 0px)`;
343
+ });
344
+ };
345
+ paint();
346
+ for (let i = 0; i < n; i += 1) gsap.set(wordsAt(i), { y: 0, yPercent: i === 0 ? 0 : AWAY });
347
+
348
+ // One clock for words and photos. Change k runs from item k to item k+1 at k * period + hold.
349
+ const period = T.hold + T.change;
350
+ const cycle = gsap.timeline({ paused: true, repeat: -1 });
351
+ for (let k = 0; k < n; k += 1) {
352
+ const cur = k;
353
+ const next = (k + 1) % n;
354
+ const at = k * period + T.hold;
355
+ cycle
356
+ .set(layers, { zIndex: (i: number) => (i === next ? 2 : i === cur ? 1 : 0) }, at)
357
+ .set(hidden[next], { v: 1, onComplete: paint }, at)
358
+ .to(hidden[next], { v: 0, duration: T.change, ease: "power3.inOut", onUpdate: paint }, at)
359
+ // The photo copy takes the next photo's ink on the same clock as its wipe.
360
+ .to(over, { color: () => inks[next] ?? inks[cur] ?? "", duration: T.change, ease: "power3.inOut" }, at)
361
+ .to(
362
+ wordsAt(cur),
363
+ {
364
+ yPercent: -AWAY,
365
+ duration: T.change * 0.85,
366
+ ease: "power3.inOut",
367
+ },
368
+ at,
369
+ )
370
+ .fromTo(
371
+ wordsAt(next),
372
+ { yPercent: AWAY },
373
+ {
374
+ yPercent: 0,
375
+ duration: T.change * 0.85,
376
+ ease: "power3.inOut",
377
+ immediateRender: false,
378
+ },
379
+ at + T.change * 0.15,
380
+ );
381
+ }
382
+ cycle.set({}, {}, n * period); // the loop's length, exactly n periods
383
+
384
+ // Runs only while the hero is on screen and the tab visible, and only once every photograph is decoded.
385
+ let decoded = false;
386
+ let entered = shown || reduce;
387
+ let onScreen = true;
388
+ const sync = () => {
389
+ const go = decoded && entered && onScreen && !document.hidden && !reduce && n > 1;
390
+ if (go) cycle.play();
391
+ else cycle.pause();
392
+ };
393
+ let gone = false;
394
+ Promise.all(
395
+ layers.map((l) =>
396
+ l
397
+ .querySelector("img")
398
+ ?.decode()
399
+ .catch(() => undefined),
400
+ ),
401
+ ).then(() => {
402
+ if (gone) return;
403
+ decoded = true;
404
+ grade();
405
+ sync();
406
+ });
407
+ const io = new IntersectionObserver(([e]) => {
408
+ onScreen = e.isIntersecting;
409
+ sync();
410
+ });
411
+ io.observe(el);
412
+ const onVis = () => sync();
413
+ document.addEventListener("visibilitychange", onVis);
414
+ const onWide = () => {
415
+ fit(false);
416
+ grade();
417
+ };
418
+ wide.addEventListener("change", onWide);
419
+
420
+ let cancel = () => {};
421
+ if (!shown) {
422
+ // The cover has started to leave; the type rises (and the veils settle) once nothing covers the photo any more,
423
+ // at most 1.6s later.
424
+ let watch = 0;
425
+ const free = () => {
426
+ const p = box.getBoundingClientRect();
427
+ const hit = document.elementFromPoint(p.left + p.width / 2, p.top + p.height / 2);
428
+ return !hit || el.contains(hit);
429
+ };
430
+ const play = contextSafe(() => {
431
+ shownOnce = true;
432
+ gsap.to(rise, {
433
+ yPercent: 0,
434
+ duration: 1.1,
435
+ ease: "expo.out",
436
+ stagger: 0.06,
437
+ delay: 0.1,
438
+ onComplete: () => {
439
+ entered = true;
440
+ sync();
441
+ },
442
+ });
443
+ });
444
+ const stop = whenUncovered(() => {
445
+ const until = performance.now() + 1600;
446
+ const look = () => {
447
+ if (gone) return;
448
+ if (free() || performance.now() > until) {
449
+ settle();
450
+ play();
451
+ } else watch = requestAnimationFrame(look);
452
+ };
453
+ look();
454
+ });
455
+ cancel = () => {
456
+ stop();
457
+ cancelAnimationFrame(watch);
458
+ };
459
+ } else {
460
+ settle();
461
+ sync();
462
+ }
463
+
464
+ // The opening: the hero holds while the first scroll opens the window, scrubbed, so scrolling back closes it.
465
+ if (opening && !reduce && wide.matches) {
466
+ gsap
467
+ .timeline({
468
+ scrollTrigger: {
469
+ trigger: el,
470
+ start: "top top",
471
+ end: () => `+=${Math.round(window.innerHeight * 0.9)}`,
472
+ pin: true,
473
+ scrub: 0.3,
474
+ invalidateOnRefresh: true,
475
+ },
476
+ })
477
+ .to(open, { p: 1, ease: "power2.inOut", duration: 1, onUpdate: openUp });
478
+ } else if (flat && !reduce) {
479
+ // Lying down the headline stands on the seam, so it travels with the photograph's edge; the photo runs slower
480
+ // inside its box and the foot row leaves first.
481
+ gsap
482
+ .timeline({
483
+ scrollTrigger: {
484
+ trigger: el,
485
+ start: "top top",
486
+ end: "bottom top",
487
+ scrub: 0.4,
488
+ },
489
+ })
490
+ .to(drift, { yPercent: 10, ease: "none" }, 0)
491
+ .to(column, { yPercent: -16, ease: "none", duration: 0.6 }, 0);
492
+ } else if (!reduce) {
493
+ // The hand-over: the photograph runs slower than the page, the type and the headline leave first.
494
+ gsap
495
+ .timeline({
496
+ scrollTrigger: {
497
+ trigger: el,
498
+ start: "top top",
499
+ end: "bottom top",
500
+ scrub: 0.4,
501
+ },
502
+ })
503
+ .to(drift, { yPercent: 10, ease: "none" }, 0)
504
+ .to([column, q("[data-stage]")[0]], { yPercent: -16, ease: "none", duration: 0.6 }, 0);
505
+ }
506
+
507
+ return () => {
508
+ gone = true;
509
+ cancel();
510
+ io.disconnect();
511
+ resize.disconnect();
512
+ document.removeEventListener("visibilitychange", onVis);
513
+ document.fonts?.removeEventListener?.("loadingdone", refit);
514
+ wide.removeEventListener("change", onWide);
515
+ };
516
+ },
517
+ { scope: root, dependencies: [variant] },
518
+ );
519
+
520
+ const mask = (child: React.ReactNode, key?: number | string) => (
521
+ <span key={key} className="struq-line-mask block overflow-hidden">
522
+ <span data-rise="" className="block">
523
+ {child}
524
+ </span>
525
+ </span>
526
+ );
527
+
528
+ // The headline's two lines; the cycling words share one grid cell, so the line is as wide as the widest.
529
+ const headline = (copy: string) => (
530
+ <>
531
+ {mask(
532
+ <span data-fit="" className="inline-block whitespace-nowrap">
533
+ {lead}
534
+ </span>,
535
+ `${copy}-lead`,
536
+ )}
537
+ {mask(
538
+ <span data-fit="" className="struq-line-mask inline-grid overflow-hidden whitespace-nowrap align-top">
539
+ {items.map((item, i) => (
540
+ <span
541
+ key={item.word}
542
+ data-word={i}
543
+ aria-hidden={i === 0 ? undefined : true}
544
+ className="[grid-area:1/1]"
545
+ style={i === 0 ? undefined : { transform: `translateY(${AWAY}%)` }}
546
+ >
547
+ {item.word}
548
+ </span>
549
+ ))}
550
+ </span>,
551
+ `${copy}-words`,
552
+ )}
553
+ </>
554
+ );
555
+
556
+ // Lying down the headline stands on the seam (58% down) and the seam cuts the cycling word through its middle.
557
+ const headClass = `font-display absolute ${
558
+ flat ? "top-[calc(58%-1.45em)]" : "bottom-[calc(5vh+5.5rem)]"
559
+ } leading-[0.92] tracking-[-0.035em] text-[length:var(--hsp-size,9vw)] px-[4vw] left-0`;
560
+
561
+ const grid = flat
562
+ ? "md:grid-cols-1 md:grid-rows-[58fr_42fr]"
563
+ : `md:grid-rows-1 ${left ? "md:grid-cols-[58fr_42fr]" : "md:grid-cols-[42fr_58fr]"}`;
564
+ const photoPlace = flat ? "md:col-start-1 md:row-start-1" : left ? "md:col-start-1" : "md:col-start-2";
565
+
566
+ // The photograph's layers: each discipline's photo at full size with its own veil; the next is uncovered over the
567
+ // last from the bottom edge. In the opening the layer is anchored to the outer edge at the cell's width.
568
+ const photos = (
569
+ <div
570
+ data-drift=""
571
+ data-stack-photo=""
572
+ className={`absolute -bottom-[6%] -top-[6%] ${opening ? (left ? "left-0 w-full" : "right-0 w-full") : "inset-x-0"}`}
573
+ >
574
+ {items.map((item, i) => (
575
+ <div
576
+ key={item.word}
577
+ data-layer=""
578
+ className="absolute inset-0"
579
+ style={i === 0 ? { zIndex: 2 } : { clipPath: "inset(100% 0px 0px 0px)", zIndex: 0 }}
580
+ >
581
+ <Image
582
+ src={item.image.src}
583
+ alt={i === 0 ? item.image.alt : ""}
584
+ fill
585
+ priority={i === 0}
586
+ loading={i === 0 ? undefined : "eager"}
587
+ sizes={flat ? "100vw" : "(min-width: 768px) 58vw, 100vw"}
588
+ unoptimized={item.image.unoptimized}
589
+ className="object-cover"
590
+ style={item.focus ? { objectPosition: item.focus } : undefined}
591
+ />
592
+ <div data-veil="" aria-hidden="true" className="absolute inset-0" />
593
+ </div>
594
+ ))}
595
+ </div>
596
+ );
597
+
598
+ // The two copies' first clips, before the script writes the seam in whole pixels.
599
+ const groundClip = flat ? "inset(58% 0 0 0)" : left ? "inset(0 0 0 58%)" : "inset(0 58% 0 0)";
600
+ const photoClip = flat ? "inset(0 0 42% 0)" : left ? "inset(0 42% 0 0)" : "inset(0 0 0 42%)";
601
+
602
+ return (
603
+ <section
604
+ ref={root}
605
+ data-hero-split=""
606
+ data-variant={variant}
607
+ data-photo={photo}
608
+ suppressHydrationWarning
609
+ className={`relative grid min-h-svh grid-rows-[60svh_auto] overflow-hidden bg-surface-0 text-ink md:h-svh md:min-h-[34rem] ${grid} ${className}`}
610
+ >
611
+ {/* The type side: the name at the top, the line and link at the foot; on phones the headline between them.
612
+ Lying down, the name, the line and the link share one row at the foot. */}
613
+ <div
614
+ data-type=""
615
+ className={`row-start-2 flex flex-col justify-between gap-8 px-6 pb-8 pt-7 md:px-[4vw] md:pb-[5vh] md:pt-[4vh] ${
616
+ flat ? "md:col-start-1 md:row-start-2 md:flex-row md:items-end" : `md:row-start-1 ${left ? "md:col-start-2" : "md:col-start-1"}`
617
+ }`}
618
+ >
619
+ <div className="text-small">{mask(name, "name")}</div>
620
+ <div data-copy="phone" className="md:hidden">
621
+ <h1 className="font-display text-[length:var(--hsp-phone,13vw)] leading-[0.92] tracking-[-0.035em]">
622
+ {headline("phone")}
623
+ </h1>
624
+ </div>
625
+ <div className="text-small flex flex-wrap items-baseline justify-between gap-x-8 gap-y-2">
626
+ {line ? mask(<span className="text-ink-muted">{line}</span>, "line") : null}
627
+ {mask(
628
+ <a href={link.href} className="underline decoration-1 underline-offset-4">
629
+ {link.label}
630
+ </a>,
631
+ "link",
632
+ )}
633
+ </div>
634
+ </div>
635
+
636
+ {/* The photograph: edge to edge to the top, the outer side and the bottom (lying down: the top and both
637
+ sides). In the opening the box is a window inside the cell, as wide as the script opens it; the preloader
638
+ lands on the window. */}
639
+ {opening ? (
640
+ <div data-photo-cell="" className={`relative row-start-1 ${photoPlace}`}>
641
+ <div
642
+ data-photo-box=""
643
+ data-stack-target=""
644
+ className={`absolute inset-y-0 w-full overflow-hidden ${left ? "left-0" : "right-0"}`}
645
+ >
646
+ {photos}
647
+ </div>
648
+ </div>
649
+ ) : (
650
+ <div
651
+ data-photo-cell=""
652
+ data-photo-box=""
653
+ data-stack-target=""
654
+ className={`relative row-start-1 overflow-hidden ${photoPlace}`}
655
+ >
656
+ {photos}
657
+ </div>
658
+ )}
659
+
660
+ {/* The headline across the seam, wide screens: one copy in the ink, clipped to the ground; one in the photo ink,
661
+ clipped to the photograph. Only the first is read. */}
662
+ <div data-stage="" className="pointer-events-none absolute inset-0 z-10 hidden md:block">
663
+ <div
664
+ data-copy="ground"
665
+ className="absolute inset-0 text-ink"
666
+ style={{ clipPath: groundClip }}
667
+ >
668
+ <h1 data-head="" className={headClass}>
669
+ {headline("ground")}
670
+ </h1>
671
+ </div>
672
+ <div
673
+ data-copy="photo"
674
+ aria-hidden="true"
675
+ className="absolute inset-0 text-on-photo"
676
+ style={{ clipPath: photoClip }}
677
+ >
678
+ <div data-head="" className={headClass}>
679
+ {headline("photo")}
680
+ </div>
681
+ </div>
682
+ </div>
683
+ </section>
684
+ );
685
+ }