@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,818 @@
1
+ "use client";
2
+ // background-aurora (Struq background recipe): Noorderlicht
3
+ // A full-screen hero whose ground is a live field of northern light: slow curtains, each hanging up from a folding
4
+ // lower edge, brightest along that edge and where a fold turns side on, with long vertical rays drifting through them.
5
+ // On a dark palette the light is the accent laid over the ground, its brightest cores leaning toward the ink: real
6
+ // light. On a light palette it is a pale glow on paper, the curtain lifting toward a near white with only a faint
7
+ // shade of the accent at its thin edges. The headline (light display weight), one lead sentence and one text link
8
+ // stand on the calm side, clear of the curtains.
9
+ //
10
+ // <BackgroundAurora variant="veil" lines={["Ruimte voor", "het licht."]} lead="..." link={{ href: "/werk", label: "Bekijk het werk" }} />
11
+ //
12
+ // Three variants, three compositions of the same field:
13
+ // veil one tall curtain standing in the right third, two pleats crossing as it sways; the type on the calm left.
14
+ // horizon a low band of light along the foot of the screen under the type, rising a little as the hero scrolls away.
15
+ // drift the curtains across the upper half, travelling slowly sideways and bending away from the pointer.
16
+ //
17
+ // The field: raw WebGL2, one full-screen pass at reduced resolution (the light is soft, so half scale stays sharp
18
+ // enough; a fine dither keeps the upscale free of bands). The device pixel ratio is capped at 1.5. Phones get a
19
+ // lighter pass (lower scale, no fine ray layer, 30 frames a second) that still moves. The loop pauses off screen and
20
+ // in a hidden tab, survives a lost context, and lets everything go on unmount.
21
+ //
22
+ // Colour comes from the tokens on the block's root (--surface-0, --accent, --ink), read at runtime and again whenever
23
+ // the page switches palette or theme. No colour lives in this file.
24
+ //
25
+ // Contrast is measured, not trusted: every text box (data-quiet) gets a ceiling, the brightest the field may be under
26
+ // it so the text there still holds its ratio (display 3:1, small text 4.5:1) against the real colours, computed with
27
+ // the same colour math the shader uses. The compositions keep the curtains clear of the type, so the ceiling only
28
+ // catches a stray ray, and it meets the field softly: faint light runs on behind the text, no veil and no cutout.
29
+ //
30
+ // Entrance: the canvas stays hidden until its first frame is drawn, then fades in while the light dawns; the type
31
+ // rises through masks once its face is in (capped). Reduced motion: one rendered still frame, held, type in place. No
32
+ // WebGL: the same field drawn once, small, on a 2D canvas. No scripts: the composed hero on the plain ground; with
33
+ // scripts the type waits below its masks and is released by the CSS after 3 seconds if the script never runs
34
+ // (blocks.css, "--- background-aurora ---").
35
+ import { useEffect, useRef } from "react";
36
+ import { ScrollTrigger, gsap, useGSAP } from "../kit/gsap";
37
+
38
+ export type AuroraVariant = "veil" | "horizon" | "drift";
39
+
40
+ /** The variants, first is the default, for the catalogue and the lab picker. */
41
+ export const AURORA_VARIANTS: { id: AuroraVariant; title: string; does: string }[] = [
42
+ { id: "veil", title: "Gordijn", does: "One tall curtain of light standing in the right third, swaying; the type on the calm left." },
43
+ { id: "horizon", title: "Horizon", does: "A low band of light along the foot of the screen under the type, rising as the hero scrolls away." },
44
+ { id: "drift", title: "Drift", does: "Curtains across the upper half, drifting sideways and bending away from the pointer." },
45
+ ];
46
+
47
+ type Props = {
48
+ variant?: AuroraVariant;
49
+ /** The headline, one entry per line. Two short lines read best. */
50
+ lines: string[];
51
+ /** One lead sentence. */
52
+ lead: string;
53
+ /** One text link. */
54
+ link: { href: string; label: string };
55
+ className?: string;
56
+ };
57
+
58
+ const MODE: Record<AuroraVariant, number> = { veil: 0, horizon: 1, drift: 2 };
59
+
60
+ // The moment of the field the reduced motion still and the first frame start from: curtains well formed.
61
+ const T0 = 37;
62
+
63
+ // How softly a text box's ceiling meets the field around it, in canvas heights.
64
+ const FEATHER = 0.1;
65
+
66
+ // ---------------------------------------------------------------------------------------------------------------
67
+ // The shader. Space: u is the canvas in fractions (0,0 bottom left), p the same in canvas heights, so the light keeps
68
+ // its shape at any aspect. Noise never runs more than a handful of times per pixel.
69
+
70
+ const VERTEX = `#version 300 es
71
+ in vec2 aPos;
72
+ void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
73
+ `;
74
+
75
+ const FRAGMENT = `#version 300 es
76
+ precision highp float;
77
+ uniform vec2 uRes;
78
+ uniform float uTime;
79
+ uniform int uMode;
80
+ uniform float uNarrow;
81
+ uniform int uFine;
82
+ uniform vec3 uGround;
83
+ uniform vec3 uAccent;
84
+ uniform vec3 uInk;
85
+ uniform float uStrength;
86
+ uniform float uCore;
87
+ uniform int uLight;
88
+ uniform float uIntro;
89
+ uniform float uLeave;
90
+ uniform vec3 uPtr;
91
+ uniform vec4 uQuiet[3];
92
+ uniform float uCeil[3];
93
+ uniform vec2 uFeather;
94
+ out vec4 o;
95
+
96
+ float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
97
+ float h31(vec3 p) { p = fract(p * 0.3183099 + 0.1); p *= 17.0; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); }
98
+ float n3(vec3 x) {
99
+ vec3 i = floor(x), f = fract(x);
100
+ f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
101
+ return mix(
102
+ mix(mix(h31(i), h31(i + vec3(1, 0, 0)), f.x), mix(h31(i + vec3(0, 1, 0)), h31(i + vec3(1, 1, 0)), f.x), f.y),
103
+ mix(mix(h31(i + vec3(0, 0, 1)), h31(i + vec3(1, 0, 1)), f.x), mix(h31(i + vec3(0, 1, 1)), h31(i + vec3(1, 1, 1)), f.x), f.y),
104
+ f.z);
105
+ }
106
+ float gs(float x) { return exp(-x * x); }
107
+
108
+ // The folding lower edge of a curtain at s (along the curtain), in canvas heights.
109
+ float edgeAt(float s, float base, float amp, float t, float seed) {
110
+ return base + amp * sin(s * 1.9 - t * 0.11 + seed) + 0.5 * amp * sin(s * 5.3 + t * 0.07 + 2.0 * seed)
111
+ + 1.4 * amp * (n3(vec3(s * 0.9 - t * 0.04, seed, t * 0.05)) - 0.5);
112
+ }
113
+
114
+ // One curtain: light hanging up from its folding edge, brightest along the edge and where a fold turns side on,
115
+ // drawn in long vertical rays.
116
+ float sheet(float y, float s, float base, float amp, float hang, float t, float seed) {
117
+ float e = edgeAt(s, base, amp, t, seed);
118
+ float slope = (edgeAt(s + 0.03, base, amp, t, seed) - e) / 0.03;
119
+ float d = y - e;
120
+ // Two ray frequencies that never line up, so the rays fall unevenly instead of in a regular row, and each ray
121
+ // reaches its own height.
122
+ float r1 = n3(vec3(s * 13.0 - t * 0.14, d * 1.1, t * 0.07 + seed));
123
+ float r2 = n3(vec3(s * 31.0 + t * 0.09, d * 0.7, t * 0.05 + seed * 1.7));
124
+ float hg = hang * (0.6 + 0.25 * sin(s * 4.3 + seed) * sin(s * 1.7 - t * 0.02 + 2.0 * seed) + 0.15 * sin(s * 23.0 + 3.1 * seed));
125
+ // On paper the hem fades out a little wider, so it reads as glow and not as a drawn edge.
126
+ float below = uLight == 1 ? 0.08 : 0.022;
127
+ float prof = d < 0.0 ? gs(d / below) : 0.5 * exp(-d / hg) + 0.5 * gs(d / 0.04);
128
+ float rays = 0.07 + 0.93 * smoothstep(0.2, 0.9, sqrt(r1 * r2));
129
+ if (uFine == 1) rays *= 0.6 + 0.4 * n3(vec3(s * 55.0 + t * 0.1, d * 2.0, t * 0.12 + seed));
130
+ float fold = 1.0 + 1.2 * smoothstep(0.03, 0.22, abs(slope));
131
+ return prof * rays * fold;
132
+ }
133
+
134
+ float box(vec2 p, vec4 r, vec2 f) {
135
+ vec2 a = smoothstep(r.xy - f, r.xy, p);
136
+ vec2 b = 1.0 - smoothstep(r.zw, r.zw + f, p);
137
+ return a.x * a.y * b.x * b.y;
138
+ }
139
+
140
+ void main() {
141
+ float a = uRes.x / uRes.y;
142
+ vec2 u = gl_FragCoord.xy / uRes;
143
+ vec2 p = vec2(u.x * a, u.y);
144
+ float t = uTime;
145
+ float light = 0.0;
146
+ if (uMode == 0) {
147
+ // veil: one tall curtain in the right third, two pleats swaying across each other.
148
+ // On a phone the type stands at the foot, so the curtain hangs higher, above it.
149
+ float cx = mix(0.735, 0.66, uNarrow);
150
+ float wc = mix(0.1, 0.24, uNarrow);
151
+ float sway = 0.03 * sin(u.y * 2.3 - t * 0.12) + 0.06 * (n3(vec3(u.y * 1.2 - t * 0.03, 7.0, t * 0.04)) - 0.5);
152
+ float s = (u.x - sway) * a;
153
+ float base = mix(0.16, 0.53, uNarrow) + 0.06 * uLeave;
154
+ float top = 1.0 - smoothstep(0.72, 1.08, u.y);
155
+ float c1 = gs((u.x - cx - sway) / wc);
156
+ float c2 = gs((u.x - cx + 0.04 - 1.5 * sway) / (wc * 0.65));
157
+ // The second pleat's hem rises and falls on its own, so the two hems never meet in one shelf.
158
+ float base2 = base + 0.07 + 0.045 * sin(t * 0.09 + 1.3) + 0.02 * sin(s * 2.7 - t * 0.05);
159
+ light = 1.2 * c1 * sheet(p.y, s, base, 0.03, mix(0.55, 0.3, uNarrow), t, 1.3)
160
+ + 0.75 * c2 * sheet(p.y, s + 0.37, base2, 0.025, 0.4, t, 4.1);
161
+ // The foot dissolves into the room instead of ending in a line.
162
+ light *= mix(0.3, 1.0, smoothstep(base - 0.05, base + 0.16, p.y));
163
+ light = light * top + 0.1 * gs((u.x - cx) / (wc * 2.6)) * smoothstep(base - 0.08, base + 0.35, u.y) * top;
164
+ light *= 1.0 - 0.45 * uLeave;
165
+ } else if (uMode == 1) {
166
+ // horizon: a low band along the foot, stronger to the right, rising as the hero leaves. The second sheet is
167
+ // fainter, longer and folds deeper, so its hem never runs parallel to the first.
168
+ float base = mix(0.085, 0.1, uNarrow) + 0.12 * uLeave;
169
+ float side = mix(0.4, 1.0, smoothstep(0.0, 0.85, u.x));
170
+ light = 0.85 * sheet(p.y, p.x, base, 0.035, 0.13, t, 2.2)
171
+ + 0.25 * sheet(p.y, p.x * 0.62 + 1.7, base + 0.085, 0.055, 0.09, t, 5.7);
172
+ light = light * side + 0.09 * gs((u.y - base - 0.05) / 0.13) * side;
173
+ } else {
174
+ // drift: curtains across the upper half, weighted to the right, pushed gently away from the pointer. The hem
175
+ // hangs clear above the type.
176
+ vec2 pp = vec2(uPtr.x * a, uPtr.y);
177
+ vec2 dv = p - pp;
178
+ p -= dv * 0.5 * exp(-dot(dv, dv) / 0.08) * uPtr.z;
179
+ float base = mix(0.66, 0.7, uNarrow) + 0.08 * uLeave;
180
+ float side = mix(0.15, 1.0, smoothstep(0.25, 0.95, u.x));
181
+ light = 0.8 * sheet(p.y, p.x - t * 0.012, base, 0.06, 0.2, t, 3.4)
182
+ + 0.5 * sheet(p.y, p.x - t * 0.007 + 0.9, base + 0.12, 0.05, 0.15, t, 6.2);
183
+ light = light * side + 0.05 * smoothstep(base - 0.06, base + 0.3, p.y) * side;
184
+ }
185
+
186
+ float lum = 1.0 - exp(-light * 2.2);
187
+ float v = lum * uStrength * uIntro;
188
+ // Behind text a soft ceiling, measured per text box, for the stray ray that reaches it: faint light passes, a
189
+ // bright core is held down. A ceiling of 1 means the text holds against any light, and nothing is touched.
190
+ float qmax = 0.0;
191
+ for (int i = 0; i < 3; i++) {
192
+ vec4 r = uQuiet[i];
193
+ if (r.z <= r.x) continue;
194
+ float q = box(u, r, uFeather);
195
+ qmax = max(qmax, q);
196
+ float c = max(uCeil[i], 0.001);
197
+ if (c >= 1.0) continue;
198
+ v = mix(v, c * (1.0 - exp(-v / c)), q);
199
+ }
200
+ vec3 col;
201
+ if (uLight == 1) {
202
+ // Light on paper: the body of the curtain lifts toward a near white of the ground itself; only where the light
203
+ // is thin, on the fold sides and between the rays, does a faint shade of the accent show.
204
+ vec3 glow = mix(1.0 - (1.0 - uGround) * 0.06, uAccent, 0.03);
205
+ float tint = 0.1 * smoothstep(0.0, 0.18, v) * (1.0 - smoothstep(0.15, 0.4, v));
206
+ // The light only ever brightens the paper; it never casts a shadow on it.
207
+ col = max(mix(mix(uGround, uAccent, tint), glow, smoothstep(0.08, 0.6, v)), uGround);
208
+ } else {
209
+ // Light on a dark ground: real light, its brightest cores leaning toward the ink.
210
+ float core = smoothstep(0.5, 0.95, lum) * uCore * uIntro * (1.0 - qmax);
211
+ col = mix(mix(uGround, uAccent, v), uInk, core * 0.5);
212
+ }
213
+ col += (h21(gl_FragCoord.xy + fract(uTime * 7.31) * 113.0) - 0.5) * (1.5 / 255.0);
214
+ o = vec4(col, 1.0);
215
+ }
216
+ `;
217
+
218
+ // ---------------------------------------------------------------------------------------------------------------
219
+ // The same field in plain numbers, for a browser without WebGL2: drawn once, small, and stretched by the canvas.
220
+
221
+ const fract = (x: number) => x - Math.floor(x);
222
+ const mixN = (a: number, b: number, k: number) => a + (b - a) * k;
223
+ const gsN = (x: number) => Math.exp(-x * x);
224
+ const smooth = (e0: number, e1: number, x: number) => {
225
+ const k = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
226
+ return k * k * (3 - 2 * k);
227
+ };
228
+ function h31(x: number, y: number, z: number) {
229
+ x = fract(x * 0.3183099 + 0.1) * 17;
230
+ y = fract(y * 0.3183099 + 0.1) * 17;
231
+ z = fract(z * 0.3183099 + 0.1) * 17;
232
+ return fract(x * y * z * (x + y + z));
233
+ }
234
+ function n3(x: number, y: number, z: number) {
235
+ const ix = Math.floor(x), iy = Math.floor(y), iz = Math.floor(z);
236
+ const q = (f: number) => f * f * f * (f * (f * 6 - 15) + 10);
237
+ const fx = q(x - ix), fy = q(y - iy), fz = q(z - iz);
238
+ const at = (a: number, b: number, c: number) => h31(ix + a, iy + b, iz + c);
239
+ return mixN(
240
+ mixN(mixN(at(0, 0, 0), at(1, 0, 0), fx), mixN(at(0, 1, 0), at(1, 1, 0), fx), fy),
241
+ mixN(mixN(at(0, 0, 1), at(1, 0, 1), fx), mixN(at(0, 1, 1), at(1, 1, 1), fx), fy),
242
+ fz,
243
+ );
244
+ }
245
+ function edgeAtN(s: number, base: number, amp: number, t: number, seed: number) {
246
+ return (
247
+ base + amp * Math.sin(s * 1.9 - t * 0.11 + seed) + 0.5 * amp * Math.sin(s * 5.3 + t * 0.07 + 2 * seed) +
248
+ 1.4 * amp * (n3(s * 0.9 - t * 0.04, seed, t * 0.05) - 0.5)
249
+ );
250
+ }
251
+ function sheetN(y: number, s: number, base: number, amp: number, hang: number, t: number, seed: number, below: number) {
252
+ const e = edgeAtN(s, base, amp, t, seed);
253
+ const slope = (edgeAtN(s + 0.03, base, amp, t, seed) - e) / 0.03;
254
+ const d = y - e;
255
+ const r1 = n3(s * 13 - t * 0.14, d * 1.1, t * 0.07 + seed);
256
+ const r2 = n3(s * 31 + t * 0.09, d * 0.7, t * 0.05 + seed * 1.7);
257
+ const hg = hang * (0.6 + 0.25 * Math.sin(s * 4.3 + seed) * Math.sin(s * 1.7 - t * 0.02 + 2 * seed) + 0.15 * Math.sin(s * 23 + 3.1 * seed));
258
+ const prof = d < 0 ? gsN(d / below) : 0.5 * Math.exp(-d / hg) + 0.5 * gsN(d / 0.04);
259
+ const rays = 0.07 + 0.93 * smooth(0.2, 0.9, Math.sqrt(r1 * r2));
260
+ return prof * rays * (1 + 1.2 * smooth(0.03, 0.22, Math.abs(slope)));
261
+ }
262
+ function lightAt(ux: number, uy: number, a: number, mode: number, t: number, narrow: number, below: number) {
263
+ const px = ux * a;
264
+ const py = uy;
265
+ if (mode === 0) {
266
+ const cx = mixN(0.735, 0.66, narrow);
267
+ const wc = mixN(0.1, 0.24, narrow);
268
+ const sway = 0.03 * Math.sin(uy * 2.3 - t * 0.12) + 0.06 * (n3(uy * 1.2 - t * 0.03, 7, t * 0.04) - 0.5);
269
+ const s = (ux - sway) * a;
270
+ const base = mixN(0.16, 0.53, narrow);
271
+ const top = 1 - smooth(0.72, 1.08, uy);
272
+ const c1 = gsN((ux - cx - sway) / wc);
273
+ const c2 = gsN((ux - cx + 0.04 - 1.5 * sway) / (wc * 0.65));
274
+ const base2 = base + 0.07 + 0.045 * Math.sin(t * 0.09 + 1.3) + 0.02 * Math.sin(s * 2.7 - t * 0.05);
275
+ const l =
276
+ (1.2 * c1 * sheetN(py, s, base, 0.03, mixN(0.55, 0.3, narrow), t, 1.3, below) +
277
+ 0.75 * c2 * sheetN(py, s + 0.37, base2, 0.025, 0.4, t, 4.1, below)) *
278
+ mixN(0.3, 1, smooth(base - 0.05, base + 0.16, py));
279
+ return l * top + 0.1 * gsN((ux - cx) / (wc * 2.6)) * smooth(base - 0.08, base + 0.35, uy) * top;
280
+ }
281
+ if (mode === 1) {
282
+ const base = mixN(0.085, 0.1, narrow);
283
+ const side = mixN(0.4, 1, smooth(0, 0.85, ux));
284
+ const l =
285
+ 0.85 * sheetN(py, px, base, 0.035, 0.13, t, 2.2, below) + 0.25 * sheetN(py, px * 0.62 + 1.7, base + 0.085, 0.055, 0.09, t, 5.7, below);
286
+ return l * side + 0.09 * gsN((uy - base - 0.05) / 0.13) * side;
287
+ }
288
+ const base = mixN(0.66, 0.7, narrow);
289
+ const side = mixN(0.15, 1, smooth(0.25, 0.95, ux));
290
+ const l = 0.8 * sheetN(py, px - t * 0.012, base, 0.06, 0.2, t, 3.4, below) + 0.5 * sheetN(py, px - t * 0.007 + 0.9, base + 0.12, 0.05, 0.15, t, 6.2, below);
291
+ return l * side + 0.05 * smooth(base - 0.06, base + 0.3, py) * side;
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------------------------------------------
295
+ // Colour: the tokens resolved through a one pixel canvas, so every colour syntax (hex, oklch, color-mix) lands as sRGB.
296
+
297
+ type Rgb = [number, number, number];
298
+ type Colours = { ground: Rgb; accent: Rgb; ink: Rgb; light: boolean; strength: number; core: number };
299
+ type Zone = { rect: [number, number, number, number]; ceil: number };
300
+
301
+ let probe: CanvasRenderingContext2D | null = null;
302
+ function rgbOf(color: string): Rgb {
303
+ if (!probe) {
304
+ const cv = document.createElement("canvas");
305
+ cv.width = 1;
306
+ cv.height = 1;
307
+ probe = cv.getContext("2d", { willReadFrequently: true });
308
+ }
309
+ if (!probe) return [0, 0, 0];
310
+ probe.clearRect(0, 0, 1, 1);
311
+ probe.fillStyle = "#000";
312
+ probe.fillStyle = color;
313
+ probe.fillRect(0, 0, 1, 1);
314
+ const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
315
+ return [r / 255, g / 255, b / 255];
316
+ }
317
+ const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
318
+ const lum = ([r, g, b]: Rgb) => 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
319
+ const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
320
+ const mixRgb = (a: Rgb, b: Rgb, k: number): Rgb => [mixN(a[0], b[0], k), mixN(a[1], b[1], k), mixN(a[2], b[2], k)];
321
+
322
+ function readColours(el: HTMLElement): Colours {
323
+ const css = getComputedStyle(el);
324
+ const token = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
325
+ const ground = rgbOf(token("--surface-0", css.backgroundColor));
326
+ const ink = rgbOf(token("--ink", css.color));
327
+ const accent = rgbOf(token("--accent", css.color));
328
+ const light = lum(ground) >= 0.2;
329
+ // Light: a pale glow on paper with a faint accent shade at its thin edges. Dark: real light, its cores leaning
330
+ // toward the ink.
331
+ return { ground, accent, ink, light, strength: light ? 0.9 : 0.95, core: light ? 0 : 0.5 };
332
+ }
333
+
334
+ /** The colour of the field at strength `v` with ink core `core`: the shader's colour model in plain numbers. */
335
+ function colourAt(c: Colours, v: number, core: number): Rgb {
336
+ if (c.light) {
337
+ const glow = mixRgb(c.ground.map((x) => 1 - (1 - x) * 0.06) as Rgb, c.accent, 0.03);
338
+ const tint = 0.1 * smooth(0, 0.18, v) * (1 - smooth(0.15, 0.4, v));
339
+ const out = mixRgb(mixRgb(c.ground, c.accent, tint), glow, smooth(0.08, 0.6, v));
340
+ return out.map((x, i) => Math.max(x, c.ground[i])) as Rgb;
341
+ }
342
+ return mixRgb(mixRgb(c.ground, c.accent, v), c.ink, core * 0.5);
343
+ }
344
+
345
+ /**
346
+ * The brightest the field may be (0 to 1, the shader's own strength) under text of colour `text` for `ratio`, or 1
347
+ * when the text holds against any light and the field may run on untouched.
348
+ */
349
+ function ceilingFor(c: Colours, text: Rgb, ratio: number) {
350
+ const lt = lum(text);
351
+ let v = 0;
352
+ for (let s = 0.01; s <= 1.0001; s += 0.01) {
353
+ if (contrast(lum(colourAt(c, s, 0)), lt) < ratio) {
354
+ // A small margin for the dither and rounding.
355
+ return Math.max(0.002, v * 0.95);
356
+ }
357
+ v = s;
358
+ }
359
+ return 1;
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------------------------------------------
363
+ // WebGL plumbing.
364
+
365
+ type Gl = {
366
+ program: WebGLProgram;
367
+ vao: WebGLVertexArrayObject;
368
+ buffer: WebGLBuffer;
369
+ loc: Record<string, WebGLUniformLocation | null>;
370
+ };
371
+
372
+ const UNIFORMS = [
373
+ "uRes", "uTime", "uMode", "uNarrow", "uFine", "uGround", "uAccent", "uInk", "uStrength", "uCore", "uLight", "uIntro", "uLeave",
374
+ "uPtr", "uQuiet", "uCeil", "uFeather",
375
+ ];
376
+
377
+ function compile(gl: WebGL2RenderingContext, type: number, source: string) {
378
+ const shader = gl.createShader(type);
379
+ if (!shader) return null;
380
+ gl.shaderSource(shader, source);
381
+ gl.compileShader(shader);
382
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
383
+ if (!gl.isContextLost()) console.warn("background-aurora:", gl.getShaderInfoLog(shader));
384
+ gl.deleteShader(shader);
385
+ return null;
386
+ }
387
+ return shader;
388
+ }
389
+
390
+ function build(gl: WebGL2RenderingContext): Gl | null {
391
+ const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);
392
+ const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
393
+ const program = gl.createProgram();
394
+ if (!vs || !fs || !program) return null;
395
+ gl.attachShader(program, vs);
396
+ gl.attachShader(program, fs);
397
+ gl.bindAttribLocation(program, 0, "aPos");
398
+ gl.linkProgram(program);
399
+ gl.deleteShader(vs);
400
+ gl.deleteShader(fs);
401
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
402
+ gl.deleteProgram(program);
403
+ return null;
404
+ }
405
+ const vao = gl.createVertexArray();
406
+ const buffer = gl.createBuffer();
407
+ if (!vao || !buffer) return null;
408
+ gl.bindVertexArray(vao);
409
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
410
+ // One triangle that covers the screen.
411
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
412
+ gl.enableVertexAttribArray(0);
413
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
414
+ gl.bindVertexArray(null);
415
+ const loc: Gl["loc"] = {};
416
+ for (const name of UNIFORMS) loc[name] = gl.getUniformLocation(program, name);
417
+ return { program, vao, buffer, loc };
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------------------------------------------
421
+
422
+ export function BackgroundAurora({ variant = "veil", lines, lead, link, className = "" }: Props) {
423
+ const root = useRef<HTMLElement>(null);
424
+ const canvasRef = useRef<HTMLCanvasElement>(null);
425
+
426
+ // The field.
427
+ useEffect(() => {
428
+ const el = root.current;
429
+ const canvas = canvasRef.current;
430
+ if (!el || !canvas) return;
431
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
432
+ const finePointer = window.matchMedia("(pointer: fine)").matches;
433
+ const mode = MODE[variant];
434
+ let gone = false;
435
+ let colours = readColours(el);
436
+ let zones: Zone[] = [];
437
+ let narrow = false;
438
+ let onScreen = true;
439
+ let lost = false;
440
+ let lit = false;
441
+ let raf = 0;
442
+ let last = 0;
443
+ let clock = 0;
444
+ let introClock = 0;
445
+ const leave = { now: 0, target: 0 };
446
+ const ptr = { x: 0.5, y: 0.5, z: 0, tx: 0.5, ty: 0.5, tz: 0 };
447
+
448
+ const gl = canvas.getContext("webgl2", {
449
+ alpha: false,
450
+ antialias: false,
451
+ depth: false,
452
+ stencil: false,
453
+ powerPreference: "low-power",
454
+ });
455
+ let prog: Gl | null = gl ? build(gl) : null;
456
+
457
+ const show = () => {
458
+ if (lit) return;
459
+ lit = true;
460
+ // The frame is on screen once the browser has presented it: the canvas fades in from the next frame.
461
+ requestAnimationFrame(() => {
462
+ if (!gone) el.dataset.lit = "";
463
+ });
464
+ };
465
+
466
+ const measure = () => {
467
+ const box = el.getBoundingClientRect();
468
+ if (box.width < 1 || box.height < 1) return;
469
+ zones = Array.from(el.querySelectorAll<HTMLElement>("[data-quiet]"))
470
+ .slice(0, 3)
471
+ .map((z) => {
472
+ const r = z.getBoundingClientRect();
473
+ const ratio = z.dataset.quiet === "display" ? 3 : 4.5;
474
+ return {
475
+ rect: [
476
+ (r.left - box.left) / box.width,
477
+ 1 - (r.bottom - box.top) / box.height,
478
+ (r.right - box.left) / box.width,
479
+ 1 - (r.top - box.top) / box.height,
480
+ ],
481
+ ceil: ceilingFor(colours, rgbOf(getComputedStyle(z).color), ratio),
482
+ };
483
+ });
484
+ };
485
+
486
+ // The still for a browser without WebGL2: the same field at a small size, drawn once.
487
+ const drawStill = () => {
488
+ const ctx = canvas.getContext("2d");
489
+ if (!ctx) return;
490
+ const w = el.clientWidth;
491
+ const h = el.clientHeight;
492
+ if (w < 1 || h < 1) return;
493
+ const cw = narrow ? 96 : 160;
494
+ const ch = Math.max(2, Math.round((cw * h) / w));
495
+ canvas.width = cw;
496
+ canvas.height = ch;
497
+ const img = ctx.createImageData(cw, ch);
498
+ const a = w / h;
499
+ for (let y = 0; y < ch; y += 1) {
500
+ for (let x = 0; x < cw; x += 1) {
501
+ const ux = (x + 0.5) / cw;
502
+ const uy = 1 - (y + 0.5) / ch;
503
+ const l = 1 - Math.exp(-lightAt(ux, uy, a, mode, T0, narrow ? 1 : 0, colours.light ? 0.08 : 0.022) * 2.2);
504
+ let v = l * colours.strength;
505
+ let q = 0;
506
+ for (const z of zones) {
507
+ const [x0, y0, x1, y1] = z.rect;
508
+ const f = FEATHER;
509
+ const inside =
510
+ smooth(x0 - f / a, x0, ux) * smooth(y0 - f, y0, uy) * (1 - smooth(x1, x1 + f / a, ux)) * (1 - smooth(y1, y1 + f, uy));
511
+ q = Math.max(q, inside);
512
+ if (z.ceil < 1) v = mixN(v, z.ceil * (1 - Math.exp(-v / z.ceil)), inside);
513
+ }
514
+ const c = colourAt(colours, v, smooth(0.5, 0.95, l) * colours.core * (1 - q));
515
+ const i = (y * cw + x) * 4;
516
+ img.data[i] = c[0] * 255;
517
+ img.data[i + 1] = c[1] * 255;
518
+ img.data[i + 2] = c[2] * 255;
519
+ img.data[i + 3] = 255;
520
+ }
521
+ }
522
+ ctx.putImageData(img, 0, 0);
523
+ show();
524
+ };
525
+
526
+ const draw = () => {
527
+ if (!gl || !prog || lost) return;
528
+ const { loc } = prog;
529
+ gl.viewport(0, 0, canvas.width, canvas.height);
530
+ gl.useProgram(prog.program);
531
+ gl.bindVertexArray(prog.vao);
532
+ const a = canvas.width / Math.max(1, canvas.height);
533
+ const quiet = new Float32Array(12);
534
+ const ceil = new Float32Array(3);
535
+ zones.forEach((z, i) => {
536
+ quiet.set(z.rect, i * 4);
537
+ ceil[i] = z.ceil;
538
+ });
539
+ gl.uniform2f(loc.uRes, canvas.width, canvas.height);
540
+ gl.uniform1f(loc.uTime, T0 + clock);
541
+ gl.uniform1i(loc.uMode, mode);
542
+ gl.uniform1f(loc.uNarrow, narrow ? 1 : 0);
543
+ gl.uniform1i(loc.uFine, narrow ? 0 : 1);
544
+ gl.uniform3fv(loc.uGround, colours.ground);
545
+ gl.uniform3fv(loc.uAccent, colours.accent);
546
+ gl.uniform3fv(loc.uInk, colours.ink);
547
+ gl.uniform1f(loc.uStrength, colours.strength);
548
+ gl.uniform1f(loc.uCore, colours.core);
549
+ gl.uniform1i(loc.uLight, colours.light ? 1 : 0);
550
+ // The light dawns over its first seconds; a held still is fully lit.
551
+ const k = Math.min(1, introClock / 2.8);
552
+ gl.uniform1f(loc.uIntro, reduce ? 1 : 1 - Math.pow(1 - k, 3));
553
+ gl.uniform1f(loc.uLeave, leave.now);
554
+ gl.uniform3f(loc.uPtr, ptr.x, ptr.y, ptr.z);
555
+ gl.uniform4fv(loc.uQuiet, quiet);
556
+ gl.uniform1fv(loc.uCeil, ceil);
557
+ gl.uniform2f(loc.uFeather, FEATHER / a, FEATHER);
558
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
559
+ gl.bindVertexArray(null);
560
+ show();
561
+ };
562
+
563
+ const active = () => !gone && !reduce && !lost && !!prog && onScreen && !document.hidden;
564
+ const tick = (now: number) => {
565
+ raf = 0;
566
+ if (!active()) return;
567
+ raf = requestAnimationFrame(tick);
568
+ // Phones: thirty frames a second is plenty for light this slow.
569
+ if (narrow && last && now - last < 31) return;
570
+ const dt = last ? Math.min(0.1, (now - last) / 1000) : 0;
571
+ last = now;
572
+ clock += dt;
573
+ introClock += dt;
574
+ // The pointer eases in over about 0.18 s; the scroll settles like a camera, about 0.4 s.
575
+ const kp = 1 - Math.exp(-dt / 0.06);
576
+ ptr.x += (ptr.tx - ptr.x) * kp;
577
+ ptr.y += (ptr.ty - ptr.y) * kp;
578
+ ptr.z += (ptr.tz - ptr.z) * (1 - Math.exp(-dt / 0.12));
579
+ leave.now += (leave.target - leave.now) * (1 - Math.exp(-dt / 0.12));
580
+ draw();
581
+ };
582
+ const wake = () => {
583
+ if (raf || !active()) return;
584
+ last = 0;
585
+ raf = requestAnimationFrame(tick);
586
+ };
587
+
588
+ const resize = () => {
589
+ const w = el.clientWidth;
590
+ const h = el.clientHeight;
591
+ if (w < 1 || h < 1) return;
592
+ narrow = w < 768;
593
+ measure();
594
+ if (!gl || !prog) {
595
+ drawStill();
596
+ return;
597
+ }
598
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
599
+ const scale = narrow ? 0.5 : 0.75;
600
+ canvas.width = Math.max(2, Math.round(w * dpr * scale));
601
+ canvas.height = Math.max(2, Math.round(h * dpr * scale));
602
+ // A resize clears the canvas: draw at once so it never shows blank.
603
+ draw();
604
+ };
605
+
606
+ // Palette or theme switched: new colours, new ceilings, and a fresh frame even when the loop is paused.
607
+ const recolour = () => {
608
+ colours = readColours(el);
609
+ measure();
610
+ if (gl && prog) draw();
611
+ else drawStill();
612
+ };
613
+
614
+ // Races get ids: only the latest font settle re-measures.
615
+ let fontsId = 0;
616
+ const refit = () => {
617
+ const id = ++fontsId;
618
+ document.fonts?.ready.then(() => {
619
+ if (gone || id !== fontsId) return;
620
+ measure();
621
+ if (gl && prog) draw();
622
+ else drawStill();
623
+ });
624
+ };
625
+
626
+ const sized = new ResizeObserver(resize);
627
+ sized.observe(el);
628
+ resize();
629
+ refit();
630
+ document.fonts?.addEventListener?.("loadingdone", refit);
631
+
632
+ const host = el.closest<HTMLElement>("[data-blocks]");
633
+ const themed = new MutationObserver(recolour);
634
+ if (host) themed.observe(host, { attributes: true, attributeFilter: ["style", "class"] });
635
+ themed.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
636
+
637
+ const seen =
638
+ typeof IntersectionObserver === "undefined"
639
+ ? null
640
+ : new IntersectionObserver((entries) => {
641
+ onScreen = entries.some((e) => e.isIntersecting);
642
+ wake();
643
+ });
644
+ seen?.observe(el);
645
+ const visibility = () => wake();
646
+ document.addEventListener("visibilitychange", visibility);
647
+
648
+ const onLost = (e: Event) => {
649
+ e.preventDefault();
650
+ lost = true;
651
+ if (raf) cancelAnimationFrame(raf);
652
+ raf = 0;
653
+ };
654
+ const onRestored = () => {
655
+ if (!gl) return;
656
+ lost = false;
657
+ prog = build(gl);
658
+ resize();
659
+ wake();
660
+ };
661
+ canvas.addEventListener("webglcontextlost", onLost);
662
+ canvas.addEventListener("webglcontextrestored", onRestored);
663
+
664
+ // The scroll: the hero leaving the screen, 0 to 1.
665
+ const trigger = reduce
666
+ ? null
667
+ : ScrollTrigger.create({
668
+ trigger: el,
669
+ start: "top top",
670
+ end: "bottom top",
671
+ onUpdate: (self) => {
672
+ leave.target = self.progress;
673
+ },
674
+ });
675
+
676
+ // The pointer, for drift only, on a fine pointer.
677
+ const usePointer = mode === 2 && finePointer && !reduce;
678
+ const onMove = (e: PointerEvent) => {
679
+ const r = el.getBoundingClientRect();
680
+ const x = (e.clientX - r.left) / r.width;
681
+ const y = 1 - (e.clientY - r.top) / r.height;
682
+ const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1;
683
+ // Arriving from nowhere, the bend starts where the pointer is instead of sweeping in from the centre.
684
+ if (ptr.z < 0.02) {
685
+ ptr.x = x;
686
+ ptr.y = y;
687
+ }
688
+ ptr.tx = x;
689
+ ptr.ty = y;
690
+ ptr.tz = inside ? 1 : 0;
691
+ };
692
+ const onOut = () => {
693
+ ptr.tz = 0;
694
+ };
695
+ if (usePointer) {
696
+ window.addEventListener("pointermove", onMove, { passive: true });
697
+ document.documentElement.addEventListener("pointerleave", onOut);
698
+ }
699
+
700
+ wake();
701
+
702
+ return () => {
703
+ gone = true;
704
+ if (raf) cancelAnimationFrame(raf);
705
+ sized.disconnect();
706
+ themed.disconnect();
707
+ seen?.disconnect();
708
+ trigger?.kill();
709
+ document.removeEventListener("visibilitychange", visibility);
710
+ document.fonts?.removeEventListener?.("loadingdone", refit);
711
+ canvas.removeEventListener("webglcontextlost", onLost);
712
+ canvas.removeEventListener("webglcontextrestored", onRestored);
713
+ if (usePointer) {
714
+ window.removeEventListener("pointermove", onMove);
715
+ document.documentElement.removeEventListener("pointerleave", onOut);
716
+ }
717
+ if (gl && prog && !gl.isContextLost()) {
718
+ gl.deleteBuffer(prog.buffer);
719
+ gl.deleteVertexArray(prog.vao);
720
+ gl.deleteProgram(prog.program);
721
+ }
722
+ prog = null;
723
+ // The context itself is left to the browser: under strict mode the same canvas mounts again at once, and a
724
+ // context lost on purpose would come back lost.
725
+ delete el.dataset.lit;
726
+ };
727
+ }, [variant]);
728
+
729
+ // The type: it rises through masks once its face is in (at most 1.2 s), or stays where the CSS fallback put it.
730
+ useGSAP(
731
+ (_, contextSafe) => {
732
+ const el = root.current;
733
+ if (!el || !contextSafe) return;
734
+ const rise = Array.from(el.querySelectorAll<HTMLElement>("[data-rise]"));
735
+ if (!rise.length) return;
736
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
737
+ // A late script: once the CSS fallback has run its 3 s the lines are shown, and they stay. Decided once per
738
+ // element, so strict mode's second run does not read the state its first run left behind.
739
+ const fallback = rise[0]
740
+ .getAnimations()
741
+ .find((a) => (a as CSSAnimation).animationName === "struq-bga-rise");
742
+ el.dataset.shown ??= String(
743
+ reduce || (fallback ? Number(fallback.currentTime ?? 0) >= 2900 : getComputedStyle(rise[0]).transform === "none"),
744
+ );
745
+ const shown = el.dataset.shown === "true";
746
+ // y: 0 as well, or GSAP reads the CSS start state into a pixel offset and keeps it.
747
+ gsap.set(rise, { y: 0, yPercent: shown ? 0 : 115 });
748
+ el.dataset.ready = "";
749
+ if (shown) return;
750
+ let gone = false;
751
+ const play = contextSafe(() => {
752
+ if (gone) return;
753
+ gsap.to(rise, { yPercent: 0, duration: 1.2, ease: "expo.out", stagger: 0.09, delay: 0.15 });
754
+ });
755
+ const fonts = document.fonts?.ready ?? Promise.resolve();
756
+ Promise.race([fonts, new Promise((r) => setTimeout(r, 1200))]).then(play);
757
+ return () => {
758
+ gone = true;
759
+ };
760
+ },
761
+ { scope: root, dependencies: [variant] },
762
+ );
763
+
764
+ const mask = (child: React.ReactNode, key?: number) => (
765
+ <span key={key} className="struq-line-mask block overflow-hidden">
766
+ <span data-rise="" className="block">
767
+ {child}
768
+ </span>
769
+ </span>
770
+ );
771
+
772
+ // Where the type stands, per composition: the calm left beside the curtain, above the band, or under the curtains.
773
+ const place =
774
+ variant === "horizon"
775
+ ? "items-start pt-[15svh] md:pt-[17svh]"
776
+ : variant === "drift"
777
+ ? "items-end pb-[9svh] md:pb-[6svh]"
778
+ : "items-end pb-[9svh] md:items-center md:pb-0";
779
+
780
+ return (
781
+ <section
782
+ ref={root}
783
+ data-bg-aurora=""
784
+ data-variant={variant}
785
+ suppressHydrationWarning
786
+ className={`relative isolate flex h-svh min-h-[34rem] overflow-hidden bg-surface-0 text-ink ${className}`}
787
+ >
788
+ <canvas ref={canvasRef} data-field="" aria-hidden="true" className="absolute inset-0 block h-full w-full" />
789
+ <div className={`relative z-10 flex w-full px-6 md:px-[4vw] ${place}`}>
790
+ <div className={variant === "veil" ? "md:max-w-[52%]" : "max-w-[min(100%,52rem)]"}>
791
+ <h1
792
+ data-quiet="display"
793
+ className="w-fit font-display text-[clamp(2.9rem,12.5vw,4.5rem)] leading-[0.95] tracking-[-0.03em] md:text-[clamp(3.5rem,7vw,8rem)]"
794
+ >
795
+ {lines.map((line, i) => mask(line, i))}
796
+ </h1>
797
+ {/* The spacing sits on plain wrappers: the line mask's own negative margins would cancel it. */}
798
+ <div className="mt-7 md:mt-10">
799
+ <div data-quiet="small" className="struq-line-mask max-w-[34ch] overflow-hidden text-ink-muted">
800
+ <p data-rise="" className="text-lead m-0">
801
+ {lead}
802
+ </p>
803
+ </div>
804
+ </div>
805
+ <div className="mt-5 md:mt-6">
806
+ <div data-quiet="small" className="struq-line-mask w-fit overflow-hidden">
807
+ <span data-rise="" className="block">
808
+ <a href={link.href} className="text-body underline decoration-1 underline-offset-[0.3em]">
809
+ {link.label}
810
+ </a>
811
+ </span>
812
+ </div>
813
+ </div>
814
+ </div>
815
+ </div>
816
+ </section>
817
+ );
818
+ }