@struq/skill 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +15 -5
  2. package/package.json +4 -4
  3. package/skills/struq/SKILL.md +304 -200
  4. package/skills/struq/cinematic/showpieces/README.md +18 -0
  5. package/skills/struq/cinematic/showpieces/toonstuk-fill-rows.tsx +74 -0
  6. package/skills/struq/cinematic/showpieces/toonstuk-hero-opener.tsx +128 -0
  7. package/skills/struq/cinematic/showpieces/toonstuk-image-to-fullbleed.tsx +78 -0
  8. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +81 -0
  9. package/skills/struq/cinematic/showpieces/toonstuk-panel-wipe.tsx +89 -0
  10. package/skills/struq/cinematic/showpieces/toonstuk-pixel-resolve.tsx +107 -0
  11. package/skills/struq/cinematic/showpieces/toonstuk-project-stack.tsx +86 -0
  12. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +78 -0
  13. package/skills/struq/cinematic/showpieces/toonstuk-wordmark-finale.tsx +105 -0
  14. package/skills/struq/recipes/app/fonts.ts +22 -0
  15. package/skills/struq/recipes/app/globals.css +185 -0
  16. package/skills/struq/recipes/app/layout.tsx +28 -0
  17. package/skills/struq/recipes/components/struq/Cursor.tsx +117 -0
  18. package/skills/struq/recipes/components/struq/HeroCanvas.tsx +278 -0
  19. package/skills/struq/recipes/components/struq/HorizontalGallery.tsx +106 -0
  20. package/skills/struq/recipes/components/struq/MagneticButton.tsx +92 -0
  21. package/skills/struq/recipes/components/struq/MediaFrame.tsx +96 -0
  22. package/skills/struq/recipes/components/struq/PinnedZoom.tsx +79 -0
  23. package/skills/struq/recipes/components/struq/Reveal.tsx +64 -0
  24. package/skills/struq/recipes/components/struq/SmoothScroll.tsx +57 -0
  25. package/skills/struq/recipes/components/struq/SplitHeadline.tsx +68 -0
  26. package/skills/struq/recipes/components/struq/scenes/contours.ts +161 -0
  27. package/skills/struq/recipes/components/struq/scenes/dots.ts +110 -0
  28. package/skills/struq/recipes/components/struq/scenes/ripple.ts +99 -0
  29. package/skills/struq/recipes/components/struq/scenes/types.ts +114 -0
  30. package/skills/struq/recipes/lib/gsap.ts +21 -0
  31. package/skills/struq/references/anti-patterns.md +71 -6
  32. package/skills/struq/references/baseline-prompt.md +18 -14
  33. package/skills/struq/references/brief-router.md +7 -8
  34. package/skills/struq/references/composition.md +0 -1
  35. package/skills/struq/references/depth-parallax.md +1 -1
  36. package/skills/struq/references/direction.md +5 -5
  37. package/skills/struq/references/hero-scenes.md +107 -0
  38. package/skills/struq/references/iconography.md +1 -1
  39. package/skills/struq/references/imagery.md +10 -29
  40. package/skills/struq/references/micro-interactions.md +1 -1
  41. package/skills/struq/references/mobile-composition.md +1 -1
  42. package/skills/struq/references/niche-packets.md +338 -0
  43. package/skills/struq/references/page-architecture.md +2 -2
  44. package/skills/struq/references/pinning-scrub.md +3 -4
  45. package/skills/struq/references/scroll-reveals.md +1 -2
  46. package/skills/struq/references/sections.md +141 -0
  47. package/skills/struq/references/seo.md +2 -2
  48. package/skills/struq/references/showcase.md +2 -2
  49. package/skills/struq/references/svg-motion.md +390 -390
  50. package/skills/struq/references/typography.md +39 -39
  51. package/skills/struq/references/vibe-presets.md +5 -5
  52. package/skills/struq/references/webgl-scenes.md +593 -593
  53. package/skills/struq/scripts/canon.mjs +309 -0
  54. package/skills/struq/scripts/check.mjs +547 -0
  55. package/skills/struq/scripts/font-names.mjs +40 -0
  56. package/skills/struq/scripts/images.mjs +360 -0
  57. package/skills/struq/scripts/log.mjs +92 -0
  58. package/skills/struq/scripts/packs.mjs +156 -0
  59. package/skills/struq/scripts/recipes.mjs +135 -0
  60. /package/{bin → cli}/struq.mjs +0 -0
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ // components/struq/Reveal.tsx (Struq recipe)
3
+ // Staggered rise for a group of text blocks: every direct child rises 48px from a fade as the
4
+ // group scrolls into view. Opacity AND transform, never opacity alone, expo.out, fired once.
5
+ //
6
+ // <Reveal className="grid gap-6">
7
+ // <h2 className="text-h2">...</h2>
8
+ // <p className="text-lead">...</p>
9
+ // </Reveal>
10
+ //
11
+ // Before hydration the children are hidden only when JavaScript runs (the `js` class from the
12
+ // layout), and a CSS failsafe shows them after 3s if hydration never comes (globals.css).
13
+ import { useRef } from "react";
14
+ import { gsap, useGSAP } from "@/lib/gsap";
15
+
16
+ type Props = {
17
+ children: React.ReactNode;
18
+ className?: string;
19
+ as?: "div" | "section" | "header" | "footer" | "ul" | "ol";
20
+ /** Distance each child rises, in px. 24 to 80. */
21
+ y?: number;
22
+ stagger?: number;
23
+ delay?: number;
24
+ /** "scroll" waits for the viewport; "load" plays on mount (for the hero). */
25
+ on?: "scroll" | "load";
26
+ };
27
+
28
+ export function Reveal({ children, className = "", as = "div", y = 48, stagger = 0.08, delay = 0, on = "scroll" }: Props) {
29
+ const ref = useRef<HTMLDivElement>(null);
30
+
31
+ useGSAP(
32
+ () => {
33
+ const el = ref.current;
34
+ if (!el) return;
35
+ const items = Array.from(el.children);
36
+ const mm = gsap.matchMedia();
37
+ mm.add(
38
+ { motion: "(prefers-reduced-motion: no-preference)", reduce: "(prefers-reduced-motion: reduce)" },
39
+ (ctx) => {
40
+ const reduce = ctx.conditions?.reduce;
41
+ gsap.from(items, {
42
+ y: reduce ? 0 : y,
43
+ autoAlpha: 0,
44
+ duration: reduce ? 0.5 : 0.9,
45
+ ease: "expo.out",
46
+ stagger,
47
+ delay,
48
+ scrollTrigger: on === "scroll" ? { trigger: el, start: "top 85%", once: true } : undefined,
49
+ });
50
+ },
51
+ );
52
+ el.dataset.ready = "1";
53
+ return () => mm.revert();
54
+ },
55
+ { scope: ref },
56
+ );
57
+
58
+ const Tag = as as "div";
59
+ return (
60
+ <Tag ref={ref} data-reveal="" className={className}>
61
+ {children}
62
+ </Tag>
63
+ );
64
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+ // components/struq/SmoothScroll.tsx (Struq recipe)
3
+ // Lenis smooth scroll on the GSAP ticker: one requestAnimationFrame for the whole page.
4
+ // Mount it once, in app/layout.tsx, around {children}.
5
+ //
6
+ // Why it is built this way:
7
+ // - autoRaf false plus gsap.ticker: Lenis and ScrollTrigger sample scroll on the same frame,
8
+ // so scrubbed timelines never micro-stutter.
9
+ // - lagSmoothing(0): GSAP never "catches up" after a long frame, which would jump scrubs.
10
+ // - Lenis is created inside the effect, so there is no ref race that can leave the wheel dead.
11
+ // - Reduced motion: no Lenis, native scroll. ScrollTrigger keeps working on native scroll.
12
+ import Lenis from "lenis";
13
+ import "lenis/dist/lenis.css";
14
+ import { useEffect } from "react";
15
+ import { gsap, ScrollTrigger } from "@/lib/gsap";
16
+
17
+ let current: Lenis | null = null;
18
+
19
+ /**
20
+ * The page's Lenis instance, or null under reduced motion and before mount.
21
+ * An overlay menu calls getLenis()?.stop() when it opens and getLenis()?.start() on EVERY close path.
22
+ */
23
+ export function getLenis() {
24
+ return current;
25
+ }
26
+
27
+ export function SmoothScroll({ children }: { children: React.ReactNode }) {
28
+ useEffect(() => {
29
+ ScrollTrigger.config({ ignoreMobileResize: true });
30
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
31
+
32
+ // Fonts change line breaks and section heights: re-measure every trigger once they land.
33
+ const refresh = () => ScrollTrigger.refresh();
34
+ document.fonts?.ready.then(refresh);
35
+ const late = window.setTimeout(refresh, 600);
36
+
37
+ if (reduce) {
38
+ return () => window.clearTimeout(late);
39
+ }
40
+
41
+ const lenis = new Lenis({ lerp: 0.1, autoRaf: false, anchors: true });
42
+ const raf = (time: number) => lenis.raf(time * 1000); // GSAP gives seconds, Lenis wants ms
43
+ lenis.on("scroll", ScrollTrigger.update);
44
+ gsap.ticker.add(raf);
45
+ gsap.ticker.lagSmoothing(0);
46
+ current = lenis;
47
+
48
+ return () => {
49
+ window.clearTimeout(late);
50
+ gsap.ticker.remove(raf);
51
+ lenis.destroy();
52
+ current = null;
53
+ };
54
+ }, []);
55
+
56
+ return <>{children}</>;
57
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ // components/struq/SplitHeadline.tsx (Struq recipe)
3
+ // The hero headline: SplitText cuts it into lines, each line rises out of its own mask.
4
+ // autoSplit re-cuts it when the width or the webfont changes, so mobile line breaks stay right.
5
+ //
6
+ // <SplitHeadline as="h1" className="font-display text-display max-w-[14ch]">
7
+ // Vis en groente, van het seizoen.
8
+ // </SplitHeadline>
9
+ //
10
+ // Pass plain text (or text with <em>): SplitText needs text nodes, not components.
11
+ import { useRef } from "react";
12
+ import { gsap, SplitText, useGSAP } from "@/lib/gsap";
13
+
14
+ type Props = {
15
+ children: React.ReactNode;
16
+ as?: "h1" | "h2" | "h3" | "p";
17
+ className?: string;
18
+ /** "load" for the hero, "scroll" for a statement further down. */
19
+ on?: "load" | "scroll";
20
+ delay?: number;
21
+ /** Mark it as copy the hero canvas hushes behind. On by default for the h1. */
22
+ quiet?: boolean;
23
+ };
24
+
25
+ export function SplitHeadline({ children, as = "h1", className = "", on = "load", delay = 0.15, quiet = as === "h1" }: Props) {
26
+ const ref = useRef<HTMLHeadingElement>(null);
27
+
28
+ useGSAP(
29
+ () => {
30
+ const el = ref.current;
31
+ if (!el) return;
32
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
33
+
34
+ if (reduce) {
35
+ el.dataset.ready = "1";
36
+ gsap.from(el, { autoAlpha: 0, duration: 0.6 });
37
+ return;
38
+ }
39
+
40
+ const split = SplitText.create(el, {
41
+ type: "lines",
42
+ mask: "lines",
43
+ linesClass: "struq-line",
44
+ autoSplit: true,
45
+ onSplit(self) {
46
+ return gsap.from(self.lines, {
47
+ yPercent: 115,
48
+ duration: 1.25,
49
+ ease: "expo.out",
50
+ stagger: 0.09,
51
+ delay,
52
+ scrollTrigger: on === "scroll" ? { trigger: el, start: "top 80%", once: true } : undefined,
53
+ });
54
+ },
55
+ });
56
+ el.dataset.ready = "1";
57
+ return () => split.revert();
58
+ },
59
+ { scope: ref },
60
+ );
61
+
62
+ const Tag = as as "h1";
63
+ return (
64
+ <Tag ref={ref} data-split="" data-quiet={quiet ? "" : undefined} className={className}>
65
+ {children}
66
+ </Tag>
67
+ );
68
+ }
@@ -0,0 +1,161 @@
1
+ // components/struq/scenes/contours.ts (Struq recipe)
2
+ // A survey terrain seen at a low angle: fine contour lines in the ink, every fifth line catching the
3
+ // accent on the right of the frame, compressing toward a horizon above the top edge. The field drifts
4
+ // toward the viewer on a slow clock, the pointer lifts it a little, and it hushes behind the copy.
5
+ // Composition: the vanishing point sits on the right third and the lines fade out to the left, so a
6
+ // headline on the left sits in calm space. Needs no photo.
7
+ // Suits: architecture, studios, consultancy, research, nature, anything calm and precise.
8
+ //
9
+ // Two passes, so noise never runs per screen pixel: a field pass writes the terrain height into a
10
+ // small target (one texel per 4 CSS pixels, packed to 16 bits), and the draw pass turns the
11
+ // upsampled height into antialiased lines with cheap per-pixel arithmetic.
12
+ import { NOISE_GLSL, QUIET_GLSL, quietUniforms, type CreateScene } from "./types";
13
+
14
+ const CELL = 4; // CSS pixels per field texel
15
+
16
+ export const createScene: CreateScene = async ({ THREE, renderer, palette, dark }) => {
17
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
18
+ const quad = new THREE.PlaneGeometry(2, 2);
19
+ const vertexShader = /* glsl */ `void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }`;
20
+
21
+ const field = new THREE.WebGLRenderTarget(2, 2, {
22
+ minFilter: THREE.NearestFilter,
23
+ magFilter: THREE.NearestFilter,
24
+ depthBuffer: false,
25
+ generateMipmaps: false,
26
+ });
27
+
28
+ const fieldUniforms = {
29
+ uFieldRes: { value: new THREE.Vector2(2, 2) },
30
+ uAspect: { value: 1 },
31
+ uTime: { value: 0 },
32
+ uPointer: { value: new THREE.Vector2() },
33
+ uPresence: { value: 0 },
34
+ uScroll: { value: 0 },
35
+ };
36
+ const fieldMaterial = new THREE.ShaderMaterial({
37
+ uniforms: fieldUniforms,
38
+ vertexShader,
39
+ fragmentShader: /* glsl */ `
40
+ uniform vec2 uFieldRes; uniform float uAspect; uniform float uTime;
41
+ uniform vec2 uPointer; uniform float uPresence; uniform float uScroll;
42
+ ${NOISE_GLSL}
43
+ void main() {
44
+ vec2 uv = gl_FragCoord.xy / uFieldRes;
45
+ float t = uTime * 0.022; // the ambient clock: a slow drift, Vapor pace
46
+ // Perspective: the horizon sits just above the top edge, the vanishing point on the right third.
47
+ float below = 1.18 - uv.y;
48
+ float Z = 0.8 / below;
49
+ float X = (uv.x - 0.66) * uAspect * Z;
50
+ vec2 p = vec2(X, Z - t * 1.1 + uScroll * 1.2) * 0.62;
51
+ vec2 warp = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(4.1, -t)));
52
+ float h = fbm(p * 0.9 + 1.25 * warp);
53
+ // The pointer: a soft rise where it rests, eased by presence. A modulation, never a spotlight.
54
+ vec2 d = (uv - (uPointer * 0.5 + 0.5)) * vec2(uAspect, 1.0);
55
+ h += exp(-dot(d, d) / 0.035) * 0.05 * uPresence;
56
+ h = clamp(h, 0.0, 0.999);
57
+ gl_FragColor = vec4(floor(h * 255.0) / 255.0, fract(h * 255.0), 0.0, 1.0);
58
+ }
59
+ `,
60
+ });
61
+ const fieldScene = new THREE.Scene();
62
+ fieldScene.add(new THREE.Mesh(quad, fieldMaterial));
63
+
64
+ const quietSet = quietUniforms(THREE, dark ? 0.08 : 0.12);
65
+ const drawUniforms = {
66
+ ...quietSet.uniforms,
67
+ uField: { value: field.texture },
68
+ uFieldRes: { value: new THREE.Vector2(2, 2) },
69
+ uCell: { value: CELL },
70
+ uResPx: { value: new THREE.Vector2(1, 1) },
71
+ uTime: { value: 0 },
72
+ uSurface: { value: new THREE.Color(palette.surface) },
73
+ uInk: { value: new THREE.Color(palette.ink) },
74
+ uAccent: { value: new THREE.Color(palette.accent) },
75
+ uInkWeight: { value: dark ? 0.85 : 0.56 },
76
+ // On a dark ground the lines need more weight and must not vanish on the copy side.
77
+ uLeftFloor: { value: dark ? 0.45 : 0.2 },
78
+ };
79
+ const drawMaterial = new THREE.ShaderMaterial({
80
+ uniforms: drawUniforms,
81
+ vertexShader,
82
+ fragmentShader: /* glsl */ `
83
+ uniform sampler2D uField; uniform vec2 uFieldRes; uniform float uCell; uniform vec2 uResPx;
84
+ uniform float uTime; uniform vec3 uSurface; uniform vec3 uInk; uniform vec3 uAccent; uniform float uInkWeight; uniform float uLeftFloor;
85
+ ${QUIET_GLSL}
86
+ float grain(vec2 p) { p = fract(p * vec2(443.897, 441.423)); p += dot(p, p.yx + 19.19); return fract((p.x + p.y) * p.x); }
87
+ float at(ivec2 i) {
88
+ ivec2 m = ivec2(uFieldRes) - 1;
89
+ vec4 v = texelFetch(uField, clamp(i, ivec2(0), m), 0);
90
+ return v.r + v.g / 255.0;
91
+ }
92
+ float height(vec2 fc) {
93
+ vec2 g = fc / uCell - 0.5;
94
+ ivec2 i = ivec2(floor(g));
95
+ vec2 f = fract(g);
96
+ return mix(mix(at(i), at(i + ivec2(1, 0)), f.x), mix(at(i + ivec2(0, 1)), at(i + ivec2(1, 1)), f.x), f.y);
97
+ }
98
+ void main() {
99
+ vec2 fc = gl_FragCoord.xy;
100
+ vec2 uv = fc / uResPx;
101
+ float f = height(fc) * 54.0;
102
+ float w = fwidth(f);
103
+ float dist = min(fract(f), 1.0 - fract(f));
104
+ float line = 1.0 - smoothstep(0.0, w * 1.3, dist);
105
+ // Where lines crowd closer than the screen can draw, they fade into a mist instead of moire.
106
+ float dense = smoothstep(0.22, 0.55, w);
107
+ float mist = dense * 0.05;
108
+ line *= 1.0 - dense;
109
+ float major = 1.0 - step(0.5, mod(floor(f + 0.5), 5.0));
110
+ // Composition and depth: weighted to the right, fading toward the far horizon.
111
+ float right = uLeftFloor + (1.0 - uLeftFloor) * smoothstep(0.05, 0.8, uv.x);
112
+ float near = 1.0 - 0.6 * smoothstep(0.3, 1.0, uv.y);
113
+ float inkV = hush((line * (0.55 + 0.45 * major) + mist) * uInkWeight * right * near, fc);
114
+ float accentV = hush(line * major * smoothstep(0.5, 0.95, uv.x) * near * 0.75, fc);
115
+ vec3 col = mix(uSurface, uInk, inkV);
116
+ col = mix(col, uAccent, accentV);
117
+ col += (grain(fc + fract(uTime) * 61.0) - 0.5) * 0.018;
118
+ gl_FragColor = vec4(col, 1.0);
119
+ #include <colorspace_fragment>
120
+ }
121
+ `,
122
+ });
123
+ const scene = new THREE.Scene();
124
+ scene.add(new THREE.Mesh(quad, drawMaterial));
125
+
126
+ return {
127
+ scene,
128
+ camera,
129
+ update(state) {
130
+ fieldUniforms.uTime.value = state.time;
131
+ fieldUniforms.uPointer.value.set(state.pointer.x, state.pointer.y);
132
+ fieldUniforms.uPresence.value = state.presence;
133
+ fieldUniforms.uScroll.value = state.scroll;
134
+ drawUniforms.uTime.value = state.time;
135
+ quietSet.update(state);
136
+ },
137
+ resize(width, height) {
138
+ const pr = renderer.getPixelRatio();
139
+ const fw = Math.max(2, Math.ceil(width / CELL));
140
+ const fh = Math.max(2, Math.ceil(height / CELL));
141
+ field.setSize(fw, fh);
142
+ fieldUniforms.uFieldRes.value.set(fw, fh);
143
+ fieldUniforms.uAspect.value = width / Math.max(1, height);
144
+ drawUniforms.uFieldRes.value.set(fw, fh);
145
+ drawUniforms.uCell.value = CELL * pr;
146
+ drawUniforms.uResPx.value.set(width * pr, height * pr);
147
+ },
148
+ render(r) {
149
+ r.setRenderTarget(field);
150
+ r.render(fieldScene, camera);
151
+ r.setRenderTarget(null);
152
+ r.render(scene, camera);
153
+ },
154
+ dispose() {
155
+ quad.dispose();
156
+ field.dispose();
157
+ fieldMaterial.dispose();
158
+ drawMaterial.dispose();
159
+ },
160
+ };
161
+ };
@@ -0,0 +1,110 @@
1
+ // components/struq/scenes/dots.ts (Struq recipe)
2
+ // A fine field of points seen at a low angle, rolling like a slow swell toward a horizon in the upper
3
+ // third. The swell rises toward the right of the frame, its crests catch the accent, the far rows thin
4
+ // out into the ground colour, and the field hushes behind the copy. The pointer lifts a soft hill only
5
+ // while it is there. Scrolling lowers the field away.
6
+ // Suits: mobility, logistics, technology, sport, energy, data: anything about movement or scale.
7
+ import { NOISE_GLSL, QUIET_GLSL, quietUniforms, type CreateScene } from "./types";
8
+
9
+ export const createScene: CreateScene = async ({ THREE, renderer, palette, dark }) => {
10
+ const scene = new THREE.Scene();
11
+ const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
12
+ camera.position.set(-1.5, 4.2, 13);
13
+ camera.lookAt(1.5, 0.4, -6);
14
+ renderer.setClearColor(new THREE.Color(palette.surface), 1);
15
+
16
+ const cols = 260;
17
+ const rows = 120;
18
+ const positions = new Float32Array(cols * rows * 3);
19
+ let i = 0;
20
+ for (let r = 0; r < rows; r++) {
21
+ for (let c = 0; c < cols; c++) {
22
+ positions[i++] = (c / (cols - 1) - 0.5) * 48;
23
+ positions[i++] = 0;
24
+ positions[i++] = (r / (rows - 1)) * -40 + 10;
25
+ }
26
+ }
27
+ const geometry = new THREE.BufferGeometry();
28
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
29
+
30
+ const quietSet = quietUniforms(THREE, dark ? 0.1 : 0.16);
31
+ const uniforms = {
32
+ ...quietSet.uniforms,
33
+ uTime: { value: 0 },
34
+ uPointer: { value: new THREE.Vector2(0, 0) },
35
+ uPresence: { value: 0 },
36
+ uScroll: { value: 0 },
37
+ uDpr: { value: renderer.getPixelRatio() },
38
+ uInk: { value: new THREE.Color(palette.ink) },
39
+ uAccent: { value: new THREE.Color(palette.accent) },
40
+ uInkWeight: { value: dark ? 1.0 : 0.8 },
41
+ // On a dark ground light points read smaller and fainter: bigger points and a visibility floor,
42
+ // so a photo-less opener on the inverse ground still carries the screen.
43
+ uSize: { value: dark ? 62 : 44 },
44
+ uFloor: { value: dark ? 0.38 : 0 },
45
+ };
46
+
47
+ const material = new THREE.ShaderMaterial({
48
+ uniforms,
49
+ transparent: true,
50
+ depthWrite: false,
51
+ vertexShader: /* glsl */ `
52
+ uniform float uTime; uniform vec2 uPointer; uniform float uPresence; uniform float uScroll; uniform float uDpr; uniform float uSize;
53
+ varying float vH; varying float vFade;
54
+ ${NOISE_GLSL}
55
+ void main() {
56
+ vec3 p = position;
57
+ float t = uTime * 0.04;
58
+ // Long swells rolling toward the viewer, broken by slow noise; taller toward the right.
59
+ float rise = smoothstep(-20.0, 18.0, p.x);
60
+ float h = 0.8 * sin(p.z * 0.3 + p.x * 0.05 + t * 3.0) + 1.4 * (fbm(p.xz * 0.07 + vec2(t, t * 0.6)) - 0.5);
61
+ h = h * (0.35 + 0.85 * rise);
62
+ vec2 hill = p.xz - vec2(uPointer.x * 16.0 + 2.0, -uPointer.y * 10.0 - 4.0);
63
+ h += exp(-dot(hill, hill) * 0.03) * 0.7 * uPresence;
64
+ p.y += h - uScroll * 2.2;
65
+ vH = clamp(h * 0.6 + 0.5, 0.0, 1.0);
66
+ vec4 mv = modelViewMatrix * vec4(p, 1.0);
67
+ vFade = smoothstep(46.0, 14.0, -mv.z) * smoothstep(0.5, 4.0, -mv.z);
68
+ gl_Position = projectionMatrix * mv;
69
+ gl_PointSize = max(1.5 * uDpr, uDpr * uSize / -mv.z);
70
+ }
71
+ `,
72
+ fragmentShader: /* glsl */ `
73
+ uniform vec3 uInk; uniform vec3 uAccent; uniform float uInkWeight; uniform float uFloor;
74
+ varying float vH; varying float vFade;
75
+ ${QUIET_GLSL}
76
+ void main() {
77
+ float d = length(gl_PointCoord - 0.5);
78
+ float disc = smoothstep(0.5, 0.3, d);
79
+ float crest = smoothstep(0.68, 0.95, vH);
80
+ float v = hush(max((0.3 + 0.7 * vH) * uInkWeight, uFloor) * vFade, gl_FragCoord.xy);
81
+ vec3 col = mix(uInk, uAccent, crest * 0.85);
82
+ gl_FragColor = vec4(col, disc * v);
83
+ #include <colorspace_fragment>
84
+ }
85
+ `,
86
+ });
87
+
88
+ scene.add(new THREE.Points(geometry, material));
89
+
90
+ return {
91
+ scene,
92
+ camera,
93
+ update(state) {
94
+ uniforms.uTime.value = state.time;
95
+ uniforms.uPointer.value.set(state.pointer.x, state.pointer.y);
96
+ uniforms.uPresence.value = state.presence;
97
+ uniforms.uScroll.value = state.scroll;
98
+ quietSet.update(state);
99
+ },
100
+ resize(width, height) {
101
+ camera.aspect = width / Math.max(1, height);
102
+ camera.updateProjectionMatrix();
103
+ uniforms.uDpr.value = renderer.getPixelRatio();
104
+ },
105
+ dispose() {
106
+ geometry.dispose();
107
+ material.dispose();
108
+ },
109
+ };
110
+ };
@@ -0,0 +1,99 @@
1
+ // components/struq/scenes/ripple.ts (Struq recipe)
2
+ // The hero photo as a living surface: a slow liquid drift, a ripple that follows the pointer
3
+ // and grows with its speed, a hint of chromatic split in the ripple, and a slow push-in as the
4
+ // hero scrolls away. The photo is pulled toward the palette (ink in the shadows, surface in the
5
+ // highlights) by `grade`, so any pack photo sits in the site's colours. The ripple only lives while
6
+ // the pointer is there (presence), and it stills behind the copy marked data-quiet.
7
+ // Suits: hospitality, food, hotels, fashion, wellness, any brief with one strong hero photo.
8
+ import { NOISE_GLSL, QUIET_GLSL, quietUniforms, type CreateScene } from "./types";
9
+
10
+ export const createScene: CreateScene = async ({ THREE, renderer, palette, image, grade }) => {
11
+ if (!image) throw new Error("The ripple scene needs an image: <HeroCanvas scene=\"ripple\" image={images.hero[0]} />");
12
+ const scene = new THREE.Scene();
13
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
14
+ const texture = await new THREE.TextureLoader().loadAsync(image.src);
15
+ texture.colorSpace = THREE.SRGBColorSpace;
16
+ texture.minFilter = THREE.LinearFilter;
17
+ texture.generateMipmaps = false;
18
+
19
+ const quietSet = quietUniforms(THREE, 0.1);
20
+ const uniforms = {
21
+ ...quietSet.uniforms,
22
+ uMap: { value: texture },
23
+ uPresence: { value: 0 },
24
+ uPr: { value: renderer.getPixelRatio() },
25
+ uTime: { value: 0 },
26
+ uRes: { value: new THREE.Vector2(1, 1) },
27
+ uImg: { value: new THREE.Vector2(image.width, image.height) },
28
+ uPointer: { value: new THREE.Vector2(0, 0) },
29
+ uStrength: { value: 0 },
30
+ uScroll: { value: 0 },
31
+ uGrade: { value: grade },
32
+ uInk: { value: new THREE.Color(palette.ink) },
33
+ uSurface: { value: new THREE.Color(palette.surface) },
34
+ };
35
+
36
+ const material = new THREE.ShaderMaterial({
37
+ uniforms,
38
+ vertexShader: /* glsl */ `
39
+ varying vec2 vUv;
40
+ void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
41
+ `,
42
+ fragmentShader: /* glsl */ `
43
+ uniform sampler2D uMap; uniform float uTime; uniform vec2 uRes; uniform vec2 uImg;
44
+ uniform vec2 uPointer; uniform float uStrength; uniform float uScroll; uniform float uGrade;
45
+ uniform vec3 uInk; uniform vec3 uSurface; uniform float uPresence; uniform float uPr;
46
+ varying vec2 vUv;
47
+ ${NOISE_GLSL}
48
+ ${QUIET_GLSL}
49
+ vec2 cover(vec2 uv) {
50
+ float rs = uRes.x / uRes.y; float ri = uImg.x / uImg.y;
51
+ vec2 s = rs > ri ? vec2(1.0, ri / rs) : vec2(rs / ri, 1.0);
52
+ return (uv - 0.5) * s + 0.5;
53
+ }
54
+ void main() {
55
+ vec2 uv = (vUv - 0.5) / (1.04 + uScroll * 0.12) + 0.5;
56
+ float calm = 1.0 - 0.8 * quiet(gl_FragCoord.xy);
57
+ float t = uTime * 0.05;
58
+ vec2 d = (vec2(noise(uv * 2.4 + t), noise(uv * 2.4 - t * 0.8)) - 0.5) * 0.008 * calm;
59
+ vec2 aspect = vec2(uRes.x / uRes.y, 1.0);
60
+ vec2 diff = (vUv - (uPointer * 0.5 + 0.5)) * aspect;
61
+ float dist = length(diff);
62
+ float wave = sin(dist * 30.0 - uTime * 2.0) * exp(-dist * 6.0) * (0.003 + uStrength * 0.012) * uPresence * calm;
63
+ d += normalize(diff + 1e-5) * wave;
64
+ vec2 c = cover(uv + d);
65
+ float split = abs(wave) * 0.6;
66
+ vec3 col = vec3(texture2D(uMap, c + vec2(split, 0.0)).r, texture2D(uMap, c).g, texture2D(uMap, c - vec2(split, 0.0)).b);
67
+ float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
68
+ col = mix(col, mix(uInk, uSurface, smoothstep(0.02, 0.9, l)), uGrade);
69
+ col += (hash(vUv * uRes + fract(uTime * 7.0)) - 0.5) * 0.025;
70
+ gl_FragColor = vec4(col, 1.0);
71
+ #include <colorspace_fragment>
72
+ }
73
+ `,
74
+ });
75
+
76
+ const geometry = new THREE.PlaneGeometry(2, 2);
77
+ scene.add(new THREE.Mesh(geometry, material));
78
+
79
+ return {
80
+ scene,
81
+ camera,
82
+ update(state) {
83
+ uniforms.uTime.value = state.time;
84
+ uniforms.uPointer.value.set(state.pointer.x, state.pointer.y);
85
+ uniforms.uStrength.value = state.velocity;
86
+ uniforms.uPresence.value = state.presence;
87
+ quietSet.update(state);
88
+ uniforms.uScroll.value = state.scroll;
89
+ },
90
+ resize(width, height) {
91
+ uniforms.uRes.value.set(width, height);
92
+ },
93
+ dispose() {
94
+ geometry.dispose();
95
+ material.dispose();
96
+ texture.dispose();
97
+ },
98
+ };
99
+ };
@@ -0,0 +1,114 @@
1
+ // components/struq/scenes/types.ts (Struq recipe)
2
+ // The contract every hero scene follows. HeroCanvas owns the renderer, the clock, the pointer,
3
+ // the pause, the poster and the quiet zones; a scene owns only what it draws.
4
+ import type * as THREE_NS from "three";
5
+
6
+ export type Palette = {
7
+ /** CSS colour strings, already resolved to rgb() from the :root tokens. */
8
+ surface: string;
9
+ surface2: string;
10
+ ink: string;
11
+ accent: string;
12
+ };
13
+
14
+ export type SceneState = {
15
+ /** Seconds on gsap.ticker. Scenes scale it down: an ambient field moves at 0.02 to 0.06 of it. */
16
+ time: number;
17
+ /** Smoothed pointer, -1 to 1 on both axes, y up. A gentle modulation only, never the subject. */
18
+ pointer: { x: number; y: number };
19
+ /** 0 to 1: how far the pointer is engaged; eases in on move and out when it rests or leaves. */
20
+ presence: number;
21
+ /** 0 to 1: how far the hero has scrolled out of view. */
22
+ scroll: number;
23
+ /** 0 to 1: recent pointer speed, decays on its own. */
24
+ velocity: number;
25
+ /**
26
+ * Up to four rects behind the copy (elements marked data-quiet in the hero), in device pixels with
27
+ * a bottom-left origin like gl_FragCoord: [left, bottom, right, top] per rect, zeros when unused.
28
+ */
29
+ quiet: Float32Array;
30
+ /** The soft edge around each quiet rect, in device pixels. */
31
+ feather: number;
32
+ };
33
+
34
+ export type SceneInput = {
35
+ THREE: typeof THREE_NS;
36
+ renderer: THREE_NS.WebGLRenderer;
37
+ palette: Palette;
38
+ image?: { src: string; width: number; height: number };
39
+ grade: number;
40
+ /** True on a dark palette: the quiet level and the line weights differ per ground. */
41
+ dark: boolean;
42
+ };
43
+
44
+ export type HeroScene = {
45
+ scene: THREE_NS.Scene;
46
+ camera: THREE_NS.Camera;
47
+ update(state: SceneState): void;
48
+ /** Width and height in CSS pixels; read renderer.getPixelRatio() for device pixels. */
49
+ resize(width: number, height: number): void;
50
+ /** A multi-pass scene renders itself; otherwise HeroCanvas renders scene with camera. */
51
+ render?(renderer: THREE_NS.WebGLRenderer): void;
52
+ dispose(): void;
53
+ };
54
+
55
+ export type CreateScene = (input: SceneInput) => Promise<HeroScene>;
56
+
57
+ /** Shared GLSL: hash, value noise and fbm. Run noise per field cell, never per screen pixel, where you can. */
58
+ export const NOISE_GLSL = /* glsl */ `
59
+ float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
60
+ float noise(vec2 p) {
61
+ vec2 i = floor(p); vec2 f = fract(p);
62
+ vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
63
+ return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
64
+ }
65
+ float fbm(vec2 p) {
66
+ float v = 0.0; float a = 0.5;
67
+ for (int i = 0; i < 4; i++) { v += a * noise(p); p = p * 2.03 + vec2(1.7, 9.2); a *= 0.5; }
68
+ return v;
69
+ }
70
+ `;
71
+
72
+ /**
73
+ * The hush behind the copy. Inside a quiet rect the field does not scale down to a dark box: it meets
74
+ * a soft ceiling (uQuietLevel), so faint light passes as it is and only the bright parts are held
75
+ * down. The feather lies outside each rect, so the copy itself sits at full quiet.
76
+ */
77
+ export const QUIET_GLSL = /* glsl */ `
78
+ uniform vec4 uQuiet[4];
79
+ uniform float uFeather;
80
+ uniform float uQuietLevel;
81
+ float quietBox(vec2 p, vec4 r, float f) {
82
+ vec2 a = smoothstep(r.xy - f, r.xy, p);
83
+ vec2 b = 1.0 - smoothstep(r.zw, r.zw + f, p);
84
+ return a.x * a.y * b.x * b.y;
85
+ }
86
+ float quiet(vec2 p) {
87
+ float m = 0.0;
88
+ for (int i = 0; i < 4; i++) {
89
+ vec4 r = uQuiet[i];
90
+ if (r.z > r.x) m = max(m, quietBox(p, r, uFeather));
91
+ }
92
+ return m;
93
+ }
94
+ float hush(float v, vec2 p) {
95
+ float q = quiet(p);
96
+ return mix(v, uQuietLevel * (1.0 - exp(-v / uQuietLevel)), q);
97
+ }
98
+ `;
99
+
100
+ /** The uniforms QUIET_GLSL reads, and the update that copies HeroCanvas's rects into them. */
101
+ export function quietUniforms(THREE: typeof THREE_NS, level: number) {
102
+ const uniforms = {
103
+ uQuiet: { value: [0, 1, 2, 3].map(() => new THREE.Vector4()) },
104
+ uFeather: { value: 48 },
105
+ uQuietLevel: { value: level },
106
+ };
107
+ const update = (state: SceneState) => {
108
+ for (let i = 0; i < 4; i++) {
109
+ uniforms.uQuiet.value[i].set(state.quiet[i * 4], state.quiet[i * 4 + 1], state.quiet[i * 4 + 2], state.quiet[i * 4 + 3]);
110
+ }
111
+ uniforms.uFeather.value = state.feather;
112
+ };
113
+ return { uniforms, update };
114
+ }