@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,616 @@
1
+ "use client";
2
+ // grid-assembly (Struq section recipe)
3
+ // Prints thrown on a table that lock into an editorial grid, and one of them becomes the ground
4
+ // for what follows. The stage pins on a composed scatter: nine photos of different sizes lying
5
+ // at slight angles around the middle, a few hanging past the edges of the screen. As you scroll
6
+ // they travel into a precise grid with exact gaps, each straightening and settling into its own
7
+ // cell a beat after the last, and the section's line stands up in the one cell left for type. The
8
+ // grid holds. Then the photo in the hero cell grows: its frame opens from the cell to the full
9
+ // screen, the photo inside settling with it so its crop never jumps, while the other prints are
10
+ // pushed out of the way. Full bleed, the next line of copy rises on the photo's calm side, holds,
11
+ // and as the pin lets go the photo drifts slower than the page.
12
+ //
13
+ // Every move is written from plain numbers on every frame. A tile's whole path is one number
14
+ // (`--p` from the scatter to its cell, `--q` out of the way), read by one CSS transform. The hero
15
+ // print is a window onto the full-screen photo, registered to the screen; once the grid locks, a
16
+ // full-screen plate of the same photo takes over showing the very same pixels, and the grow is a
17
+ // clip opening from the cell's pixels to the screen's, the photo never changing scale. The scatter is
18
+ // the first paint, so nothing jumps when the pin engages, and it only exists once the page runs
19
+ // scripts (blocks.css, "--- grid-assembly ---"). The pin is 250%.
20
+ // Lead for an architect, a builder, a studio with a body of work: any brief whose story is many
21
+ // pieces making one thing.
22
+ //
23
+ // <GridAssembly tiles={[...nine photos]} hero={images.hero[1]} title="Noord bouwt in lagen." text="..."
24
+ // heading={["Van fundering", "tot dakrand."]} line="..." />
25
+ //
26
+ // Phones: the grid at rest, its tiles opening on a short entrance; then the photo full width with
27
+ // its line. Reduced motion and pages without scripts: the same still layout, nothing hidden.
28
+ import Image from "next/image";
29
+ import { useRef } from "react";
30
+ import { gsap, useGSAP } from "../kit/gsap";
31
+ import type { StruqImage } from "../kit/images";
32
+ import { photoLuminances } from "../kit/photo-read";
33
+
34
+ type Props = {
35
+ /** Eight photos for the grid around the hero cell (more are ignored). */
36
+ tiles: StruqImage[];
37
+ /** The photo in the hero cell, the one that grows to the full screen. */
38
+ hero: StruqImage;
39
+ /** The section's line, set in the grid's type cell. */
40
+ title: string;
41
+ /** One short line under it. */
42
+ text?: string;
43
+ /** The line on the full-bleed photo, one entry per line. */
44
+ heading: string[];
45
+ /** One sentence under it. */
46
+ line?: string;
47
+ className?: string;
48
+ };
49
+
50
+ type Cell = { c: number; cs: number; r: number; rs: number };
51
+ type Scatter = { x: number; y: number; rot: number; scale: number };
52
+
53
+ // The board: twelve columns, six rows, every cell filled. `t` is the type cell, `h` the hero cell.
54
+ const CELLS: Record<string, Cell> = {
55
+ t: { c: 1, cs: 4, r: 1, rs: 2 },
56
+ a: { c: 5, cs: 3, r: 1, rs: 3 },
57
+ b: { c: 8, cs: 5, r: 1, rs: 2 },
58
+ c: { c: 1, cs: 2, r: 3, rs: 2 },
59
+ d: { c: 3, cs: 2, r: 3, rs: 4 },
60
+ e: { c: 8, cs: 2, r: 3, rs: 2 },
61
+ f: { c: 10, cs: 3, r: 3, rs: 4 },
62
+ h: { c: 5, cs: 3, r: 4, rs: 3 },
63
+ i: { c: 8, cs: 2, r: 5, rs: 2 },
64
+ j: { c: 1, cs: 2, r: 5, rs: 2 },
65
+ };
66
+ const ORDER = ["a", "b", "c", "d", "e", "f", "i", "j"] as const;
67
+
68
+ // The scatter: where each print lies before it is placed (its centre, in vw and svh), its angle and size. One
69
+ // overlapping pile a little right of the middle, the hero print on top of it, three spilling past the edges:
70
+ // prints thrown onto a table, composed rather than random.
71
+ const SCATTER: Record<string, Scatter> = {
72
+ a: { x: 37, y: 31, rot: -8, scale: 0.74 },
73
+ b: { x: 64, y: 21, rot: 5, scale: 0.64 },
74
+ c: { x: 22, y: 60, rot: 10, scale: 0.92 },
75
+ d: { x: 46, y: 68, rot: -4, scale: 0.72 },
76
+ e: { x: 72, y: 52, rot: 12, scale: 0.96 },
77
+ f: { x: 94, y: 72, rot: -9, scale: 0.8 },
78
+ h: { x: 54, y: 43, rot: 2, scale: 0.84 },
79
+ i: { x: 12, y: 92, rot: -13, scale: 0.96 },
80
+ j: { x: 76, y: 94, rot: 7, scale: 0.9 },
81
+ };
82
+
83
+ // The board's geometry, the same numbers the CSS grid uses: 4vw sides, 7svh top and bottom, 0.9vw gaps.
84
+ const COL = "((92vw - 11 * 0.9vw) / 12)";
85
+ const ROW = "((86svh - 5 * 0.9vw) / 6)";
86
+ const centre = (cell: Cell) => ({
87
+ x: `(4vw + ${cell.c - 1} * (${COL} + 0.9vw) + (${cell.cs} * ${COL} + ${cell.cs - 1} * 0.9vw) / 2)`,
88
+ y: `(7svh + ${cell.r - 1} * (${ROW} + 0.9vw) + (${cell.rs} * ${ROW} + ${cell.rs - 1} * 0.9vw) / 2)`,
89
+ });
90
+ /** The custom properties a tile's scatter is written in: the offset from its cell to its place in the pile. */
91
+ const scatterVars = (key: string): React.CSSProperties => {
92
+ const s = SCATTER[key];
93
+ const c = centre(CELLS[key]);
94
+ return {
95
+ "--sx": `calc(${s.x}vw - ${c.x})`,
96
+ "--sy": `calc(${s.y}svh - ${c.y})`,
97
+ "--sr": `${s.rot}deg`,
98
+ "--sd": String(1 - s.scale),
99
+ } as React.CSSProperties;
100
+ };
101
+
102
+ /** Until the photo is read, and without scripts: dark ink over a light veil that holds 4.5:1 over any photo (the
103
+ * on-photo light over black, composited in sRGB, comes to about 0.21 luminance at this strength). */
104
+ const SAFE_VEIL = 0.52;
105
+ /** The share of the grow by which every other print has left the screen (and is no longer drawn). */
106
+ const OFF = 0.7;
107
+ /** How long one print takes to fly, as a share of the lock, and how far apart they leave. */
108
+ const FLY = 0.62;
109
+ /** The order the prints are placed in: the hero first, then outward. */
110
+ const PLACE_ORDER = ["h", "a", "e", "d", "b", "c", "f", "j", "i"];
111
+
112
+ const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
113
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
114
+ /** power4.inOut: a hard settle, so each print lands exactly. */
115
+ const settle = (t: number) => (t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2);
116
+ const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
117
+ const encode = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
118
+ const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
119
+
120
+ /** The relative luminance of any CSS colour, through a one-pixel canvas so every colour syntax resolves. */
121
+ function luminanceOf(color: string): number {
122
+ const cv = document.createElement("canvas");
123
+ cv.width = 1;
124
+ cv.height = 1;
125
+ const ctx = cv.getContext("2d", { willReadFrequently: true });
126
+ if (!ctx) return 0;
127
+ ctx.fillStyle = "#000";
128
+ ctx.fillStyle = color;
129
+ ctx.fillRect(0, 0, 1, 1);
130
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
131
+ return 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255);
132
+ }
133
+
134
+ /** The weakest veil that brings `under` to `ratio` against an ink, composited as the browser does: in sRGB. */
135
+ function veilFor(under: number, ink: number, veilS: number, ratio: number): number {
136
+ if (contrast(under, ink) >= ratio) return 0;
137
+ const s = encode(under);
138
+ const ok = (a: number) => contrast(linear(s * (1 - a) + veilS * a), ink) >= ratio;
139
+ if (!ok(0.85)) return 0.85;
140
+ let lo = 0;
141
+ let hi = 0.85;
142
+ for (let i = 0; i < 14; i += 1) {
143
+ const mid = (lo + hi) / 2;
144
+ if (ok(mid)) hi = mid;
145
+ else lo = mid;
146
+ }
147
+ return hi;
148
+ }
149
+
150
+ /**
151
+ * The darker and brighter ends of a photo's luminance under a box of the screen (the photo covers the screen).
152
+ * Only read when the photo is from this page's own origin: a photo from elsewhere would taint the canvas.
153
+ */
154
+ function readUnder(
155
+ img: HTMLImageElement,
156
+ W: number,
157
+ H: number,
158
+ box: { x: number; y: number; w: number; h: number },
159
+ tail: number,
160
+ ) {
161
+ const ys = photoLuminances(img, { x: 0, y: 0, w: W, h: H }, box);
162
+ if (!ys) return null;
163
+ return { lo: ys[Math.floor(ys.length * tail)], hi: ys[Math.min(ys.length - 1, Math.floor(ys.length * (1 - tail)))] };
164
+ }
165
+
166
+ export function GridAssembly({ tiles, hero, title, text, heading, line, className = "" }: Props) {
167
+ const stage = useRef<HTMLElement>(null);
168
+ const photos = ORDER.map((key, i) => ({ key, image: tiles[i % Math.max(1, tiles.length)] })).filter((t) => t.image);
169
+ const side = hero.negativeSpace === "right" ? "right" : "left";
170
+
171
+ useGSAP(
172
+ () => {
173
+ const el = stage.current;
174
+ if (!el) return;
175
+ const one = <T extends HTMLElement = HTMLElement>(s: string) => el.querySelector<T>(s);
176
+ const all = (s: string) => Array.from(el.querySelectorAll<HTMLElement>(s));
177
+
178
+ // The ink of the line on the full-bleed photo: read from the photo where it may be read, else the
179
+ // treatment that holds over any photo.
180
+ const tone = () => {
181
+ const host = one("[data-bleed]")?.offsetParent ? one("[data-bleed]") : one("[data-still-bleed]");
182
+ const img = host?.querySelector("img");
183
+ const copy = host?.querySelector<HTMLElement>("[data-copy]");
184
+ const veil = host?.querySelector<HTMLElement>("[data-veil]");
185
+ const probe = (k: string) => {
186
+ const n = el.querySelector<HTMLElement>(`[data-ink='${k}']`);
187
+ return n ? luminanceOf(getComputedStyle(n).color) : 0;
188
+ };
189
+ if (!host || !img || !copy || !veil) return;
190
+ const light = probe("light");
191
+ const dark = probe("dark");
192
+ const hostBox = host.getBoundingClientRect();
193
+ const W = hostBox.width;
194
+ const H = hostBox.height;
195
+ // The heading (display size, 3:1, read against most of what lies under it) and the line (4.5:1, against
196
+ // nearly all of it), each on its current side and mirrored to the other side.
197
+ const parts = [
198
+ { n: copy.querySelector<HTMLElement>("h3"), ratio: 3, tail: 0.1 },
199
+ { n: copy.querySelector<HTMLElement>("p"), ratio: 4.5, tail: 0.03 },
200
+ ].flatMap((p) => {
201
+ if (!p.n) return [];
202
+ const b = p.n.getBoundingClientRect();
203
+ const box = { x: b.left - hostBox.left - 12, y: b.top - hostBox.top - 12, w: b.width + 24, h: b.height + 24 };
204
+ return [{ ...p, here: box, there: { ...box, x: W - box.x - box.w } }];
205
+ });
206
+ const current = el.dataset.side === "right" ? "right" : "left";
207
+ // The weaker veil of the two inks over every part, or null when the photo cannot be read.
208
+ const treat = (which: "here" | "there") => {
209
+ let d = 0;
210
+ let l = 0;
211
+ for (const p of parts) {
212
+ const r = readUnder(img, W, H, p[which], p.tail);
213
+ if (!r) return null;
214
+ d = Math.max(d, veilFor(r.lo, dark, encode(light), p.ratio));
215
+ l = Math.max(l, veilFor(r.hi, light, encode(dark), p.ratio));
216
+ }
217
+ // A light veil strong enough for a mid-tone photo reads as fog where a dark one reads as shade, so dark ink
218
+ // only wins clearly: with no veil at all, or well under the dark veil the light ink would need.
219
+ const darkInk = d === 0 || d < l * 0.6;
220
+ return { dark: darkInk, veil: darkInk ? d : l };
221
+ };
222
+ // A photo that cannot be read: the treatment that holds over anything, for the small copy.
223
+ const safeD = veilFor(0, dark, encode(light), 4.5);
224
+ const safeL = veilFor(1, light, encode(dark), 4.5);
225
+ const a = treat("here") ?? { dark: safeD <= safeL, veil: Math.min(safeD, safeL) };
226
+ // The line stands on whichever side of the photo it reads on with the lighter touch (the photo's named calm
227
+ // side by default); a photo that cannot be read keeps its named side.
228
+ const b2 = treat("there");
229
+ const flip = b2 !== null && b2.veil + 0.02 < a.veil;
230
+ const t = flip && b2 ? b2 : a;
231
+ if (flip) el.dataset.side = current === "left" ? "right" : "left";
232
+ el.dataset.tone = t.dark ? "dark" : "light";
233
+ for (const v of all("[data-veil]")) v.style.opacity = t.veil.toFixed(3);
234
+ };
235
+ const imgs = Array.from(el.querySelectorAll("img"));
236
+ // Every photo decoded ahead of the section, the hidden full-screen plate included, so the grow never waits
237
+ // on a decode in its first frame.
238
+ const decode = (img: HTMLImageElement) => {
239
+ if (img.complete && img.naturalWidth) img.decode?.().catch(() => {});
240
+ };
241
+ for (const img of imgs) {
242
+ img.addEventListener("load", tone);
243
+ img.addEventListener("load", () => decode(img));
244
+ decode(img);
245
+ }
246
+ tone();
247
+ document.fonts?.ready.then(tone);
248
+ window.addEventListener("resize", tone);
249
+
250
+ const mm = gsap.matchMedia();
251
+
252
+ // Wide screens with motion: the pinned assembly.
253
+ mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
254
+ const bleed = one("[data-bleed]");
255
+ const frame = one("[data-bleed-frame]");
256
+ const plate = one("[data-bleed-plate]");
257
+ const drift = one("[data-bleed-drift]");
258
+ const heroTile = one("[data-tile='h']");
259
+ const typeTile = one("[data-tile='t']");
260
+ const win = one("[data-tile='h'] [data-window]");
261
+ const veilOn = one("[data-veil-on]");
262
+ if (!bleed || !bleed.offsetParent || !frame || !plate || !drift || !heroTile || !typeTile || !win || !veilOn) return;
263
+ // If the script arrives after the CSS fallback has already dropped the tiles into the grid, the grid stays:
264
+ // the flight is skipped rather than thrown back into the pile.
265
+ const fellBack = getComputedStyle(heroTile).getPropertyValue("--p").trim() === "1";
266
+ el.dataset.motion = "1";
267
+ const tilesEls = all("[data-tile]");
268
+ const copyRise = all("[data-bleed] [data-rise]");
269
+ const stageBox = one("[data-stage]")!;
270
+
271
+ const g = { W: 1, H: 1, cell: { x: 0, y: 0, w: 1, h: 1 } };
272
+ const box = (n: HTMLElement) => {
273
+ let x = 0;
274
+ let y = 0;
275
+ let cur: HTMLElement | null = n;
276
+ while (cur && cur !== stageBox) {
277
+ x += cur.offsetLeft;
278
+ y += cur.offsetTop;
279
+ cur = cur.offsetParent as HTMLElement | null;
280
+ }
281
+ return { x, y, w: n.offsetWidth, h: n.offsetHeight };
282
+ };
283
+ const measure = () => {
284
+ tone();
285
+ g.W = stageBox.clientWidth;
286
+ g.H = stageBox.clientHeight;
287
+ g.cell = box(heroTile);
288
+ // Registration: the hero print is a window onto the full-screen photo, the same photo at the same size and
289
+ // place as the plate, so when the plate takes over at the lock it shows exactly the same pixels, and the
290
+ // grow is only a clip opening to the screen, never a change of scale.
291
+ win.style.left = `${-g.cell.x}px`;
292
+ win.style.top = `${-g.cell.y}px`;
293
+ win.style.width = `${g.W}px`;
294
+ win.style.height = `${g.H}px`;
295
+ win.style.right = "auto";
296
+ win.style.bottom = "auto";
297
+ // Each other print is pushed straight away from the hero cell, by translation only, just far enough to
298
+ // clear the nearest edge of the screen it is heading for: it is gone by OFF of the grow, then no longer
299
+ // drawn, so the prints never weigh on the frames where the photo opens widest.
300
+ const hx = g.cell.x + g.cell.w / 2;
301
+ const hy = g.cell.y + g.cell.h / 2;
302
+ for (const t of tilesEls) {
303
+ if (t === heroTile) continue;
304
+ const b = box(t);
305
+ const dx = b.x + b.w / 2 - hx;
306
+ const dy = b.y + b.h / 2 - hy;
307
+ const len = Math.hypot(dx, dy) || 1;
308
+ const ux = dx / len;
309
+ const uy = dy / len;
310
+ const margin = 24;
311
+ const needX = ux > 0.01 ? (g.W - b.x + margin) / ux : ux < -0.01 ? (b.x + b.w + margin) / -ux : Infinity;
312
+ const needY = uy > 0.01 ? (g.H - b.y + margin) / uy : uy < -0.01 ? (b.y + b.h + margin) / -uy : Infinity;
313
+ const reach = Math.min(needX, needY) / OFF;
314
+ push.set(t, { x: ux * reach, y: uy * reach });
315
+ }
316
+ };
317
+ const push = new Map<HTMLElement, { x: number; y: number }>();
318
+
319
+ // The values the scroll drives: the lock (each print's own share of it), the grow, the copy.
320
+ const s = { lock: fellBack ? 1 : 0, grow: 0 };
321
+ const order = PLACE_ORDER.filter((k) => tilesEls.some((t) => t.dataset.tile === k));
322
+ const paint = () => {
323
+ const span = order.length > 1 ? (1 - FLY) / (order.length - 1) : 0;
324
+ for (const t of tilesEls) {
325
+ const key = t.dataset.tile ?? "";
326
+ const at = key === "t" ? order.length - 1 : Math.max(0, order.indexOf(key));
327
+ const p = settle(clamp((s.lock - at * span) / FLY, 0, 1));
328
+ t.style.setProperty("--p", p.toFixed(4));
329
+ // The push out of the way follows the grow's own ease, so every print is off screen at OFF. It is written
330
+ // in whole pixels: a layer whose only transform is a fractional translation is re-rastered by the browser
331
+ // each time the fraction changes, which stalled the grow while eight prints slid at once.
332
+ const q = key === "h" ? 0 : s.grow;
333
+ const m = push.get(t);
334
+ if (q > 0 && m) {
335
+ t.style.transform = `translate3d(${Math.round(q * m.x)}px, ${Math.round(q * m.y)}px, 0)`;
336
+ } else {
337
+ t.style.transform = "";
338
+ }
339
+ if (key !== "h") t.style.visibility = q >= OFF ? "hidden" : "";
340
+ }
341
+ // The hero's frame: a clip from its cell to the screen over the photo at full size. The plate takes over from the hero tile as soon as the grid is locked, clipped exactly to the cell, so
342
+ // the browser has painted it through the whole hold before the grow begins.
343
+ const e = s.grow;
344
+ const on = s.lock >= 0.999 || e > 0.0005;
345
+ bleed.style.visibility = on ? "visible" : "hidden";
346
+ heroTile.style.visibility = on ? "hidden" : "";
347
+ const L = lerp(g.cell.x, 0, e);
348
+ const T = lerp(g.cell.y, 0, e);
349
+ const R = lerp(g.W - (g.cell.x + g.cell.w), 0, e);
350
+ const B = lerp(g.H - (g.cell.y + g.cell.h), 0, e);
351
+ // The clip sits on the plate's own layer (and the veil's), never on a parent: clipped from outside, the
352
+ // browser rasters only the part of the photo inside the clip and stalls on the rest as the clip opens.
353
+ const cp = `inset(${T.toFixed(2)}px ${R.toFixed(2)}px ${B.toFixed(2)}px ${L.toFixed(2)}px)`;
354
+ plate.style.clipPath = cp;
355
+ veilOn.style.clipPath = cp;
356
+ // The veil the line needs comes in with the grow, so the take-over at the lock shows the bare photo.
357
+ veilOn.style.opacity = e.toFixed(4);
358
+ };
359
+
360
+ measure();
361
+ paint();
362
+ gsap.set(copyRise, { yPercent: 118 });
363
+
364
+ const tl = gsap.timeline({
365
+ defaults: { ease: "none" },
366
+ onUpdate: paint,
367
+ scrollTrigger: {
368
+ trigger: el,
369
+ start: "top top",
370
+ end: "+=250%",
371
+ pin: true,
372
+ scrub: 1,
373
+ onRefresh: () => {
374
+ measure();
375
+ paint();
376
+ },
377
+ },
378
+ });
379
+ // Hold on the pile; the lock; hold on the grid; the grow; hold on the photo with its line.
380
+ // Units: the moves (lock 1.3, grow 1.8) take 3.1, the holds 4.4. The grow is the longest move and eases
381
+ // on power2.inOut, so the frame opens over about a quarter of the pin rather than snapping through its middle.
382
+ tl.to({}, { duration: 0.7 })
383
+ .to(s, { lock: 1, duration: 1.3, ease: "none" }, 0.7)
384
+ .to({}, { duration: 1.3 }, 2.0)
385
+ .to(s, { grow: 1, duration: 1.8, ease: "power2.inOut" }, 3.3)
386
+ // The line rises once the frame is fully open.
387
+ .to(copyRise, { yPercent: 0, duration: 0.9, stagger: 0.09, ease: "expo.out" }, 5.1)
388
+ // The last hold: the copy is whole from about 6.1, held 1.4 more; 2.4 after the grow lands.
389
+ .to({}, { duration: 2.4 }, 5.1);
390
+
391
+ // The release: the photo drifts slower than the page as the stage leaves.
392
+ const pin = tl.scrollTrigger!;
393
+ gsap.fromTo(
394
+ drift,
395
+ { yPercent: 0 },
396
+ {
397
+ yPercent: 22,
398
+ ease: "none",
399
+ immediateRender: false,
400
+ scrollTrigger: { start: () => pin.end, end: () => pin.end + el.offsetHeight, scrub: true },
401
+ },
402
+ );
403
+
404
+ return () => {
405
+ delete el.dataset.motion;
406
+ for (const t of tilesEls) {
407
+ t.style.removeProperty("--p");
408
+ t.style.transform = "";
409
+ t.style.visibility = "";
410
+ }
411
+ heroTile.style.visibility = "";
412
+ bleed.style.visibility = "";
413
+ plate.style.clipPath = "";
414
+ veilOn.style.clipPath = "";
415
+ veilOn.style.opacity = "";
416
+ for (const p of ["left", "top", "width", "height", "right", "bottom"] as const) win.style[p] = "";
417
+ };
418
+ });
419
+
420
+ // Phones with motion: the grid at rest, each tile opening from its foot as the grid arrives.
421
+ mm.add("(max-width: 767px) and (prefers-reduced-motion: no-preference)", () => {
422
+ const tilesEls = all("[data-tile]");
423
+ const open = tilesEls.map(() => ({ v: 0 }));
424
+ const write = () =>
425
+ tilesEls.forEach((t, i) => {
426
+ t.style.clipPath = `inset(${(100 * (1 - open[i].v)).toFixed(2)}% 0% 0% 0%)`;
427
+ });
428
+ write();
429
+ el.dataset.shown = "1";
430
+ const tw = gsap.to(open, {
431
+ v: 1,
432
+ duration: 0.9,
433
+ stagger: 0.07,
434
+ ease: "expo.out",
435
+ paused: true,
436
+ onUpdate: write,
437
+ });
438
+ const st = gsap.timeline({ scrollTrigger: { trigger: el, start: "top 80%", once: true, onEnter: () => tw.play() } });
439
+ return () => {
440
+ st.scrollTrigger?.kill();
441
+ tw.kill();
442
+ delete el.dataset.shown;
443
+ for (const t of tilesEls) t.style.clipPath = "";
444
+ };
445
+ });
446
+
447
+ return () => {
448
+ window.removeEventListener("resize", tone);
449
+ for (const img of imgs) img.removeEventListener("load", tone);
450
+ mm.revert();
451
+ };
452
+ },
453
+ { scope: stage, dependencies: [hero.src] },
454
+ );
455
+
456
+ const grade = "object-cover [filter:saturate(0.9)_contrast(1.05)]";
457
+ const place = (cell: Cell) =>
458
+ ({
459
+ "--c": `${cell.c} / span ${cell.cs}`,
460
+ "--r": `${cell.r} / span ${cell.rs}`,
461
+ }) as React.CSSProperties;
462
+ // Desktop: the board's cell. Phones: two columns, tall prints over two rows.
463
+ const tileClass = (tall: boolean, wide: boolean) =>
464
+ `relative overflow-hidden bg-surface-2 will-change-transform md:[grid-column:var(--c)] md:[grid-row:var(--r)] ${
465
+ wide ? "col-span-2" : ""
466
+ } ${tall ? "row-span-2" : ""}`;
467
+ const ink = "text-on-photo group-data-[tone=dark]/ga:text-[var(--ga-dark)]";
468
+ const display = "font-display leading-[0.92] tracking-[-0.035em]";
469
+
470
+ const copy = (rise: boolean) => (
471
+ <div
472
+ data-copy=""
473
+ className={`max-w-[46rem] ${ink} group-data-[side=right]/ga:ml-auto group-data-[side=right]/ga:text-right`}
474
+ >
475
+ <h3 className={`m-0 text-[clamp(2.75rem,1rem_+_5.4vw,8rem)] ${display}`}>
476
+ {heading.map((h) => (
477
+ <span key={h} className="block overflow-hidden pb-[0.1em] -mb-[0.1em]">
478
+ <span data-rise={rise ? "" : undefined} className="block">
479
+ {h}
480
+ </span>
481
+ </span>
482
+ ))}
483
+ </h3>
484
+ {line ? (
485
+ <p className="m-0 mt-6 overflow-hidden text-lead">
486
+ <span data-rise={rise ? "" : undefined} className="block max-w-[34ch] group-data-[side=right]/ga:ml-auto">
487
+ {line}
488
+ </span>
489
+ </p>
490
+ ) : null}
491
+ </div>
492
+ );
493
+
494
+ return (
495
+ <section
496
+ ref={stage}
497
+ data-grid-assembly=""
498
+ data-tone="dark"
499
+ data-side={side}
500
+ aria-label={title}
501
+ className={`group/ga relative bg-surface-0 ${className}`}
502
+ style={{ "--ga-dark": "oklch(from var(--surface-0) min(calc(l * 0.42), 0.19) c h)" } as React.CSSProperties}
503
+ >
504
+ {/* The inks as this palette resolves them, read once to measure contrast. */}
505
+ <span aria-hidden className="pointer-events-none absolute size-0 overflow-hidden">
506
+ <span data-ink="light" className="text-on-photo" />
507
+ <span data-ink="dark" className="text-[var(--ga-dark)]" />
508
+ </span>
509
+
510
+ <div data-stage="" className="relative px-4 py-12 md:h-svh md:overflow-hidden md:p-0">
511
+ {/* The board: twelve columns and six rows with exact gaps on wide screens, two columns on phones. */}
512
+ <div className="grid auto-rows-[34vw] grid-cols-2 gap-2 md:absolute md:inset-x-[4vw] md:inset-y-[7svh] md:auto-rows-auto md:grid-cols-12 md:grid-rows-6 md:gap-[0.9vw]">
513
+ {/* The type cell: the section's line, standing up as the grid locks. */}
514
+ <div
515
+ data-tile="t"
516
+ className={`${tileClass(false, true)} flex flex-col justify-between bg-transparent py-1 md:py-0`}
517
+ style={{ ...place(CELLS.t), ...({ "--sx": "0px", "--sy": "0px", "--sr": "0deg", "--sd": "0" } as React.CSSProperties) }}
518
+ >
519
+ <h2 className={`m-0 text-[clamp(2.25rem,1rem_+_2.9vw,5rem)] text-ink ${display}`}>
520
+ <span className="block overflow-hidden pb-[0.1em] -mb-[0.1em]">
521
+ <span data-ga-line="" className="block">
522
+ {title}
523
+ </span>
524
+ </span>
525
+ </h2>
526
+ {text ? (
527
+ <p className="m-0 overflow-hidden text-small text-ink-muted">
528
+ <span data-ga-line="" className="block max-w-[32ch]">
529
+ {text}
530
+ </span>
531
+ </p>
532
+ ) : null}
533
+ </div>
534
+ {photos.map(({ key, image }) => {
535
+ const cell = CELLS[key];
536
+ return (
537
+ <div
538
+ key={key}
539
+ data-tile={key}
540
+ className={tileClass(cell.rs >= 3, cell.cs >= 5)}
541
+ style={{ ...place(cell), ...scatterVars(key) }}
542
+ >
543
+ <Image
544
+ src={image.src}
545
+ alt={image.alt}
546
+ fill
547
+ loading="eager"
548
+ sizes={`(min-width: 768px) ${Math.round((cell.cs / 12) * 92)}vw, ${cell.cs >= 5 ? 92 : 46}vw`}
549
+ unoptimized={image.unoptimized}
550
+ className={grade}
551
+ />
552
+ </div>
553
+ );
554
+ })}
555
+ <div
556
+ data-tile="h"
557
+ className={tileClass(true, true)}
558
+ style={{ ...place(CELLS.h), ...scatterVars("h") }}
559
+ >
560
+ {/* A window onto the full-screen photo once the assembly runs (the script positions it); the cell's own
561
+ crop otherwise. */}
562
+ <div data-window="" className="absolute inset-0">
563
+ <Image
564
+ src={hero.src}
565
+ alt={hero.alt}
566
+ fill
567
+ loading="eager"
568
+ sizes="(min-width: 768px) 100vw, 92vw"
569
+ unoptimized={hero.unoptimized}
570
+ className={grade}
571
+ />
572
+ </div>
573
+ </div>
574
+ </div>
575
+
576
+ {/* The grow: a full-screen plate of the hero photo, clipped to the hero's cell until it opens. */}
577
+ <div
578
+ data-bleed=""
579
+ className="pointer-events-none invisible absolute inset-0 z-10 hidden md:motion-safe:[:is(.js,.sq-js)_&]:block"
580
+ >
581
+ <div data-bleed-frame="" className="absolute inset-0 overflow-hidden">
582
+ <div data-bleed-drift="" className="absolute inset-0">
583
+ {/* Its own layer at scale 1 for the whole sequence: rastered whole while the grid holds, so the clip
584
+ can open without the browser painting a full-screen photo on the first frame of the grow. */}
585
+ <div data-bleed-plate="" className="absolute inset-0 will-change-transform [transform:translateZ(0)]">
586
+ <Image
587
+ src={hero.src}
588
+ alt=""
589
+ fill
590
+ loading="eager"
591
+ sizes="100vw"
592
+ unoptimized={hero.unoptimized}
593
+ className={grade}
594
+ />
595
+ </div>
596
+ </div>
597
+ <div data-veil-on="" aria-hidden className="absolute inset-0">
598
+ <div data-veil="" className="absolute inset-0 bg-[var(--ga-dark)] group-data-[tone=dark]/ga:bg-on-photo" style={{ opacity: SAFE_VEIL }} />
599
+ </div>
600
+ <div className="absolute inset-x-[5vw] bottom-[9svh]">{copy(true)}</div>
601
+ </div>
602
+ </div>
603
+ </div>
604
+
605
+ {/* The still hand-over (phones, reduced motion, pages without scripts): the photo full width with its line. */}
606
+ <figure
607
+ data-still-bleed=""
608
+ className="relative m-0 h-[82svh] overflow-hidden md:h-svh md:motion-safe:[:is(.js,.sq-js)_&]:hidden"
609
+ >
610
+ <Image src={hero.src} alt="" fill loading="eager" sizes="100vw" unoptimized={hero.unoptimized} className={grade} />
611
+ <div data-veil="" aria-hidden className="absolute inset-0 bg-[var(--ga-dark)] group-data-[tone=dark]/ga:bg-on-photo" style={{ opacity: SAFE_VEIL }} />
612
+ <div className="absolute inset-x-6 bottom-[8svh] md:inset-x-[5vw]">{copy(false)}</div>
613
+ </figure>
614
+ </section>
615
+ );
616
+ }