@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,855 @@
1
+ "use client";
2
+ // hero-index (Struq hero recipe)
3
+ // The studio's list of work is the first screen. Five or six project names at display size stand down the screen over
4
+ // a full-screen photograph, each with a quiet place and year, each a link. At rest the first project's photograph is
5
+ // up, drifting very slowly. Hover or focus a name and the photograph behind the whole list becomes that project's,
6
+ // wiping in the way the hand travels (down the list it comes from the top edge, up the list from the bottom), while
7
+ // the other names step back. Leave the list and the first project returns. On load the photograph is up first, then
8
+ // the names rise one by one through their masks.
9
+ //
10
+ // The photographs are full-size layers stacked in one box; a swap only opens a clip on the incoming layer, written
11
+ // from whole-pixel numbers, and never scales anything. Going back to a photograph that still shows under a wipe
12
+ // draws the wipe back instead of re-hiding it, so the screen is never without a picture. Every photograph is decoded
13
+ // before it can be swapped in.
14
+ //
15
+ // The ink is decided per photograph, for the whole list at once: the dark ink with no veil, or the light ink with a
16
+ // veil of at most a quarter, whichever gives every name 3:1 over what is under it, the stepped-back names at their
17
+ // reduced strength too. The list stands on the side of the screen where most of the photographs allow that (each one
18
+ // measured); a photograph that allows it on neither side keeps the light ink under the capped veil and is marked
19
+ // data-unfit, and should not be offered as a hero. Each place line stands right-aligned under its name and takes
20
+ // whichever ink holds 4.5:1 in its own box. During a wipe the list keeps the outgoing ink until the incoming photo has
21
+ // passed the whole list, then switches at once. A photograph that cannot be read keeps the CSS's light ink and veil.
22
+ //
23
+ // <HeroIndex items={[{ title: "Olijfhof", place: "Alentejo", year: "2025", image: images.hero[2], href: "/werk/olijfhof" }, ...]} />
24
+ //
25
+ // The photo box carries data-stack-target, so preloader-stack and page-transition-stack land on the first photograph.
26
+ // Keyboard: focus swaps like hover; one focus ring, on the link. Phones and touch: the list over the first photograph,
27
+ // a tap follows the link. Reduced motion: swaps are hard cuts, no drift, the names simply stand. Without scripts: the
28
+ // list over the first photograph.
29
+ //
30
+ // Three variants of the one idea, the list that calls up its photograph:
31
+ // - lijst (default): as above, the list over the full-screen photograph, hover and focus swap it.
32
+ // - venster: the list stands on the page ground in the ink, and the photograph lives in a tall window beside it (on
33
+ // phones above it); hover and focus wipe the window the same way. No veil: no name stands on a photograph.
34
+ // - doorlopen: the list stands in the middle of the full-screen photograph and the hero holds while the first scroll
35
+ // walks it: each stretch of scroll raises the next project's photograph from the bottom edge, scrubbed, and steps its
36
+ // name forward. Scrolling back draws the photographs back down. Focus a name and the page scrolls to its stretch.
37
+ // Reduced motion: the same stretches, cuts instead of wipes.
38
+ import Image from "next/image";
39
+ import { useRef } from "react";
40
+ import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
41
+ import type { StruqImage } from "../kit/images";
42
+ import { photoLuminances, type PhotoBox } from "../kit/photo-read";
43
+ import {
44
+ covered,
45
+ whenUncovered,
46
+ } from "../preloaders/preloader-slices-uncovered";
47
+
48
+ type Item = {
49
+ title: string;
50
+ /** Where it is; set with the year as one quiet line. */
51
+ place: string;
52
+ year: string;
53
+ image: StruqImage;
54
+ href: string;
55
+ /** Where the photograph sits in the frame (CSS object-position), so its subject stands clear of the list. */
56
+ focus?: string;
57
+ };
58
+
59
+ type Props = {
60
+ /** Where the list stands, when the photographs have been framed for it (each item's focus); left out, the block
61
+ * measures every photograph on both sides and takes the side where most of them hold. */
62
+ side?: Side;
63
+ /** Five or six projects; the first is the photograph at rest. */
64
+ items: Item[];
65
+ /** The composition; the first is the default. */
66
+ variant?: HeroIndexVariant;
67
+ className?: string;
68
+ };
69
+
70
+ export const HERO_INDEX_VARIANTS = [
71
+ { id: "lijst", title: "Lijst over de foto" },
72
+ { id: "venster", title: "Venster" },
73
+ { id: "doorlopen", title: "Doorlopen" },
74
+ ] as const;
75
+
76
+ export type HeroIndexVariant = (typeof HERO_INDEX_VARIANTS)[number]["id"];
77
+
78
+ type Side = "left" | "right";
79
+ type Ink = "dark" | "light";
80
+ type Dress = {
81
+ ink: Ink;
82
+ veil: number;
83
+ meta: Array<Ink | "foot">;
84
+ fit: boolean;
85
+ };
86
+
87
+ /** The names are display text: 3:1 against the photo under them. The place lines are small text: 4.5:1. */
88
+ const RATIO = 3;
89
+ const META_RATIO = 4.5;
90
+ const MAX_VEIL = 0.25;
91
+ /** The veil over a photograph that cannot be read (and without scripts, in the CSS). */
92
+ const SAFE_VEIL = 0.45;
93
+ /** The strength of a stepped-back name. */
94
+ const DIM = 0.8;
95
+ /** The share of the pixels under a name that the ink is judged against: the brighter side for light ink, the darker
96
+ * side for dark ink, so a name holds over most of what is under it, not just its average. */
97
+ const SAFE = 0.35;
98
+ /** A swap wipes the incoming photograph in over this long. */
99
+ const WIPE = 0.85;
100
+ /** After the hand leaves the list, the first project returns after this pause. */
101
+ const RETURN = 0.45;
102
+
103
+ const FINE = "(hover: hover) and (pointer: fine) and (min-width: 1024px)";
104
+
105
+ const linear = (c: number) =>
106
+ c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
107
+ const encode = (l: number) =>
108
+ l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055;
109
+ const contrast = (a: number, b: number) =>
110
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
111
+ const at = (ys: number[], q: number) =>
112
+ ys[Math.min(ys.length - 1, Math.max(0, Math.floor(ys.length * q)))];
113
+
114
+ /** The relative luminance of any CSS colour, through a one-pixel canvas so every colour syntax resolves. */
115
+ function luminanceOf(color: string): number {
116
+ const cv = document.createElement("canvas");
117
+ cv.width = 1;
118
+ cv.height = 1;
119
+ const ctx = cv.getContext("2d", { willReadFrequently: true });
120
+ if (!ctx) return 0;
121
+ ctx.fillStyle = "#000";
122
+ ctx.fillStyle = color;
123
+ ctx.fillRect(0, 0, 1, 1);
124
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
125
+ return (
126
+ 0.2126 * linear(r / 255) +
127
+ 0.7152 * linear(g / 255) +
128
+ 0.0722 * linear(b / 255)
129
+ );
130
+ }
131
+
132
+ /** The weakest veil (composited in sRGB, as the browser does) that brings `under` to `ratio` against `ink`. */
133
+ function veilFor(
134
+ under: number,
135
+ ink: number,
136
+ veilS: number,
137
+ ratio: number,
138
+ ): number {
139
+ if (contrast(under, ink) >= ratio) return 0;
140
+ const s = encode(under);
141
+ const ok = (a: number) =>
142
+ contrast(linear(s * (1 - a) + veilS * a), ink) >= ratio;
143
+ if (!ok(1)) return 1;
144
+ let lo = 0;
145
+ let hi = 1;
146
+ for (let i = 0; i < 14; i += 1) {
147
+ const mid = (lo + hi) / 2;
148
+ if (ok(mid)) hi = mid;
149
+ else lo = mid;
150
+ }
151
+ return hi;
152
+ }
153
+
154
+ /** How busy a photo is in a box: the spread between its quartiles (foliage and gravel are busy, a wall is calm). */
155
+ function texture(
156
+ img: HTMLImageElement,
157
+ frame: PhotoBox,
158
+ box: PhotoBox,
159
+ ): number | null {
160
+ const ys = photoLuminances(img, frame, box);
161
+ if (!ys) return null;
162
+ return Math.sqrt(at(ys, 0.75)) - Math.sqrt(at(ys, 0.25));
163
+ }
164
+
165
+ export function HeroIndex({ items, side, variant = "lijst", className = "" }: Props) {
166
+ const root = useRef<HTMLElement>(null);
167
+ // Over a photograph the names need a measured ink; in the window they stand on the ground.
168
+ const overPhoto = variant !== "venster";
169
+ const walks = variant === "doorlopen";
170
+ const listSide: Side = side ?? (items[0]?.image.negativeSpace === "right" ? "right" : "left");
171
+
172
+ useGSAP(
173
+ (_, contextSafe) => {
174
+ const el = root.current;
175
+ if (!el || !contextSafe) return;
176
+ const box = el.querySelector<HTMLElement>("[data-stack-target]");
177
+ const list = el.querySelector<HTMLElement>("[data-list]");
178
+ const rows = Array.from(
179
+ el.querySelectorAll<HTMLAnchorElement>("[data-row]"),
180
+ );
181
+ const layers = Array.from(
182
+ el.querySelectorAll<HTMLElement>("[data-layer]"),
183
+ );
184
+ const veils = layers.map((l) =>
185
+ l.querySelector<HTMLElement>("[data-veil]"),
186
+ );
187
+ const imgs = layers.map((l) => l.querySelector<HTMLImageElement>("img"));
188
+ const rise = Array.from(el.querySelectorAll<HTMLElement>("[data-rise]"));
189
+ const foot = el.querySelector<HTMLElement>("[data-foot]");
190
+ // The row whose place line is shown: the hovered or focused one, else the first.
191
+ let shownRow = -1;
192
+ const footer = () => {
193
+ if (!foot) return;
194
+ const row = rows[shownRow >= 0 ? shownRow : 0];
195
+ if (row?.dataset.placeInk === "foot") {
196
+ foot.textContent =
197
+ row.querySelector("[data-meta]")?.textContent ?? "";
198
+ foot.dataset.on = "";
199
+ } else {
200
+ foot.textContent = "";
201
+ delete foot.dataset.on;
202
+ }
203
+ };
204
+ if (!box || !list || rows.length === 0 || layers.length === 0) return;
205
+ const reduce = window.matchMedia(
206
+ "(prefers-reduced-motion: reduce)",
207
+ ).matches;
208
+ let gone = false;
209
+ const labelSide = el.dataset.side;
210
+
211
+ // Every photograph loads now and is decoded before it can be swapped in.
212
+ const ready = imgs.map((img, i) => {
213
+ if (!img) return Promise.resolve();
214
+ if (i > 0) img.loading = "eager";
215
+ const loaded = img.complete
216
+ ? Promise.resolve()
217
+ : new Promise<void>((res) => {
218
+ img.addEventListener("load", () => res(), { once: true });
219
+ img.addEventListener("error", () => res(), { once: true });
220
+ });
221
+ return loaded.then(() => img.decode()).catch(() => undefined);
222
+ });
223
+ const decoded = imgs.map(() => false);
224
+ ready.forEach((p, i) => p.then(() => (decoded[i] = true)));
225
+
226
+ // Where each name and its place line stand, in the layer's coordinates. The line mask is never transformed,
227
+ // so this holds before the names have risen too.
228
+ const rel = (r: DOMRect, base: DOMRect): PhotoBox => ({
229
+ x: r.left - base.left,
230
+ y: r.top - base.top,
231
+ w: r.width,
232
+ h: r.height,
233
+ });
234
+ const places = () => {
235
+ const base = layers[0].getBoundingClientRect();
236
+ return rows.map((row) => {
237
+ const name = row.querySelector<HTMLElement>("[data-name]")!;
238
+ const meta = row.querySelector<HTMLElement>("[data-meta]")!;
239
+ const mask = row
240
+ .querySelector<HTMLElement>(".struq-line-mask")!
241
+ .getBoundingClientRect();
242
+ const range = document.createRange();
243
+ range.selectNodeContents(name);
244
+ const g = range.getBoundingClientRect();
245
+ const m = meta.getBoundingClientRect();
246
+ const mh = meta.offsetHeight;
247
+ return {
248
+ name: rel(
249
+ new DOMRect(g.left, mask.top, g.width, name.offsetHeight),
250
+ base,
251
+ ),
252
+ meta: rel(new DOMRect(m.left, mask.bottom - mh, m.width, mh), base),
253
+ };
254
+ });
255
+ };
256
+
257
+ // The dress of one photograph for the names where they stand: one ink for the whole list, its veil, the place
258
+ // lines' inks, and whether it holds at all.
259
+ const inks = () => {
260
+ const cs = getComputedStyle(el);
261
+ const light = luminanceOf(
262
+ cs.getPropertyValue("--on-photo").trim() || "#fff",
263
+ );
264
+ const dark = luminanceOf(
265
+ getComputedStyle(veils[0] ?? el).backgroundColor,
266
+ );
267
+ return { light, dark, veilS: encode(dark) };
268
+ };
269
+ const dressFor = (
270
+ i: number,
271
+ spots: ReturnType<typeof places>,
272
+ ): Dress | null => {
273
+ const img = imgs[i];
274
+ if (!img) return null;
275
+ const frame: PhotoBox = {
276
+ x: 0,
277
+ y: 0,
278
+ w: layers[i].clientWidth,
279
+ h: layers[i].clientHeight,
280
+ };
281
+ const reads = spots.map((s) => ({
282
+ name: photoLuminances(img, frame, s.name),
283
+ meta: photoLuminances(img, frame, s.meta),
284
+ }));
285
+ if (reads.some((r) => !r.name || !r.meta)) return null;
286
+ const { light, dark, veilS } = inks();
287
+ const veiled = (l: number, v: number) =>
288
+ linear(encode(l) * (1 - v) + veilS * v);
289
+ const shown = (ink: number, bg: number, k: number) =>
290
+ linear(encode(ink) * k + encode(bg) * (1 - k));
291
+ // Dark ink, no veil: every name over the darker side of what is under it.
292
+ const darkOk = reads.every((r, k) => {
293
+ const bg = at(r.name!, SAFE);
294
+ return contrast(shown(dark, bg, k === i ? 1 : DIM), bg) >= RATIO;
295
+ });
296
+ // Light ink: the veil every name needs over the brighter side of what is under it, stepped back ones too.
297
+ const need = Math.max(
298
+ ...reads.map((r, k) => {
299
+ const bg = at(r.name!, 1 - SAFE);
300
+ if (k === i) return veilFor(bg, light, veilS, RATIO);
301
+ // A dimmed name is the ink mixed toward the ground; find the veil it needs by search.
302
+ const ok = (v: number) => {
303
+ const b = veiled(bg, v);
304
+ return contrast(shown(light, b, DIM), b) >= RATIO;
305
+ };
306
+ if (!ok(1)) return 1;
307
+ let lo = 0;
308
+ let hi = 1;
309
+ for (let n = 0; n < 14; n += 1) {
310
+ const mid = (lo + hi) / 2;
311
+ if (ok(mid)) hi = mid;
312
+ else lo = mid;
313
+ }
314
+ return hi;
315
+ }),
316
+ );
317
+ const lightOk = need <= MAX_VEIL;
318
+ const ink: Ink = darkOk ? "dark" : "light";
319
+ const veil = darkOk ? 0 : Math.min(need, MAX_VEIL);
320
+ // Each place line: the ink that holds 4.5:1 in its own box over the veil chosen; where neither does, it is
321
+ // shown at the foot of the list in the list's ink instead.
322
+ const meta = reads.map((r) => {
323
+ const lo = veiled(at(r.meta!, SAFE), veil);
324
+ const hi = veiled(at(r.meta!, 1 - SAFE), veil);
325
+ if (contrast(light, hi) >= META_RATIO) return "light" as const;
326
+ if (contrast(dark, lo) >= META_RATIO) return "dark" as const;
327
+ return "foot" as const;
328
+ });
329
+ return { ink, veil, meta, fit: darkOk || lightOk };
330
+ };
331
+
332
+ // The side: measure every photograph with the list on each side and take the side where most of them hold; on
333
+ // a tie the side where the first photograph is calmer, then the label.
334
+ const dress: Array<Dress | null> = layers.map(() => null);
335
+ const settle = () => {
336
+ if (gone) return;
337
+ // In the window the names stand on the ground: nothing to measure, no veil.
338
+ if (!overPhoto) {
339
+ el.dataset.side = listSide;
340
+ for (const v of veils) if (v) v.style.opacity = "0";
341
+ return;
342
+ }
343
+ // In the middle of the screen both sides are the same place.
344
+ const sides: Side[] = side ? [side] : walks ? [listSide] : ["left", "right"];
345
+ const tally = sides.map((side) => {
346
+ el.dataset.side = side;
347
+ const spots = places();
348
+ const all = layers.map((_, i) =>
349
+ decoded[i] ? dressFor(i, spots) : null,
350
+ );
351
+ return { side, all, fit: all.filter((d) => d?.fit).length };
352
+ });
353
+ let pick =
354
+ tally.length === 1
355
+ ? tally[0]
356
+ : tally[0].fit === tally[1].fit
357
+ ? null
358
+ : tally[0].fit > tally[1].fit
359
+ ? tally[0]
360
+ : tally[1];
361
+ if (!pick && imgs[0]) {
362
+ const frame: PhotoBox = {
363
+ x: 0,
364
+ y: 0,
365
+ w: layers[0].clientWidth,
366
+ h: layers[0].clientHeight,
367
+ };
368
+ const half = (x: number): PhotoBox => ({
369
+ x: frame.w * x,
370
+ y: frame.h * 0.25,
371
+ w: frame.w * 0.45,
372
+ h: frame.h * 0.75,
373
+ });
374
+ const l = texture(imgs[0], frame, half(0));
375
+ const r = texture(imgs[0], frame, half(0.55));
376
+ if (l !== null && r !== null && l !== r)
377
+ pick = l < r ? tally[0] : tally[1];
378
+ }
379
+ if (!pick) pick = labelSide === "right" ? tally[1] : tally[0];
380
+ el.dataset.side = pick.side;
381
+ pick.all.forEach((d, i) => {
382
+ dress[i] = d;
383
+ const veil = veils[i];
384
+ if (d) layers[i].toggleAttribute("data-unfit", !d.fit);
385
+ // A photograph that cannot be read keeps the light ink over the safe veil.
386
+ else if (veil) veil.style.opacity = SAFE_VEIL.toFixed(3);
387
+ if (veil && d) {
388
+ veil.style.transition = "none";
389
+ veil.style.opacity = d.veil.toFixed(3);
390
+ void veil.offsetWidth;
391
+ veil.style.transition = "";
392
+ }
393
+ });
394
+ behind = -1;
395
+ follow();
396
+ };
397
+
398
+ // The swap: each layer's clip as plain numbers, how much of it is still hidden (0 to 1) and the edge it comes
399
+ // from; written in whole pixels of its height.
400
+ const wipe = layers.map((_, i) => ({ a: i === 0 ? 0 : 1, dir: 1 }));
401
+ const paint = (i: number) => {
402
+ const w = wipe[i];
403
+ const px = Math.round(w.a * layers[i].clientHeight);
404
+ // Down the list it comes from the top edge (its foot still hidden), up the list from the bottom edge.
405
+ layers[i].style.clipPath =
406
+ w.dir > 0
407
+ ? `inset(0px 0px ${px}px 0px)`
408
+ : `inset(${px}px 0px 0px 0px)`;
409
+ follow();
410
+ };
411
+
412
+ // The list's ink follows the photograph that lies behind the whole list: the topmost layer whose open part
413
+ // covers the list from its top to its foot. So a wipe keeps the outgoing ink until it has passed the list.
414
+ let behind = -1;
415
+ const follow = () => {
416
+ if (!overPhoto) return;
417
+ const base = layers[0].getBoundingClientRect();
418
+ const lr = list.getBoundingClientRect();
419
+ const top = lr.top - base.top;
420
+ const foot = lr.bottom - base.top;
421
+ const order = layers
422
+ .map((_, i) => i)
423
+ .sort(
424
+ (a, b) =>
425
+ (Number(layers[b].style.zIndex) || 0) -
426
+ (Number(layers[a].style.zIndex) || 0),
427
+ );
428
+ let under = order[order.length - 1];
429
+ for (const i of order) {
430
+ const w = wipe[i];
431
+ const h = layers[i].clientHeight;
432
+ const hidden = w.a * h;
433
+ const from = w.dir > 0 ? 0 : hidden;
434
+ const to = w.dir > 0 ? h - hidden : h;
435
+ if (from <= top && to >= foot) {
436
+ under = i;
437
+ break;
438
+ }
439
+ }
440
+ if (under === behind) return;
441
+ behind = under;
442
+ const d = dress[under];
443
+ if (!d) return;
444
+ el.dataset.tone = d.ink;
445
+ rows.forEach((row, k) => (row.dataset.placeInk = d.meta[k] ?? "foot"));
446
+ footer();
447
+ };
448
+
449
+ let z = layers.length + 1;
450
+ layers.forEach((l, i) => {
451
+ l.style.zIndex = i === 0 ? String(z) : "0";
452
+ paint(i);
453
+ });
454
+ let current = 0;
455
+ let want = 0;
456
+
457
+ const mark = (i: number) => {
458
+ rows.forEach((r, j) => {
459
+ if (j === i) r.dataset.current = "";
460
+ else delete r.dataset.current;
461
+ });
462
+ };
463
+
464
+ const swap = (i: number, from: number) => {
465
+ const dir = i > from ? 1 : -1;
466
+ // Back to a photograph that still shows under the ones wiping over it: those draw back the way they came, and
467
+ // it is never re-hidden.
468
+ if (wipe[i].a < 0.995) {
469
+ const zi = Number(layers[i].style.zIndex) || 0;
470
+ for (let j = 0; j < layers.length; j += 1) {
471
+ const wj = wipe[j];
472
+ if (
473
+ j === i ||
474
+ wj.a >= 1 ||
475
+ (Number(layers[j].style.zIndex) || 0) <= zi
476
+ )
477
+ continue;
478
+ gsap.killTweensOf(wj);
479
+ if (reduce) {
480
+ wj.a = 1;
481
+ paint(j);
482
+ } else {
483
+ gsap.to(wj, {
484
+ a: 1,
485
+ duration: WIPE * 0.8,
486
+ ease: "expo.out",
487
+ onUpdate: () => paint(j),
488
+ });
489
+ }
490
+ }
491
+ const wi = wipe[i];
492
+ if (wi.a > 0) {
493
+ gsap.killTweensOf(wi);
494
+ if (reduce) {
495
+ wi.a = 0;
496
+ paint(i);
497
+ } else {
498
+ gsap.to(wi, {
499
+ a: 0,
500
+ duration: WIPE,
501
+ ease: "expo.out",
502
+ onUpdate: () => paint(i),
503
+ });
504
+ }
505
+ }
506
+ return;
507
+ }
508
+ const w = wipe[i];
509
+ gsap.killTweensOf(w);
510
+ w.dir = dir;
511
+ w.a = 1;
512
+ layers[i].style.zIndex = String(++z);
513
+ paint(i);
514
+ const done = () => {
515
+ // Only the photograph on top stays open, so the next wipe always covers a whole picture.
516
+ for (let j = 0; j < layers.length; j += 1) {
517
+ if (j === i || j === current) continue;
518
+ wipe[j].a = 1;
519
+ paint(j);
520
+ }
521
+ };
522
+ if (reduce) {
523
+ w.a = 0;
524
+ paint(i);
525
+ done();
526
+ return;
527
+ }
528
+ gsap.to(w, {
529
+ a: 0,
530
+ duration: WIPE,
531
+ ease: "expo.out",
532
+ onUpdate: () => paint(i),
533
+ onComplete: done,
534
+ });
535
+ };
536
+
537
+ const show = (i: number) => {
538
+ want = i;
539
+ if (i === current) return;
540
+ const go = () => {
541
+ if (gone || want !== i || i === current) return;
542
+ const from = current;
543
+ current = i;
544
+ swap(i, from);
545
+ };
546
+ if (decoded[i]) go();
547
+ else ready[i].then(go);
548
+ };
549
+
550
+ // The entrance: once every photograph is decoded and the side and inks are settled (at most 1.6s), the names
551
+ // rise one by one once nothing covers the page.
552
+ if (!reduce) gsap.set(rise, { y: 0, yPercent: 115 });
553
+ el.dataset.ready = "";
554
+ let stop = () => {};
555
+ let driftTween: gsap.core.Tween | null = null;
556
+ const allReady = Promise.race([
557
+ Promise.all([...ready, document.fonts?.ready ?? Promise.resolve()]),
558
+ new Promise((r) => setTimeout(r, 1600)),
559
+ ]);
560
+ allReady.then(() => {
561
+ if (gone) return;
562
+ settle();
563
+ const play = contextSafe(() => {
564
+ if (reduce) {
565
+ gsap.set(rise, { yPercent: 0 });
566
+ return;
567
+ }
568
+ gsap.to(rise, {
569
+ yPercent: 0,
570
+ duration: 1.15,
571
+ ease: "expo.out",
572
+ stagger: 0.08,
573
+ delay: covered() ? 0.1 : 0.25,
574
+ });
575
+ // The drift: a very slow travel of the whole stack, written in whole pixels so the photograph never
576
+ // shimmers between them.
577
+ const drift = el.querySelector<HTMLElement>("[data-drift]");
578
+ if (drift) {
579
+ const pos = { x: 0, y: 0 };
580
+ let lx = 0;
581
+ let ly = 0;
582
+ driftTween = gsap.to(pos, {
583
+ x: -0.012,
584
+ y: 0.008,
585
+ duration: 28,
586
+ ease: "sine.inOut",
587
+ repeat: -1,
588
+ yoyo: true,
589
+ onUpdate: () => {
590
+ const x = Math.round(pos.x * drift.offsetWidth);
591
+ const y = Math.round(pos.y * drift.offsetHeight);
592
+ if (x === lx && y === ly) return;
593
+ lx = x;
594
+ ly = y;
595
+ drift.style.transform = `translate3d(${x}px, ${y}px, 0)`;
596
+ },
597
+ });
598
+ }
599
+ });
600
+ stop = whenUncovered(play);
601
+ });
602
+ // A photograph decoded after the settle (a slow network) is dressed for the side already chosen.
603
+ ready.forEach((p, i) =>
604
+ p.then(() => {
605
+ if (gone || !overPhoto || dress[i] || !el.dataset.tone) return;
606
+ dress[i] = dressFor(i, places());
607
+ const d = dress[i];
608
+ const veil = veils[i];
609
+ if (d && veil) veil.style.opacity = d.veil.toFixed(3);
610
+ }),
611
+ );
612
+
613
+ // When the scroll walks the list, the pointer swaps nothing; the scroll does.
614
+ const fine = window.matchMedia(FINE).matches && !walks;
615
+ let back = 0;
616
+
617
+ // The walk: the hero holds while the scroll runs from the first project to the last. Each photograph has the
618
+ // stretch before it; it rises from the bottom edge over the middle of that stretch (the photo rests between),
619
+ // scrubbed, so scrolling back draws it down the way it came. The layers stack in list order.
620
+ let walk: ScrollTrigger | undefined;
621
+ if (walks && layers.length > 1) {
622
+ const n = layers.length;
623
+ const ease = gsap.parseEase("power2.inOut");
624
+ const pos = { p: 0 };
625
+ let at = 0;
626
+ layers.forEach((l, k) => (l.style.zIndex = String(k + 1)));
627
+ const render = () => {
628
+ for (let k = 1; k < n; k += 1) {
629
+ const t = Math.min(1, Math.max(0, (pos.p - (k - 1) - 0.15) / 0.7));
630
+ const a = reduce ? (t >= 0.5 ? 0 : 1) : 1 - ease(t);
631
+ if (a === wipe[k].a && wipe[k].dir < 0) continue;
632
+ wipe[k].a = a;
633
+ wipe[k].dir = -1;
634
+ paint(k);
635
+ }
636
+ const now = Math.min(n - 1, Math.round(pos.p));
637
+ if (now === at && rows[now]?.dataset.current !== undefined) return;
638
+ at = now;
639
+ current = now;
640
+ shownRow = now;
641
+ mark(now);
642
+ footer();
643
+ };
644
+ list.dataset.active = "";
645
+ render();
646
+ walk = gsap
647
+ .timeline({
648
+ scrollTrigger: {
649
+ trigger: el,
650
+ start: "top top",
651
+ end: () => `+=${Math.round((n - 1) * 0.6 * window.innerHeight)}`,
652
+ pin: true,
653
+ scrub: reduce ? true : 0.3,
654
+ invalidateOnRefresh: true,
655
+ },
656
+ })
657
+ .to(pos, { p: n - 1, ease: "none", duration: 1, onUpdate: render })
658
+ .scrollTrigger;
659
+ }
660
+ // A name focused from the keyboard scrolls the page to its stretch.
661
+ const walkTo = (i: number) => {
662
+ if (!walk) return;
663
+ const y = walk.start + (i / Math.max(1, rows.length - 1)) * (walk.end - walk.start);
664
+ window.scrollTo({ top: Math.round(y), behavior: reduce ? "auto" : "smooth" });
665
+ };
666
+ const enter = (i: number) => {
667
+ shownRow = i;
668
+ footer();
669
+ window.clearTimeout(back);
670
+ mark(i);
671
+ list.dataset.active = "";
672
+ show(i);
673
+ };
674
+ const leave = () => {
675
+ shownRow = -1;
676
+ footer();
677
+ mark(-1);
678
+ delete list.dataset.active;
679
+ window.clearTimeout(back);
680
+ back = window.setTimeout(() => !gone && show(0), RETURN * 1000);
681
+ };
682
+ const onEnter = rows.map((row, i) => {
683
+ const fn = () => enter(i);
684
+ if (fine) row.addEventListener("pointerenter", fn);
685
+ return fn;
686
+ });
687
+ const onFocus = rows.map((row, i) => {
688
+ const fn = () => {
689
+ if (!row.matches(":focus-visible")) return;
690
+ if (walks) walkTo(i);
691
+ else enter(i);
692
+ };
693
+ row.addEventListener("focus", fn);
694
+ return fn;
695
+ });
696
+ const onFocusOut = (e: FocusEvent) => {
697
+ if (walks) return;
698
+ if (!list.contains(e.relatedTarget as Node | null)) leave();
699
+ };
700
+ if (fine) list.addEventListener("pointerleave", leave);
701
+ list.addEventListener("focusout", onFocusOut);
702
+ let resizing = 0;
703
+ const onResize = () => {
704
+ layers.forEach((_, i) => paint(i));
705
+ window.clearTimeout(resizing);
706
+ resizing = window.setTimeout(settle, 200);
707
+ };
708
+ window.addEventListener("resize", onResize);
709
+
710
+ return () => {
711
+ gone = true;
712
+ stop();
713
+ driftTween?.kill();
714
+ window.clearTimeout(back);
715
+ window.clearTimeout(resizing);
716
+ window.removeEventListener("resize", onResize);
717
+ rows.forEach((row, i) => {
718
+ row.removeEventListener("pointerenter", onEnter[i]);
719
+ row.removeEventListener("focus", onFocus[i]);
720
+ delete row.dataset.current;
721
+ delete row.dataset.placeInk;
722
+ });
723
+ list.removeEventListener("pointerleave", leave);
724
+ list.removeEventListener("focusout", onFocusOut);
725
+ delete list.dataset.active;
726
+ delete el.dataset.ready;
727
+ delete el.dataset.tone;
728
+ if (foot) {
729
+ foot.textContent = "";
730
+ delete foot.dataset.on;
731
+ }
732
+ if (labelSide) el.dataset.side = labelSide;
733
+ const drift = el.querySelector<HTMLElement>("[data-drift]");
734
+ if (drift) drift.style.transform = "";
735
+ for (const l of layers) {
736
+ l.style.clipPath = "";
737
+ l.style.zIndex = "";
738
+ l.removeAttribute("data-unfit");
739
+ }
740
+ };
741
+ },
742
+ { scope: root, dependencies: [items.length, side, variant] },
743
+ );
744
+
745
+ // The window stands on the side away from the list: wide screens beside it, phones above it.
746
+ const windowBox =
747
+ listSide === "left"
748
+ ? "absolute inset-x-6 top-6 h-[38svh] overflow-hidden md:inset-x-auto md:top-[7svh] md:bottom-[7svh] md:left-[54vw] md:right-[4vw] md:h-auto"
749
+ : "absolute inset-x-6 top-6 h-[38svh] overflow-hidden md:inset-x-auto md:top-[7svh] md:bottom-[7svh] md:left-[4vw] md:right-[54vw] md:h-auto";
750
+
751
+ return (
752
+ <section
753
+ ref={root}
754
+ data-hero-index=""
755
+ data-variant={variant}
756
+ data-side={variant === "lijst" ? (items[0]?.image.negativeSpace === "right" ? "right" : "left") : listSide}
757
+ className={`group/hero relative h-svh min-h-[30rem] overflow-hidden bg-surface-0 ${overPhoto ? "text-on-photo" : "text-ink"} ${className}`}
758
+ >
759
+ {/* The photo box: what a preloader lands on. The layers stack inside it; only the first shows without scripts. */}
760
+ <div data-stack-target="" className={overPhoto ? "absolute inset-0 overflow-hidden" : windowBox}>
761
+ <div data-drift="" className="absolute -inset-[2%]">
762
+ {items.map((item, i) => (
763
+ <div
764
+ key={item.href + i}
765
+ data-layer=""
766
+ className="absolute inset-0"
767
+ style={{
768
+ clipPath:
769
+ i === 0
770
+ ? "inset(0px 0px 0px 0px)"
771
+ : "inset(0px 0px 100% 0px)",
772
+ zIndex: i === 0 ? 1 : 0,
773
+ }}
774
+ >
775
+ <Image
776
+ src={item.image.src}
777
+ alt={i === 0 ? item.image.alt : ""}
778
+ fill
779
+ priority={i === 0}
780
+ loading={i === 0 ? undefined : "lazy"}
781
+ sizes={overPhoto ? "100vw" : "(min-width: 768px) 42vw, 100vw"}
782
+ unoptimized={item.image.unoptimized}
783
+ className="object-cover"
784
+ style={item.focus ? { objectPosition: item.focus } : undefined}
785
+ />
786
+ <div
787
+ data-veil=""
788
+ aria-hidden="true"
789
+ className="absolute inset-0"
790
+ />
791
+ </div>
792
+ ))}
793
+ </div>
794
+ </div>
795
+
796
+ <nav
797
+ aria-label="Werk"
798
+ className={
799
+ walks
800
+ ? "absolute inset-0 z-10 flex flex-col items-center justify-center px-6 md:px-[4vw]"
801
+ : "absolute inset-x-0 bottom-0 z-10 px-6 pb-[7svh] md:px-[4vw] md:pb-[6svh]"
802
+ }
803
+ >
804
+ <ul
805
+ data-list=""
806
+ className={
807
+ walks
808
+ ? "group/list flex flex-col items-center"
809
+ : "group/list flex flex-col group-data-[side=right]/hero:items-end"
810
+ }
811
+ >
812
+ {items.map((item, i) => (
813
+ <li key={item.href + i}>
814
+ {/* One focus ring, on the link itself, outside the line's mask, so nothing clips it. */}
815
+ <a
816
+ data-row=""
817
+ href={item.href}
818
+ className="group/row block w-fit rounded-[0.15rem] py-[0.2svh] outline-offset-[0.35rem] transition-opacity duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-current group-data-[active]/list:not-data-[current]:opacity-80"
819
+ >
820
+ <span className="struq-line-mask block overflow-x-visible overflow-y-clip">
821
+ <span data-rise="" className="block">
822
+ {/* The place line stands right-aligned under its name. */}
823
+ <span
824
+ data-text=""
825
+ className="inline-flex flex-col items-end transition-[translate] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-data-[current]/row:translate-x-[1.2vw] group-data-[side=right]/hero:group-data-[current]/row:-translate-x-[1.2vw]"
826
+ >
827
+ <span
828
+ data-name=""
829
+ className="font-display whitespace-nowrap text-[min(11.5vw,7.6svh)] leading-[0.98] tracking-[-0.035em] md:text-[min(clamp(2.75rem,1rem_+_5.2vw,7.5rem),8.4svh)]"
830
+ >
831
+ {item.title}
832
+ </span>
833
+ <span
834
+ data-meta=""
835
+ className="text-small mt-[1.1svh] whitespace-nowrap pb-[0.3svh]"
836
+ >
837
+ {item.place}, {item.year}
838
+ </span>
839
+ </span>
840
+ </span>
841
+ </span>
842
+ </a>
843
+ </li>
844
+ ))}
845
+ </ul>
846
+ {/* A place line that cannot hold its own ink over the photo stands here, under the list, in the list's ink. */}
847
+ <p
848
+ data-foot=""
849
+ aria-hidden="true"
850
+ className={`text-small mt-[1.6svh] min-h-[1.5em] whitespace-nowrap ${walks ? "text-center" : "group-data-[side=right]/hero:text-right"}`}
851
+ />
852
+ </nav>
853
+ </section>
854
+ );
855
+ }