@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,906 @@
1
+ "use client";
2
+ // background-silk (Struq background recipe)
3
+ // Zijde: a sheet of silk in raking light. Long soft folds with a sheen that travels slowly over their crests, the
4
+ // sheen in the palette's accent, the cloth one step off the page ground (its surface token). Slow and heavy, like
5
+ // expensive fabric, never a liquid. A port of the field idea from Vapor's Zijde (lib/ambient/backdrops/silk.ts:
6
+ // diagonal satin folds bent by slow noise, the light catching each crest), drawn here as a continuous surface with a
7
+ // real normal and a low light instead of a halftone grid. Raw WebGL (GLSL ES 1.00, so a WebGL1 context runs it too),
8
+ // no three.js, self-contained.
9
+ //
10
+ // <BackgroundSilk variant="fold" lines={["Zacht licht,", "zware stof."]} lead="..." link={{ href: "#werk", label: "Bekijk het werk" }} />
11
+ //
12
+ // Three compositions of the same cloth:
13
+ // - fold: one large diagonal fold through the right half; the type stands on the calm left. The pointer turns the
14
+ // light a little, so the sheen slides along the fold like a sleeve tilted to a window.
15
+ // - ripple: the cloth across the whole screen in low relief, fuller toward the upper right; the pointer sends a slow
16
+ // ripple through it. The type stands bottom left.
17
+ // - drape: the cloth hangs from the top edge in pleats to a hem that falls toward the right; the type stands below the
18
+ // hem. Scrolling the hero away draws the cloth down over it and the pleats stretch.
19
+ //
20
+ // Colour comes from the tokens on the block's own root (ground, surface, ink, accent), read at runtime and read again
21
+ // when the lab switches palette or theme, so every palette works light and dark. Behind the type the cloth lies
22
+ // flatter (the field itself is quieter there, no veil), and how flat is measured from the drawn pixels: the display
23
+ // type holds 3:1 and the small text 4.5:1 over the brightest or darkest pixel under it, with a margin for the moving
24
+ // sheen. That measure is taken once (and again on a resize or a palette switch), at three moments of the wandering
25
+ // light, and the cloth eases toward it.
26
+ //
27
+ // Entrance: the canvas is hidden until its first frame is drawn, then fades in while the folds rise from a flat sheet;
28
+ // the type rises through masks. Performance: device pixel ratio capped at 1.5, the field drawn at 0.9 of that on a 1x
29
+ // screen and two thirds on a dense one (half on phones, with fewer noise octaves), paused off screen and in a hidden
30
+ // tab, everything disposed on
31
+ // unmount, context loss survived. Reduced motion: one still frame, held. No WebGL: the plain ground with the type.
32
+ // No scripts: the composed hero on the plain ground (blocks.css, "--- background-silk ---").
33
+ import { useRef } from "react";
34
+ import { gsap, useGSAP } from "../kit/gsap";
35
+
36
+ export const SILK_VARIANTS = [
37
+ { id: "fold", title: "Plooi" },
38
+ { id: "ripple", title: "Rimpeling" },
39
+ { id: "drape", title: "Draperie" },
40
+ ] as const;
41
+
42
+ export type SilkVariant = (typeof SILK_VARIANTS)[number]["id"];
43
+
44
+ type Props = {
45
+ variant?: SilkVariant;
46
+ /** The headline, one entry per line. Two short lines read best. */
47
+ lines: string[];
48
+ /** One lead sentence under the headline. */
49
+ lead: string;
50
+ /** One text link. */
51
+ link: { href: string; label: string };
52
+ className?: string;
53
+ };
54
+
55
+ type Rgb = [number, number, number];
56
+ type Box = { x0: number; y0: number; x1: number; y1: number };
57
+
58
+ const DPR_CAP = 1.5;
59
+ const INTRO = 2.8; // seconds for the folds to rise from a flat sheet
60
+ const STILL_T = 26; // the moment a reduced-motion still is drawn at
61
+ const HUSH_START = 0.4; // how much relief the cloth keeps behind the type before measuring
62
+ const HUSHES = [0.4, 0.3, 0.2, 0.1, 0]; // tried in turn, one per frame, until the type holds
63
+ const PROBES = [0, 35, 70]; // seconds ahead the sheen is read at: now, a quarter and half a turn of the light later
64
+ const RINGS = 6;
65
+
66
+ const VERTEX = `
67
+ attribute vec2 aPos;
68
+ void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
69
+ `;
70
+
71
+ // Space p: x and y in screen heights from the bottom left, so the folds keep their size on any aspect.
72
+ const FRAGMENT = `
73
+ precision highp float;
74
+ uniform vec2 uRes;
75
+ uniform float uTime;
76
+ uniform float uIntro;
77
+ uniform float uVariant;
78
+ uniform float uPortrait;
79
+ uniform vec3 uG;
80
+ uniform vec3 uS;
81
+ uniform vec3 uI;
82
+ uniform vec3 uA;
83
+ uniform vec3 uP;
84
+ uniform float uDarkGround;
85
+ uniform vec3 uPtr;
86
+ uniform vec4 uRip[${RINGS}];
87
+ uniform vec4 uQHead;
88
+ uniform vec4 uQSmall;
89
+ uniform float uFeather;
90
+ uniform float uHush;
91
+ uniform float uScroll;
92
+ uniform float uAz;
93
+ uniform float uOct;
94
+ uniform float uFloor;
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);
100
+ vec3 f = fract(x);
101
+ f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
102
+ return mix(
103
+ mix(mix(h31(i), h31(i + vec3(1.0, 0.0, 0.0)), f.x), mix(h31(i + vec3(0.0, 1.0, 0.0)), h31(i + vec3(1.0, 1.0, 0.0)), f.x), f.y),
104
+ mix(mix(h31(i + vec3(0.0, 0.0, 1.0)), h31(i + vec3(1.0, 0.0, 1.0)), f.x), mix(h31(i + vec3(0.0, 1.0, 1.0)), h31(i + vec3(1.0, 1.0, 1.0)), f.x), f.y),
105
+ f.z);
106
+ }
107
+ float fbm(vec3 p) {
108
+ float v = 0.0;
109
+ float a = 0.5;
110
+ float s = 0.0;
111
+ for (int i = 0; i < 4; i++) {
112
+ if (float(i) >= uOct) break;
113
+ v += a * n3(p);
114
+ s += a;
115
+ p = p * 2.03 + vec3(1.7, -3.1, 2.3);
116
+ a *= 0.5;
117
+ }
118
+ return v / s;
119
+ }
120
+
121
+ // Fold: one large diagonal fold through the right half (across the top on a tall screen), smaller folds beside it,
122
+ // a long slow swell over the whole sheet.
123
+ float hFold(vec2 p, float a, float t) {
124
+ vec2 o = mix(vec2(0.74 * a, 0.5), vec2(0.6 * a, 0.74), uPortrait);
125
+ // About 53 degrees on a wide screen: one real diagonal from the lower middle to the upper right.
126
+ vec2 dir = normalize(mix(vec2(0.75, 1.0), vec2(1.0, 0.42), uPortrait));
127
+ vec2 nr = vec2(dir.y, -dir.x);
128
+ float w = fbm(vec3(p * 0.8 + 3.0, t * 0.016)) - 0.5;
129
+ vec2 r = p - o;
130
+ float along = dot(r, dir);
131
+ float d = dot(r, nr) + w * 0.2 + 0.04 * sin(along * 1.7 + t * 0.05);
132
+ float wid = d < 0.0 ? 0.17 : 0.085;
133
+ float big = 0.1 * exp(-d * d / (wid * wid));
134
+ // A lesser fold falling beside it, on its shadow side.
135
+ float d2 = d + 0.3 + 0.05 * sin(along * 2.3 - t * 0.04);
136
+ float wid2 = d2 < 0.0 ? 0.11 : 0.06;
137
+ big += 0.045 * exp(-d2 * d2 / (wid2 * wid2));
138
+ float u = d * 13.0 + w * 2.5 - t * 0.06;
139
+ float small = 0.028 * (sin(u) + 0.3 * sin(2.0 * u + 1.3)) * exp(-abs(d) * 1.6);
140
+ float swell = 0.018 * sin(dot(p, vec2(0.6, 0.8)) * 3.0 + w * 2.0 + t * 0.03);
141
+ float side = mix(smoothstep(0.3 * a, 0.68 * a, p.x), smoothstep(0.34, 0.7, p.y), uPortrait);
142
+ return (big + small) * side + swell * mix(0.2, 1.0, side);
143
+ }
144
+
145
+ // Ripple: long low folds over the whole screen, fuller toward the upper right. The pointer sends a slow ripple through
146
+ // them: a band that runs out across the folds (stretched along them, so it reads as cloth, not a drop in water), and
147
+ // as it passes the folds lift and shift a little, then settle.
148
+ float hRipple(vec2 p, float a, float t) {
149
+ // The crests rise to the right, across the light from the upper left, so it rakes them instead of running along.
150
+ vec2 across = vec2(-0.6, 0.8);
151
+ vec2 along = vec2(0.8, 0.6);
152
+ float pulse = 0.0;
153
+ for (int k = 0; k < ${RINGS}; k++) {
154
+ vec4 rp = uRip[k];
155
+ if (rp.w <= 0.0) continue;
156
+ vec2 r = p - rp.xy / uRes.y;
157
+ float y = dot(r, along);
158
+ float dd = abs(dot(r, across)) - rp.z * 0.13;
159
+ float life = (1.0 - smoothstep(2.5, 7.5, rp.z)) * smoothstep(0.0, 1.0, rp.z);
160
+ pulse += exp(-dd * dd / 0.01) * exp(-y * y / 0.35) * life * rp.w;
161
+ }
162
+ pulse = min(pulse, 1.4);
163
+ float w = fbm(vec3(p * 0.7 + 7.0, t * 0.014)) - 0.5;
164
+ float u = dot(p, across) * 12.0 + w * 3.0 - t * 0.05 + pulse * 1.1;
165
+ // A dark ground shows less of a shallow crest, so the relief is fuller there.
166
+ float folds = mix(0.022, 0.03, uDarkGround) * (sin(u) + 0.35 * sin(2.0 * u + 0.7));
167
+ float u2 = dot(p, vec2(-0.5, 0.87)) * 4.0 + w * 2.0 + t * 0.02;
168
+ float weight = mix(0.4, 1.0, smoothstep(0.15, 0.9, (p.x / a) * 0.6 + p.y * 0.4));
169
+ return (folds * (1.0 + pulse * 0.9) + 0.026 * sin(u2)) * weight;
170
+ }
171
+
172
+ // Drape: pleats hanging from the top edge, shallow where they are gathered and deeper toward the hem. x is the pleat
173
+ // phase (also used for the wavy hem), y how far down the cloth the point is, 0 at the top and 1 at the hem.
174
+ vec2 drapeAt(vec2 p, float a, float t, out float hem) {
175
+ float yt = 1.0 - p.y;
176
+ float xn = p.x / a;
177
+ float rest = mix(mix(0.3, 0.62, xn), mix(0.34, 0.46, xn), uPortrait);
178
+ // Over the headline's columns the hem stays at least 0.08 screen heights above it (a soft min, so no kink), and it
179
+ // falls to its own line again past the headline's right end.
180
+ if (uQHead.z > uQHead.x) {
181
+ float lim = max(1.0 - uQHead.w / uRes.y - 0.08, 0.05);
182
+ float k = 0.03;
183
+ float held = -k * log(exp(-rest / k) + exp(-lim / k));
184
+ float x1 = uQHead.z / uRes.y;
185
+ rest = mix(rest, held, 1.0 - smoothstep(x1, x1 + 0.35, p.x));
186
+ }
187
+ // The hem holds while the type leaves, then the cloth is drawn down; it never passes the hero's foot, so the hem
188
+ // itself is the hand-over edge.
189
+ hem = rest * mix(0.55, 1.0, uIntro) + max(uScroll - 0.3, 0.0) * 1.8;
190
+ hem = min(hem, 1.0 - uFloor / uRes.y - 0.03);
191
+ float yy = yt / (1.0 + uScroll * 1.5);
192
+ float depth = clamp(yt / max(hem, 0.05), 0.0, 1.0);
193
+ float w = fbm(vec3(p.x * 1.3 + 11.0, yy * 0.9, t * 0.015)) - 0.5;
194
+ // Narrower pleats on a tall screen, so a phone still shows a run of them.
195
+ float f = 22.0 * mix(1.0, 1.7, uPortrait) / (1.0 + uScroll * 0.35);
196
+ float u = p.x * f + w * 4.5 + 0.8 * sin(p.x * 7.0 + 1.3) + 0.5 * sin(t * 0.07 + yy * 1.8) * depth;
197
+ return vec2(u, depth);
198
+ }
199
+ float hDrape(vec2 p, float a, float t) {
200
+ float hem;
201
+ vec2 ud = drapeAt(p, a, t, hem);
202
+ float amp = 0.02 * mix(0.3, 1.0, sqrt(ud.y)) * (1.0 + uScroll * 0.5);
203
+ // Rounded pleats meeting in soft creases, as hung cloth does.
204
+ float s = sin(ud.x * 0.5);
205
+ return amp * (2.0 * sqrt(s * s + 0.012) - 1.0);
206
+ }
207
+
208
+ float H(vec2 p, float a, float t) {
209
+ if (uVariant < 0.5) return hFold(p, a, t);
210
+ if (uVariant < 1.5) return hRipple(p, a, t);
211
+ return hDrape(p, a, t);
212
+ }
213
+
214
+ // The type's rects: the whole rect is fully quiet, the feather lies outside it.
215
+ float quiet(vec2 fc, vec4 r) {
216
+ if (r.z <= r.x) return 0.0;
217
+ vec2 lo = smoothstep(r.xy - uFeather, r.xy, fc);
218
+ vec2 hi = 1.0 - smoothstep(r.zw, r.zw + uFeather, fc);
219
+ return lo.x * lo.y * hi.x * hi.y;
220
+ }
221
+
222
+ void main() {
223
+ vec2 fc = gl_FragCoord.xy;
224
+ float a = uRes.x / uRes.y;
225
+ vec2 p = fc / uRes.y;
226
+ float t = uTime;
227
+ float e = 1.5 / uRes.y;
228
+ float h0 = H(p, a, t);
229
+ vec2 slope = vec2(H(p + vec2(e, 0.0), a, t) - h0, H(p + vec2(0.0, e), a, t) - h0) / e;
230
+ float q = max(quiet(fc, uQHead), quiet(fc, uQSmall));
231
+ // The folds rise in from a flat sheet, and lie flatter behind the type.
232
+ slope *= uIntro * mix(1.0, uHush, q);
233
+ vec3 n = normalize(vec3(-slope, 1.0));
234
+
235
+ // A low light from the upper left that wanders slowly, so the sheen travels over the crests.
236
+ float az = 2.3 + 0.32 * sin(t * 0.045) + uAz;
237
+ // Ripple on a dark ground takes a lower light, so its shallow crests still catch the accent.
238
+ float lz = mix(0.36, 0.28, uDarkGround * step(0.5, uVariant) * step(uVariant, 1.5));
239
+ vec3 L = normalize(vec3(cos(az) * 0.9, sin(az) * 0.9, lz));
240
+ vec3 Hv = normalize(L + vec3(0.0, 0.0, 1.0));
241
+ float diff = dot(n, L) / L.z;
242
+ float nh = max(dot(n, Hv), 0.0);
243
+ // The broad lobe is narrower on a light ground, so it reads as a sheen and never as a patch of colour.
244
+ float ex = mix(24.0, 12.0, uDarkGround);
245
+ float b0 = pow(Hv.z, ex);
246
+ float broad = max(pow(nh, ex) - b0, 0.0) / (1.0 - b0);
247
+ float narrow = max(pow(nh, 90.0) - pow(Hv.z, 90.0), 0.0);
248
+
249
+ vec3 deep = mix(mix(mix(uS, uI, 0.26), uA, 0.06), uG * 0.4, uDarkGround);
250
+ vec3 col = uS;
251
+ col = mix(col, deep, smoothstep(0.0, 1.0, clamp(1.0 - diff, 0.0, 1.0)) * 0.85);
252
+ col = mix(col, uP, clamp(diff - 1.0, 0.0, 1.0) * mix(0.7, 0.22, uDarkGround));
253
+ // The far side of the sheet from the light falls off a little.
254
+ float away = dot(p - vec2(0.5 * a, 0.5), normalize(L.xy));
255
+ col = mix(col, deep, clamp(-away * 0.35, 0.0, 0.18));
256
+ // On a light ground the accent is darker than the cloth: the broad sheen there leans to paper with only a trace of
257
+ // the accent, and the accent itself is kept for the narrow line on the crest.
258
+ col = mix(col, mix(mix(uP, uA, 0.25), uA, uDarkGround), broad * mix(0.45, 0.72, uDarkGround));
259
+ col = mix(col, mix(uA, uP, mix(0.68, 0.55, uDarkGround)), narrow * 0.7);
260
+
261
+ if (uVariant > 1.5) {
262
+ float hem;
263
+ vec2 ud = drapeAt(p, a, t, hem);
264
+ float yt = 1.0 - p.y;
265
+ float sh = sin(ud.x * 0.5);
266
+ float edge = hem + 0.008 * (2.0 * sqrt(sh * sh + 0.012) - 1.0) * uIntro;
267
+ float aa = 1.5 / uRes.y;
268
+ float inside = 1.0 - smoothstep(edge - aa, edge + aa, yt);
269
+ float shade = exp(-max(yt - edge, 0.0) / 0.035) * (1.0 - q * (1.0 - uHush));
270
+ // The hem is the edge here; only its shadow is let go before the foot.
271
+ shade *= smoothstep(uFloor, uFloor + 0.05 * uRes.y, fc.y);
272
+ vec3 ground = mix(uG, deep, shade * 0.32);
273
+ col = mix(ground, col, inside);
274
+ } else {
275
+ // The cloth settles into the page ground at the hero's foot (uFloor follows the drift), so the hand-over has no
276
+ // seam.
277
+ col = mix(uG, col, smoothstep(uFloor, uFloor + 0.05 * uRes.y, fc.y));
278
+ }
279
+
280
+ col += (h21(fc + fract(t * 7.0) * 61.0) - 0.5) / 255.0;
281
+ gl_FragColor = vec4(col, 1.0);
282
+ }
283
+ `;
284
+
285
+ const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
286
+ const luminance = ([r, g, b]: Rgb) => 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
287
+ const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
288
+
289
+ /** A colour token as the browser resolves it on the block root, as 0 to 1 sRGB channels. */
290
+ function readToken(host: Element, token: string, scratch: CanvasRenderingContext2D | null): Rgb {
291
+ const probe = document.createElement("span");
292
+ probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none";
293
+ probe.style.color = `var(${token})`;
294
+ host.append(probe);
295
+ const resolved = getComputedStyle(probe).color;
296
+ probe.remove();
297
+ if (!scratch) return [0.5, 0.5, 0.5];
298
+ scratch.clearRect(0, 0, 1, 1);
299
+ scratch.fillStyle = "#000";
300
+ scratch.fillStyle = resolved;
301
+ scratch.fillRect(0, 0, 1, 1);
302
+ const [r = 128, g = 128, b = 128] = scratch.getImageData(0, 0, 1, 1).data;
303
+ return [r / 255, g / 255, b / 255];
304
+ }
305
+
306
+ /** The glyphs' horizontal extent with each mask's own (untransformed) vertical extent, unioned, in client pixels. */
307
+ function textBox(masks: Element[]): DOMRect | null {
308
+ let x0 = Infinity;
309
+ let y0 = Infinity;
310
+ let x1 = -Infinity;
311
+ let y1 = -Infinity;
312
+ for (const m of masks) {
313
+ const range = document.createRange();
314
+ range.selectNodeContents(m);
315
+ const glyphs = range.getBoundingClientRect();
316
+ const own = m.getBoundingClientRect();
317
+ if (glyphs.width === 0) continue;
318
+ x0 = Math.min(x0, glyphs.left);
319
+ x1 = Math.max(x1, glyphs.right);
320
+ y0 = Math.min(y0, own.top);
321
+ y1 = Math.max(y1, own.bottom);
322
+ }
323
+ return x1 > x0 ? new DOMRect(x0, y0, x1 - x0, y1 - y0) : null;
324
+ }
325
+
326
+ type Field = {
327
+ start: () => void;
328
+ dispose: () => void;
329
+ };
330
+
331
+ /**
332
+ * The cloth's runner: a canvas made here (so a remount gets a fresh context), one program, one triangle. Calls
333
+ * `onFirst` once the first frame is on the canvas, or at once when there is no WebGL at all.
334
+ */
335
+ function silkField(
336
+ el: HTMLElement,
337
+ holder: HTMLElement,
338
+ variant: SilkVariant,
339
+ reduce: boolean,
340
+ onFirst: () => void,
341
+ ): Field {
342
+ const canvas = document.createElement("canvas");
343
+ canvas.setAttribute("aria-hidden", "true");
344
+ holder.append(canvas);
345
+ const scratch = document.createElement("canvas");
346
+ scratch.width = 1;
347
+ scratch.height = 1;
348
+ const ctx2d = scratch.getContext("2d", { willReadFrequently: true });
349
+
350
+ const attrs: WebGLContextAttributes = {
351
+ alpha: false,
352
+ antialias: false,
353
+ depth: false,
354
+ stencil: false,
355
+ premultipliedAlpha: false,
356
+ preserveDrawingBuffer: false,
357
+ powerPreference: "default",
358
+ };
359
+ const gl = (canvas.getContext("webgl2", attrs) ?? canvas.getContext("webgl", attrs)) as
360
+ | WebGL2RenderingContext
361
+ | WebGLRenderingContext
362
+ | null;
363
+
364
+ let firstDone = false;
365
+ const first = () => {
366
+ if (firstDone) return;
367
+ firstDone = true;
368
+ onFirst();
369
+ };
370
+
371
+ if (!gl) {
372
+ canvas.remove();
373
+ return { start: first, dispose: () => {} };
374
+ }
375
+
376
+ const phone = window.matchMedia("(max-width: 767px), (pointer: coarse)").matches;
377
+ // A 1x screen draws near full size, or the crest highlights smear; a dense screen draws at two thirds.
378
+ const scale = phone ? 0.5 : (window.devicePixelRatio || 1) >= 1.5 ? 0.66 : 0.9;
379
+ const octaves = phone ? 2 : 3;
380
+ const variantIndex = variant === "fold" ? 0 : variant === "ripple" ? 1 : 2;
381
+
382
+ let program: WebGLProgram | null = null;
383
+ let buffer: WebGLBuffer | null = null;
384
+ let shaders: WebGLShader[] = [];
385
+ const loc: Record<string, WebGLUniformLocation | null> = {};
386
+ const NAMES = [
387
+ "uRes", "uTime", "uIntro", "uVariant", "uPortrait", "uG", "uS", "uI", "uA", "uP", "uDarkGround", "uPtr", "uRip",
388
+ "uQHead", "uQSmall", "uFeather", "uHush", "uScroll", "uAz", "uOct", "uFloor",
389
+ ];
390
+
391
+ const build = (): boolean => {
392
+ const compile = (type: number, src: string) => {
393
+ const s = gl.createShader(type);
394
+ if (!s) return null;
395
+ gl.shaderSource(s, src);
396
+ gl.compileShader(s);
397
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS) && !gl.isContextLost()) {
398
+ console.warn("background-silk:", gl.getShaderInfoLog(s));
399
+ gl.deleteShader(s);
400
+ return null;
401
+ }
402
+ return s;
403
+ };
404
+ const vs = compile(gl.VERTEX_SHADER, VERTEX);
405
+ const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
406
+ if (!vs || !fs) return false;
407
+ shaders = [vs, fs];
408
+ program = gl.createProgram();
409
+ if (!program) return false;
410
+ gl.attachShader(program, vs);
411
+ gl.attachShader(program, fs);
412
+ gl.bindAttribLocation(program, 0, "aPos");
413
+ gl.linkProgram(program);
414
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return false;
415
+ gl.useProgram(program);
416
+ buffer = gl.createBuffer();
417
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
418
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
419
+ gl.enableVertexAttribArray(0);
420
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
421
+ for (const n of NAMES) loc[n] = gl.getUniformLocation(program, n);
422
+ gl.uniform1f(loc.uVariant, variantIndex);
423
+ gl.uniform1f(loc.uOct, octaves);
424
+ return true;
425
+ };
426
+
427
+ const release = () => {
428
+ if (program) gl.deleteProgram(program);
429
+ if (buffer) gl.deleteBuffer(buffer);
430
+ for (const s of shaders) gl.deleteShader(s);
431
+ program = null;
432
+ buffer = null;
433
+ shaders = [];
434
+ };
435
+
436
+ // State the loop owns.
437
+ let time = reduce ? STILL_T : 0;
438
+ let introAt = 0;
439
+ let last = 0;
440
+ let raf = 0;
441
+ let onScreen = true;
442
+ let lost = false;
443
+ let disposed = false;
444
+ let drawn = false;
445
+ let hush = HUSH_START;
446
+ let hushTarget = HUSH_START;
447
+ let search = -1; // index into HUSHES under test, -1 when settled
448
+ const probes = reduce ? [0] : PROBES;
449
+ const ptr = { x: 0, y: 0, z: 0, tx: 0, ty: 0, tz: 0 };
450
+ let tap = false;
451
+ let az = 0;
452
+ let scroll = 0;
453
+ const rings = new Float32Array(RINGS * 4);
454
+ let ringNext = 0;
455
+ let lastRing = { x: 0, y: 0, at: -10 };
456
+
457
+ let colours = { ink: 0, muted: 0 };
458
+ let head: Box | null = null;
459
+ let small: Box | null = null;
460
+
461
+ const portrait = () => (canvas.height >= canvas.width ? 1 : 0);
462
+
463
+ const readColours = () => {
464
+ const g = readToken(el, "--surface-0", ctx2d);
465
+ let s = readToken(el, "--surface-1", ctx2d);
466
+ const i = readToken(el, "--ink", ctx2d);
467
+ const m = readToken(el, "--ink-muted", ctx2d);
468
+ const a = readToken(el, "--accent", ctx2d);
469
+ const lg = luminance(g);
470
+ const li = luminance(i);
471
+ // A palette whose surface is the ground itself still gets a cloth one step off it.
472
+ if (Math.abs(luminance(s) - lg) < 0.006) s = g.map((c, k) => c + (i[k] - c) * 0.06) as Rgb;
473
+ const paper = li > lg ? i : g;
474
+ colours = { ink: li, muted: luminance(m) };
475
+ if (!program) return;
476
+ gl.uniform3fv(loc.uG, g);
477
+ gl.uniform3fv(loc.uS, s);
478
+ gl.uniform3fv(loc.uI, i);
479
+ gl.uniform3fv(loc.uA, a);
480
+ gl.uniform3fv(loc.uP, paper);
481
+ gl.uniform1f(loc.uDarkGround, lg < 0.18 ? 1 : 0);
482
+ };
483
+
484
+ // The type's boxes in canvas pixels (y up), read while the hero is at rest.
485
+ const readBoxes = () => {
486
+ const c = canvas.getBoundingClientRect();
487
+ if (c.width === 0) return;
488
+ const sx = canvas.width / c.width;
489
+ const sy = canvas.height / c.height;
490
+ const toBox = (r: DOMRect | null): Box | null =>
491
+ r
492
+ ? { x0: (r.left - c.left) * sx, y0: (c.bottom - r.bottom) * sy, x1: (r.right - c.left) * sx, y1: (c.bottom - r.top) * sy }
493
+ : null;
494
+ head = toBox(textBox(Array.from(el.querySelectorAll("[data-head] .struq-line-mask"))));
495
+ small = toBox(textBox(Array.from(el.querySelectorAll("[data-small] .struq-line-mask"))));
496
+ if (!program) return;
497
+ const set = (l: WebGLUniformLocation | null, b: Box | null) =>
498
+ gl.uniform4f(l, b?.x0 ?? 0, b?.y0 ?? 0, b?.x1 ?? 0, b?.y1 ?? 0);
499
+ set(loc.uQHead, head);
500
+ set(loc.uQSmall, small);
501
+ // A wide feather, so the calm zone has no edge of its own.
502
+ gl.uniform1f(loc.uFeather, 0.25 * canvas.height);
503
+ };
504
+
505
+ const resize = () => {
506
+ const r = holder.getBoundingClientRect();
507
+ const dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP) * scale;
508
+ const w = Math.max(1, Math.round(r.width * dpr));
509
+ const h = Math.max(1, Math.round(r.height * dpr));
510
+ if (canvas.width !== w || canvas.height !== h) {
511
+ canvas.width = w;
512
+ canvas.height = h;
513
+ }
514
+ if (!program) return;
515
+ gl.viewport(0, 0, w, h);
516
+ gl.uniform2f(loc.uRes, w, h);
517
+ gl.uniform1f(loc.uPortrait, portrait());
518
+ if (scroll < 0.01) readBoxes();
519
+ };
520
+
521
+ /**
522
+ * The worst contrast under the type, as a share of what it needs (1 is exactly enough): the display type against
523
+ * the ink at 3:1, the small text against the muted ink at 4.5:1. Read from the frame just drawn.
524
+ */
525
+ const contrastShare = (): number => {
526
+ let worst = Infinity;
527
+ const read = (b: Box | null, ink: number, need: number) => {
528
+ if (!b) return;
529
+ const x0 = Math.max(0, Math.floor(b.x0));
530
+ const y0 = Math.max(0, Math.floor(b.y0));
531
+ const w = Math.min(canvas.width, Math.ceil(b.x1)) - x0;
532
+ const h = Math.min(canvas.height, Math.ceil(b.y1)) - y0;
533
+ if (w <= 0 || h <= 0) return;
534
+ const px = new Uint8Array(w * h * 4);
535
+ gl.readPixels(x0, y0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
536
+ for (let k = 0; k < px.length; k += 12) {
537
+ const l = luminance([px[k] / 255, px[k + 1] / 255, px[k + 2] / 255]);
538
+ worst = Math.min(worst, contrast(l, ink) / need);
539
+ }
540
+ };
541
+ read(head, colours.ink, 3);
542
+ read(small, colours.muted, 4.5);
543
+ return worst;
544
+ };
545
+
546
+ const intro = () => {
547
+ if (reduce) return 1;
548
+ const x = Math.min(1, Math.max(0, (time - introAt) / INTRO));
549
+ return 1 - Math.pow(1 - x, 3);
550
+ };
551
+
552
+ /**
553
+ * One step of the hush search: the cloth at full relief and the hush under test, drawn at each probe moment and
554
+ * read back. Drawn before the real frame in the same task, so the probe is never composited. The first hush that
555
+ * holds with a margin for the sheen becomes the target, which the loop eases toward.
556
+ */
557
+ const probeStep = () => {
558
+ if (search < 0 || scroll >= 0.01) return;
559
+ if (!head && !small) {
560
+ search = -1;
561
+ return;
562
+ }
563
+ const h = HUSHES[search];
564
+ gl.uniform1f(loc.uIntro, 1);
565
+ gl.uniform1f(loc.uHush, h);
566
+ gl.uniform1f(loc.uScroll, 0);
567
+ let worst = Infinity;
568
+ for (const ahead of probes) {
569
+ gl.uniform1f(loc.uTime, time + ahead);
570
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
571
+ worst = Math.min(worst, contrastShare());
572
+ }
573
+ if (worst >= 1.15 || search === HUSHES.length - 1) {
574
+ hushTarget = h;
575
+ search = -1;
576
+ } else {
577
+ search += 1;
578
+ }
579
+ };
580
+
581
+ const draw = () => {
582
+ if (!program || lost) return;
583
+ probeStep();
584
+ gl.uniform1f(loc.uTime, time);
585
+ gl.uniform1f(loc.uIntro, intro());
586
+ gl.uniform3f(loc.uPtr, ptr.x, ptr.y, ptr.z);
587
+ gl.uniform4fv(loc.uRip, rings);
588
+ gl.uniform1f(loc.uHush, hush);
589
+ gl.uniform1f(loc.uScroll, scroll);
590
+ gl.uniform1f(loc.uAz, az);
591
+ // The hero's foot in canvas pixels: the canvas drifts down on scroll, so its own bottom edge is clipped below it.
592
+ const c = canvas.getBoundingClientRect();
593
+ const foot = el.getBoundingClientRect().bottom;
594
+ gl.uniform1f(loc.uFloor, Math.max(0, c.bottom - foot) * (canvas.height / Math.max(1, c.height)));
595
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
596
+ if (!drawn) {
597
+ drawn = true;
598
+ // The first frame is on the canvas; show it on the next frame, once it has been composited.
599
+ requestAnimationFrame(() => {
600
+ if (disposed) return;
601
+ el.dataset.live = "";
602
+ first();
603
+ });
604
+ }
605
+ };
606
+
607
+ const remeasure = () => {
608
+ search = 0;
609
+ };
610
+
611
+ const ease = (dt: number, tau: number) => 1 - Math.exp(-dt / tau);
612
+
613
+ const frame = (now: number) => {
614
+ raf = 0;
615
+ if (disposed || lost) return;
616
+ const dt = last ? Math.min(0.1, (now - last) / 1000) : 1 / 60;
617
+ last = now;
618
+ time += dt;
619
+
620
+ // The pointer eases in about 0.17s.
621
+ const k = ease(dt, 0.055);
622
+ ptr.x += (ptr.tx - ptr.x) * k;
623
+ ptr.y += (ptr.ty - ptr.y) * k;
624
+ ptr.z += (ptr.tz - ptr.z) * k;
625
+ if (variant === "fold") {
626
+ const target = ptr.tz > 0 ? (ptr.tx / canvas.width - 0.5) * 0.5 : 0;
627
+ az += (target - az) * k;
628
+ }
629
+ if (variant === "ripple") {
630
+ for (let r = 0; r < RINGS; r += 1) {
631
+ if (rings[r * 4 + 3] <= 0) continue;
632
+ rings[r * 4 + 2] += dt;
633
+ if (rings[r * 4 + 2] > 8) rings[r * 4 + 3] = 0;
634
+ }
635
+ const moved = Math.hypot(ptr.x - lastRing.x, ptr.y - lastRing.y) / canvas.height;
636
+ if (tap || (ptr.z > 0.5 && moved > 0.14 && time - lastRing.at > 0.45)) {
637
+ // A touch starts its ring where the finger lands; a mouse leaves one behind as it travels.
638
+ const x = tap ? ptr.tx : ptr.x;
639
+ const y = tap ? ptr.ty : ptr.y;
640
+ const o = ringNext * 4;
641
+ rings[o] = x;
642
+ rings[o + 1] = y;
643
+ rings[o + 2] = 0;
644
+ rings[o + 3] = 1;
645
+ ringNext = (ringNext + 1) % RINGS;
646
+ lastRing = { x, y, at: time };
647
+ }
648
+ }
649
+ tap = false;
650
+ // Paced close to the type's own scrubbed exit, so on drape the hem never runs ahead of the leaving copy.
651
+ const r = el.getBoundingClientRect();
652
+ const target = r.height > 0 ? Math.min(1, Math.max(0, -r.top / r.height)) : 0;
653
+ scroll += (target - scroll) * ease(dt, 0.2);
654
+ if (Math.abs(target - scroll) < 0.0005) scroll = target;
655
+
656
+ hush += (hushTarget - hush) * ease(dt, 0.6);
657
+ draw();
658
+ schedule();
659
+ };
660
+
661
+ const running = () => !reduce && !disposed && !lost && onScreen && !document.hidden;
662
+ const schedule = () => {
663
+ if (!raf && running()) raf = requestAnimationFrame(frame);
664
+ };
665
+ const kick = () => {
666
+ if (reduce) {
667
+ draw();
668
+ return;
669
+ }
670
+ last = 0;
671
+ schedule();
672
+ };
673
+
674
+ // Reduced motion and no loop: the hush searched out at once, then the still frame drawn with it.
675
+ const still = () => {
676
+ remeasure();
677
+ for (let i = 0; i <= HUSHES.length && search !== -1; i += 1) probeStep();
678
+ hush = hushTarget;
679
+ draw();
680
+ };
681
+
682
+ const onPointer = (e: PointerEvent) => {
683
+ const c = canvas.getBoundingClientRect();
684
+ const inside = e.clientX >= c.left && e.clientX <= c.right && e.clientY >= c.top && e.clientY <= c.bottom;
685
+ const sx = canvas.width / Math.max(1, c.width);
686
+ const sy = canvas.height / Math.max(1, c.height);
687
+ ptr.tx = (e.clientX - c.left) * sx;
688
+ ptr.ty = (c.bottom - e.clientY) * sy;
689
+ if (ptr.z < 0.01) {
690
+ ptr.x = ptr.tx;
691
+ ptr.y = ptr.ty;
692
+ lastRing = { x: ptr.x, y: ptr.y, at: lastRing.at };
693
+ }
694
+ ptr.tz = inside ? 1 : 0;
695
+ };
696
+ const onLeave = () => {
697
+ ptr.tz = 0;
698
+ };
699
+ const onDown = (e: PointerEvent) => {
700
+ onPointer(e);
701
+ if (ptr.tz > 0 && e.pointerType !== "mouse") tap = true;
702
+ };
703
+ // A finger that lifts leaves no pointer behind on the cloth.
704
+ const onUp = (e: PointerEvent) => {
705
+ if (e.pointerType !== "mouse") ptr.tz = 0;
706
+ };
707
+
708
+ const resizeObserver = new ResizeObserver(() => {
709
+ resize();
710
+ remeasure();
711
+ if (reduce) still();
712
+ });
713
+ const io = new IntersectionObserver(
714
+ (entries) => {
715
+ onScreen = entries.some((en) => en.isIntersecting);
716
+ kick();
717
+ },
718
+ { rootMargin: "64px" },
719
+ );
720
+ const onVisibility = () => kick();
721
+
722
+ // Palette and theme: the lab swaps the tokens on [data-blocks]; a site swaps a class or data-theme on <html>.
723
+ let colourFrame = 0;
724
+ const onTokens = () => {
725
+ cancelAnimationFrame(colourFrame);
726
+ colourFrame = requestAnimationFrame(() => {
727
+ readColours();
728
+ remeasure();
729
+ if (reduce) still();
730
+ else kick();
731
+ });
732
+ };
733
+ const tokens = new MutationObserver(onTokens);
734
+ const scope = el.closest("[data-blocks]");
735
+ if (scope) tokens.observe(scope, { attributes: true, attributeFilter: ["style", "class", "data-theme"] });
736
+ tokens.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });
737
+
738
+ const onLost = (e: Event) => {
739
+ e.preventDefault();
740
+ lost = true;
741
+ cancelAnimationFrame(raf);
742
+ raf = 0;
743
+ };
744
+ const onRestored = () => {
745
+ lost = false;
746
+ program = null;
747
+ if (!build()) return;
748
+ readColours();
749
+ resize();
750
+ if (reduce) still();
751
+ else kick();
752
+ };
753
+
754
+ const onFonts = () => {
755
+ if (disposed) return;
756
+ if (scroll < 0.01) readBoxes();
757
+ remeasure();
758
+ if (reduce) still();
759
+ };
760
+
761
+ return {
762
+ start() {
763
+ if (!build()) {
764
+ release();
765
+ canvas.remove();
766
+ first();
767
+ return;
768
+ }
769
+ readColours();
770
+ resize();
771
+ canvas.addEventListener("webglcontextlost", onLost);
772
+ canvas.addEventListener("webglcontextrestored", onRestored);
773
+ window.addEventListener("pointermove", onPointer, { passive: true });
774
+ window.addEventListener("pointerdown", onDown, { passive: true });
775
+ window.addEventListener("pointerup", onUp, { passive: true });
776
+ window.addEventListener("pointercancel", onUp, { passive: true });
777
+ document.documentElement.addEventListener("pointerleave", onLeave);
778
+ document.addEventListener("visibilitychange", onVisibility);
779
+ resizeObserver.observe(holder);
780
+ io.observe(el);
781
+ document.fonts?.ready.then(onFonts);
782
+ introAt = time;
783
+ if (reduce) still();
784
+ else kick();
785
+ },
786
+ dispose() {
787
+ disposed = true;
788
+ cancelAnimationFrame(raf);
789
+ cancelAnimationFrame(colourFrame);
790
+ resizeObserver.disconnect();
791
+ io.disconnect();
792
+ tokens.disconnect();
793
+ canvas.removeEventListener("webglcontextlost", onLost);
794
+ canvas.removeEventListener("webglcontextrestored", onRestored);
795
+ window.removeEventListener("pointermove", onPointer);
796
+ window.removeEventListener("pointerdown", onDown);
797
+ window.removeEventListener("pointerup", onUp);
798
+ window.removeEventListener("pointercancel", onUp);
799
+ document.documentElement.removeEventListener("pointerleave", onLeave);
800
+ document.removeEventListener("visibilitychange", onVisibility);
801
+ release();
802
+ (gl.getExtension("WEBGL_lose_context") as { loseContext: () => void } | null)?.loseContext();
803
+ canvas.remove();
804
+ delete el.dataset.live;
805
+ },
806
+ };
807
+ }
808
+
809
+ export function BackgroundSilk({ variant = "fold", lines, lead, link, className = "" }: Props) {
810
+ const root = useRef<HTMLElement>(null);
811
+
812
+ useGSAP(
813
+ (_, contextSafe) => {
814
+ const el = root.current;
815
+ if (!el || !contextSafe) return;
816
+ const q = gsap.utils.selector(el);
817
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
818
+ const rise = q("[data-rise]");
819
+ const holder = q("[data-field]")[0] as HTMLElement;
820
+ const copy = q("[data-copy]")[0] as HTMLElement;
821
+
822
+ // A late script: once the CSS fallback (3s) has shown the type it stays; decided once per element, from that
823
+ // element's own fallback clock, so strict mode's second run reads the same answer.
824
+ const clock = rise[0]
825
+ ?.getAnimations()
826
+ .find((an) => (an as CSSAnimation).animationName === "struq-silk-rise");
827
+ el.dataset.shown ??= String(
828
+ reduce || (clock ? Number(clock.currentTime ?? 0) >= 2900 : getComputedStyle(rise[0]).transform === "none"),
829
+ );
830
+ const shown = el.dataset.shown === "true";
831
+ gsap.set(rise, { y: 0, yPercent: shown ? 0 : 115 });
832
+ el.dataset.ready = "";
833
+
834
+ let played = false;
835
+ const play = contextSafe(() => {
836
+ if (played) return;
837
+ played = true;
838
+ if (!shown) gsap.to(rise, { yPercent: 0, duration: 1.2, ease: "expo.out", stagger: 0.08, delay: 0.25 });
839
+ });
840
+ // The type never waits long on a slow shader compile.
841
+ const failsafe = window.setTimeout(play, 1400);
842
+ const field = silkField(el, holder, variant, reduce, play);
843
+ field.start();
844
+
845
+ // The hand-over: the type leaves first; on fold and ripple the cloth drifts slower than the page, on drape it
846
+ // stays put and the shader draws it down over the type.
847
+ if (!reduce) {
848
+ const tl = gsap.timeline({ scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.4 } });
849
+ if (variant === "drape") {
850
+ // The copy is gone by a quarter of the way; the shader holds the hem until 0.3, so the cloth never passes
851
+ // over half faded type.
852
+ tl.to(copy, { yPercent: -30, opacity: 0, ease: "none", duration: 0.24 }, 0).to({}, { duration: 0.76 }, 0.24);
853
+ } else {
854
+ tl.to(holder, { yPercent: 18, ease: "none", duration: 1 }, 0).to(copy, { yPercent: -45, ease: "none", duration: 0.6 }, 0);
855
+ }
856
+ }
857
+
858
+ return () => {
859
+ window.clearTimeout(failsafe);
860
+ field.dispose();
861
+ };
862
+ },
863
+ { scope: root, dependencies: [variant], revertOnUpdate: true },
864
+ );
865
+
866
+ const mask = (child: React.ReactNode, key?: number) => (
867
+ <span key={key} className="struq-line-mask block overflow-hidden pb-[0.06em]">
868
+ <span data-rise="" className="block">
869
+ {child}
870
+ </span>
871
+ </span>
872
+ );
873
+
874
+ // Where the type stands: fold on the calm left at the middle (at the foot on a tall screen, under the fold),
875
+ // ripple and drape at the foot on the left.
876
+ const place = variant === "fold" ? "justify-center portrait:justify-end" : "justify-end";
877
+
878
+ return (
879
+ <section
880
+ ref={root}
881
+ data-bg-silk=""
882
+ data-variant={variant}
883
+ suppressHydrationWarning
884
+ className={`relative h-svh min-h-[34rem] overflow-hidden bg-surface-0 text-ink ${className}`}
885
+ >
886
+ <div data-field="" aria-hidden="true" className="absolute inset-0" />
887
+ <div className={`relative flex h-full flex-col px-6 pb-[9vh] pt-[12vh] md:px-[5vw] md:pb-[11vh] ${place}`}>
888
+ <div data-copy="" className="max-w-[min(100%,40rem)]">
889
+ <h1 data-head="" className="font-display text-[clamp(2.75rem,12.5vw,4.5rem)] leading-[0.98] tracking-[-0.03em] md:text-[clamp(3.75rem,6.6vw,7.5rem)]">
890
+ {lines.map((line, i) => mask(line, i))}
891
+ </h1>
892
+ <div data-small="" className="mt-7 md:mt-9">
893
+ {mask(<span className="block max-w-[34ch] text-[1.0625rem] leading-relaxed text-ink-muted md:text-lg">{lead}</span>)}
894
+ <div className="mt-4">
895
+ {mask(
896
+ <a href={link.href} className="text-small inline-block underline decoration-1 underline-offset-4">
897
+ {link.label}
898
+ </a>,
899
+ )}
900
+ </div>
901
+ </div>
902
+ </div>
903
+ </div>
904
+ </section>
905
+ );
906
+ }