@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.
- package/README.md +22 -36
- package/package.json +4 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /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
|
+
}
|