@struq/skill 0.2.2 → 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.
- package/README.md +9 -7
- package/package.json +2 -2
- package/skills/struq/SKILL.md +304 -200
- package/skills/struq/cinematic/showpieces/README.md +18 -0
- package/skills/struq/cinematic/showpieces/toonstuk-fill-rows.tsx +74 -0
- package/skills/struq/cinematic/showpieces/toonstuk-hero-opener.tsx +128 -0
- package/skills/struq/cinematic/showpieces/toonstuk-image-to-fullbleed.tsx +78 -0
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +81 -0
- package/skills/struq/cinematic/showpieces/toonstuk-panel-wipe.tsx +89 -0
- package/skills/struq/cinematic/showpieces/toonstuk-pixel-resolve.tsx +107 -0
- package/skills/struq/cinematic/showpieces/toonstuk-project-stack.tsx +86 -0
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +78 -0
- package/skills/struq/cinematic/showpieces/toonstuk-wordmark-finale.tsx +105 -0
- package/skills/struq/recipes/app/fonts.ts +22 -0
- package/skills/struq/recipes/app/globals.css +185 -0
- package/skills/struq/recipes/app/layout.tsx +28 -0
- package/skills/struq/recipes/components/struq/Cursor.tsx +117 -0
- package/skills/struq/recipes/components/struq/HeroCanvas.tsx +278 -0
- package/skills/struq/recipes/components/struq/HorizontalGallery.tsx +106 -0
- package/skills/struq/recipes/components/struq/MagneticButton.tsx +92 -0
- package/skills/struq/recipes/components/struq/MediaFrame.tsx +96 -0
- package/skills/struq/recipes/components/struq/PinnedZoom.tsx +79 -0
- package/skills/struq/recipes/components/struq/Reveal.tsx +64 -0
- package/skills/struq/recipes/components/struq/SmoothScroll.tsx +57 -0
- package/skills/struq/recipes/components/struq/SplitHeadline.tsx +68 -0
- package/skills/struq/recipes/components/struq/scenes/contours.ts +161 -0
- package/skills/struq/recipes/components/struq/scenes/dots.ts +110 -0
- package/skills/struq/recipes/components/struq/scenes/ripple.ts +99 -0
- package/skills/struq/recipes/components/struq/scenes/types.ts +114 -0
- package/skills/struq/recipes/lib/gsap.ts +21 -0
- package/skills/struq/references/anti-patterns.md +71 -6
- package/skills/struq/references/baseline-prompt.md +18 -14
- package/skills/struq/references/brief-router.md +7 -8
- package/skills/struq/references/composition.md +0 -1
- package/skills/struq/references/depth-parallax.md +1 -1
- package/skills/struq/references/direction.md +5 -5
- package/skills/struq/references/hero-scenes.md +107 -0
- package/skills/struq/references/iconography.md +1 -1
- package/skills/struq/references/imagery.md +10 -29
- package/skills/struq/references/micro-interactions.md +1 -1
- package/skills/struq/references/mobile-composition.md +1 -1
- package/skills/struq/references/niche-packets.md +338 -0
- package/skills/struq/references/page-architecture.md +2 -2
- package/skills/struq/references/pinning-scrub.md +3 -4
- package/skills/struq/references/scroll-reveals.md +1 -2
- package/skills/struq/references/sections.md +141 -0
- package/skills/struq/references/seo.md +2 -2
- package/skills/struq/references/showcase.md +2 -2
- package/skills/struq/references/svg-motion.md +390 -390
- package/skills/struq/references/typography.md +39 -39
- package/skills/struq/references/vibe-presets.md +5 -5
- package/skills/struq/references/webgl-scenes.md +593 -593
- package/skills/struq/scripts/canon.mjs +309 -0
- package/skills/struq/scripts/check.mjs +547 -0
- package/skills/struq/scripts/font-names.mjs +40 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +92 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/recipes.mjs +135 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/HeroCanvas.tsx (Struq recipe): the WebGL hero layer
|
|
3
|
+
// A three.js layer behind the hero, with everything a production hero needs built in:
|
|
4
|
+
// - three.js loads in its own chunk after the page paints, never in the first-load bundle;
|
|
5
|
+
// - the poster (the hero photo, or the flat page surface) shows first and stays when WebGL is
|
|
6
|
+
// missing; the canvas fades in over it only after its first frame;
|
|
7
|
+
// - device pixel ratio capped at 1.75, plus a pixel budget for large screens;
|
|
8
|
+
// - rendering pauses when the hero leaves the viewport or the tab is hidden;
|
|
9
|
+
// - prefers-reduced-motion and phones render ONE composed still frame (a computed poster) and never
|
|
10
|
+
// run the loop, so a phone spends no battery on it;
|
|
11
|
+
// - the copy stays readable: mark the headline and lead with data-quiet and the field hushes
|
|
12
|
+
// behind them (a soft ceiling, not a dark box);
|
|
13
|
+
// - the pointer is a gentle modulation that eases in and out (presence), never a spotlight;
|
|
14
|
+
// - colours come from the site tokens on :root (--surface-0, --surface-2, --ink, --accent), so the
|
|
15
|
+
// scene is recoloured to the palette automatically;
|
|
16
|
+
// - it runs on gsap.ticker, the same clock as Lenis and ScrollTrigger.
|
|
17
|
+
//
|
|
18
|
+
// Place it as the first child of a `relative` hero section:
|
|
19
|
+
// <section className="relative min-h-svh">
|
|
20
|
+
// <HeroCanvas scene="contours" /> (no photo needed)
|
|
21
|
+
// <HeroCanvas scene="ripple" image={images.hero[0]} /> (the hero photo, liquid under the pointer)
|
|
22
|
+
// <HeroCanvas scene="dots" /> (a field of points, for tech and mobility)
|
|
23
|
+
// <div className="relative z-10"><h1 data-quiet>...</h1><p data-quiet>...</p></div>
|
|
24
|
+
// </section>
|
|
25
|
+
//
|
|
26
|
+
// A scene from the Struq MCP (struq_achtergrond) or one of your own: write it as
|
|
27
|
+
// components/struq/scenes/<name>.ts exporting createScene (see scenes/types.ts) and add it to SCENES.
|
|
28
|
+
import Image from "next/image";
|
|
29
|
+
import { useEffect, useRef } from "react";
|
|
30
|
+
import { gsap } from "@/lib/gsap";
|
|
31
|
+
import type { StruqImage } from "@/lib/struq-images";
|
|
32
|
+
import type { CreateScene, Palette } from "./scenes/types";
|
|
33
|
+
|
|
34
|
+
const SCENES: Record<string, () => Promise<{ createScene: CreateScene }>> = {
|
|
35
|
+
contours: () => import("./scenes/contours"),
|
|
36
|
+
ripple: () => import("./scenes/ripple"),
|
|
37
|
+
dots: () => import("./scenes/dots"),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type HeroSceneName = keyof typeof SCENES;
|
|
41
|
+
|
|
42
|
+
type Props = {
|
|
43
|
+
scene: HeroSceneName;
|
|
44
|
+
/** The poster, and the texture for "ripple". Use the same image as the hero photo. */
|
|
45
|
+
image?: StruqImage;
|
|
46
|
+
/** 0 to 1: how strongly the palette grade covers the photo (ripple only). */
|
|
47
|
+
grade?: number;
|
|
48
|
+
/** 0 to 0.6: a flat black scrim over the layer, so type set in text-on-photo reads on a photo. */
|
|
49
|
+
scrim?: number;
|
|
50
|
+
className?: string;
|
|
51
|
+
/** Set when the poster image is the page's LCP element. */
|
|
52
|
+
priority?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A token as an rgb() string three.js can parse, whatever it was written in: hex, oklch() or
|
|
57
|
+
* color-mix(). The browser resolves the variable on a probe element, a 2D canvas turns the result
|
|
58
|
+
* into sRGB bytes.
|
|
59
|
+
*/
|
|
60
|
+
function pick(name: string, fallback: string, host: HTMLElement) {
|
|
61
|
+
const probe = document.createElement("span");
|
|
62
|
+
probe.style.color = `var(${name}, ${fallback})`;
|
|
63
|
+
host.appendChild(probe); // resolved where the canvas sits, so a contrast scene recolours it
|
|
64
|
+
const computed = getComputedStyle(probe).color;
|
|
65
|
+
probe.remove();
|
|
66
|
+
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
67
|
+
if (!ctx) return fallback;
|
|
68
|
+
ctx.fillStyle = fallback;
|
|
69
|
+
ctx.fillStyle = computed; // an unparseable value leaves the fallback in place
|
|
70
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
71
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
72
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Relative luminance of an rgb() string, 0 to 1. */
|
|
76
|
+
function luminance(rgb: string) {
|
|
77
|
+
const [r, g, b] = (rgb.match(/[0-9]+/g) ?? ["255", "255", "255"]).slice(0, 3).map((n) => Number(n) / 255);
|
|
78
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readPalette(host: HTMLElement): Palette {
|
|
82
|
+
return {
|
|
83
|
+
surface: pick("--surface-0", "#f4efe6", host),
|
|
84
|
+
surface2: pick("--surface-2", "#e2d7c4", host),
|
|
85
|
+
ink: pick("--ink", "#1c1a17", host),
|
|
86
|
+
accent: pick("--accent", "#8a3b22", host),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function HeroCanvas({ scene, image, grade = 0.25, scrim = 0, className = "", priority = false }: Props) {
|
|
91
|
+
const host = useRef<HTMLDivElement>(null);
|
|
92
|
+
const canvasSlot = useRef<HTMLDivElement>(null);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const el = host.current;
|
|
96
|
+
const slot = canvasSlot.current;
|
|
97
|
+
if (!el || !slot) return;
|
|
98
|
+
let disposed = false;
|
|
99
|
+
let cleanup = () => {};
|
|
100
|
+
|
|
101
|
+
const start = async () => {
|
|
102
|
+
const load = SCENES[scene];
|
|
103
|
+
if (!load) return;
|
|
104
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
105
|
+
const { createScene } = await load();
|
|
106
|
+
const THREE = await import("three");
|
|
107
|
+
if (disposed) return;
|
|
108
|
+
|
|
109
|
+
let renderer: import("three").WebGLRenderer;
|
|
110
|
+
try {
|
|
111
|
+
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, powerPreference: "high-performance" });
|
|
112
|
+
} catch {
|
|
113
|
+
return; // no WebGL: the poster stays, which is the designed fallback
|
|
114
|
+
}
|
|
115
|
+
const phone = window.matchMedia("(max-width: 767px), (pointer: coarse)").matches;
|
|
116
|
+
const still = reduce || phone;
|
|
117
|
+
const dprCap = Math.min(window.devicePixelRatio || 1, phone ? 1.5 : 1.75);
|
|
118
|
+
const canvas = renderer.domElement;
|
|
119
|
+
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;opacity:0";
|
|
120
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
121
|
+
slot.appendChild(canvas);
|
|
122
|
+
|
|
123
|
+
const palette = readPalette(el);
|
|
124
|
+
let hero: Awaited<ReturnType<CreateScene>>;
|
|
125
|
+
try {
|
|
126
|
+
hero = await createScene({
|
|
127
|
+
THREE,
|
|
128
|
+
renderer,
|
|
129
|
+
palette,
|
|
130
|
+
dark: luminance(palette.surface) < 0.4,
|
|
131
|
+
image: image ? { src: image.src, width: image.width, height: image.height } : undefined,
|
|
132
|
+
grade,
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.warn("[HeroCanvas] scene failed, keeping the poster:", error);
|
|
136
|
+
renderer.dispose();
|
|
137
|
+
canvas.remove();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (disposed) {
|
|
141
|
+
canvas.remove();
|
|
142
|
+
hero.dispose();
|
|
143
|
+
renderer.dispose();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const state = {
|
|
148
|
+
time: 0,
|
|
149
|
+
pointer: { x: 0.3, y: 0 },
|
|
150
|
+
target: { x: 0.3, y: 0 },
|
|
151
|
+
presence: 0,
|
|
152
|
+
wanted: 0,
|
|
153
|
+
scroll: 0,
|
|
154
|
+
velocity: 0,
|
|
155
|
+
quiet: new Float32Array(16),
|
|
156
|
+
feather: 48,
|
|
157
|
+
};
|
|
158
|
+
// The copy the field hushes behind: up to four [data-quiet] elements in the hero section.
|
|
159
|
+
const measureQuiet = () => {
|
|
160
|
+
const pr = renderer.getPixelRatio();
|
|
161
|
+
const box = el.getBoundingClientRect();
|
|
162
|
+
const section = el.parentElement ?? el;
|
|
163
|
+
state.quiet.fill(0);
|
|
164
|
+
Array.from(section.querySelectorAll<HTMLElement>("[data-quiet]"))
|
|
165
|
+
.slice(0, 4)
|
|
166
|
+
.forEach((node, i) => {
|
|
167
|
+
const r = node.getBoundingClientRect();
|
|
168
|
+
state.quiet[i * 4] = (r.left - box.left) * pr;
|
|
169
|
+
state.quiet[i * 4 + 1] = (box.bottom - r.bottom) * pr;
|
|
170
|
+
state.quiet[i * 4 + 2] = (r.right - box.left) * pr;
|
|
171
|
+
state.quiet[i * 4 + 3] = (box.bottom - r.top) * pr;
|
|
172
|
+
});
|
|
173
|
+
state.feather = 64 * pr;
|
|
174
|
+
};
|
|
175
|
+
const size = () => {
|
|
176
|
+
const w = el.clientWidth || window.innerWidth;
|
|
177
|
+
const h = el.clientHeight || window.innerHeight;
|
|
178
|
+
// DPR cap, then a pixel budget: a full-screen shader on a 4K or ultrawide screen renders at
|
|
179
|
+
// most about 2.4 million pixels. The fields are soft, so the drop in resolution is invisible.
|
|
180
|
+
renderer.setPixelRatio(Math.min(dprCap, Math.sqrt(2_400_000 / Math.max(1, w * h))));
|
|
181
|
+
renderer.setSize(w, h, false);
|
|
182
|
+
hero.resize(w, h);
|
|
183
|
+
measureQuiet();
|
|
184
|
+
};
|
|
185
|
+
size();
|
|
186
|
+
// Webfonts and the headline's split change the copy's box once after mount.
|
|
187
|
+
const remeasure = window.setTimeout(measureQuiet, 900);
|
|
188
|
+
|
|
189
|
+
const draw = () => {
|
|
190
|
+
hero.update(state);
|
|
191
|
+
if (hero.render) hero.render(renderer);
|
|
192
|
+
else renderer.render(hero.scene, hero.camera);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
let visible = true;
|
|
196
|
+
const io = new IntersectionObserver(([entry]) => (visible = entry.isIntersecting), { threshold: 0 });
|
|
197
|
+
io.observe(el);
|
|
198
|
+
|
|
199
|
+
const onPointer = (e: PointerEvent) => {
|
|
200
|
+
const r = el.getBoundingClientRect();
|
|
201
|
+
const x = ((e.clientX - r.left) / r.width) * 2 - 1;
|
|
202
|
+
const y = -(((e.clientY - r.top) / r.height) * 2 - 1);
|
|
203
|
+
state.velocity = Math.min(1, state.velocity + Math.hypot(x - state.target.x, y - state.target.y) * 2);
|
|
204
|
+
state.target.x = x;
|
|
205
|
+
state.target.y = y;
|
|
206
|
+
state.wanted = Math.abs(x) <= 1 && Math.abs(y) <= 1 ? 1 : 0;
|
|
207
|
+
};
|
|
208
|
+
const onLeave = () => (state.wanted = 0);
|
|
209
|
+
|
|
210
|
+
const tick = (time: number) => {
|
|
211
|
+
if (!visible || document.hidden) return;
|
|
212
|
+
state.time = time;
|
|
213
|
+
state.pointer.x += (state.target.x - state.pointer.x) * 0.05;
|
|
214
|
+
state.pointer.y += (state.target.y - state.pointer.y) * 0.05;
|
|
215
|
+
state.presence += (state.wanted - state.presence) * (state.wanted ? 0.04 : 0.015);
|
|
216
|
+
state.velocity *= 0.95;
|
|
217
|
+
state.scroll = Math.min(1, Math.max(0, window.scrollY / Math.max(1, el.clientHeight)));
|
|
218
|
+
draw();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const ro = new ResizeObserver(() => {
|
|
222
|
+
size();
|
|
223
|
+
if (still) draw();
|
|
224
|
+
});
|
|
225
|
+
ro.observe(el);
|
|
226
|
+
|
|
227
|
+
if (still) {
|
|
228
|
+
state.time = 40; // one composed still frame: the computed poster
|
|
229
|
+
draw();
|
|
230
|
+
} else {
|
|
231
|
+
window.addEventListener("pointermove", onPointer, { passive: true });
|
|
232
|
+
document.documentElement.addEventListener("pointerleave", onLeave);
|
|
233
|
+
gsap.ticker.add(tick);
|
|
234
|
+
draw();
|
|
235
|
+
}
|
|
236
|
+
gsap.to(canvas, { opacity: 1, duration: 1.2, ease: "power2.out", delay: 0.1 });
|
|
237
|
+
|
|
238
|
+
cleanup = () => {
|
|
239
|
+
gsap.ticker.remove(tick);
|
|
240
|
+
window.clearTimeout(remeasure);
|
|
241
|
+
window.removeEventListener("pointermove", onPointer);
|
|
242
|
+
document.documentElement.removeEventListener("pointerleave", onLeave);
|
|
243
|
+
io.disconnect();
|
|
244
|
+
ro.disconnect();
|
|
245
|
+
hero.dispose();
|
|
246
|
+
renderer.dispose();
|
|
247
|
+
canvas.remove();
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Start after the page has painted, so the canvas never competes with the LCP.
|
|
252
|
+
const idle = window.setTimeout(() => void start(), 120);
|
|
253
|
+
return () => {
|
|
254
|
+
disposed = true;
|
|
255
|
+
window.clearTimeout(idle);
|
|
256
|
+
cleanup();
|
|
257
|
+
};
|
|
258
|
+
}, [scene, image, grade]);
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div ref={host} aria-hidden className={`pointer-events-none absolute inset-0 overflow-hidden bg-surface-0 ${className}`}>
|
|
262
|
+
{image ? (
|
|
263
|
+
<Image
|
|
264
|
+
src={image.src}
|
|
265
|
+
alt=""
|
|
266
|
+
fill
|
|
267
|
+
sizes="100vw"
|
|
268
|
+
loading={priority ? "eager" : "lazy"}
|
|
269
|
+
fetchPriority={priority ? "high" : "auto"}
|
|
270
|
+
unoptimized={image.unoptimized}
|
|
271
|
+
className="object-cover"
|
|
272
|
+
/>
|
|
273
|
+
) : null}
|
|
274
|
+
<div ref={canvasSlot} className="absolute inset-0" />
|
|
275
|
+
{scrim > 0 ? <div className="absolute inset-0 bg-black" style={{ opacity: Math.min(0.6, scrim) }} /> : null}
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/HorizontalGallery.tsx (Struq recipe): SHOWPIECE
|
|
3
|
+
// A pinned section whose track of images slides sideways as you scroll down, each photo panning
|
|
4
|
+
// inside its frame (containerAnimation). Place it at an act break: after the first proof, before
|
|
5
|
+
// the invitation. On phones and under reduced motion it becomes a native swipe row, no pin.
|
|
6
|
+
//
|
|
7
|
+
// <HorizontalGallery
|
|
8
|
+
// title={<>De kaart volgt<br />de markt.</>}
|
|
9
|
+
// intro="Wat de vissers en telers die week brengen, staat zaterdag op tafel."
|
|
10
|
+
// items={[{ image: images.card[0], title: "Zeebaars", text: "Uit de Noordzee, op de huid gebakken." }, ...]}
|
|
11
|
+
// />
|
|
12
|
+
import Image from "next/image";
|
|
13
|
+
import { useRef } from "react";
|
|
14
|
+
import { gsap, useGSAP } from "@/lib/gsap";
|
|
15
|
+
import type { StruqImage } from "@/lib/struq-images";
|
|
16
|
+
|
|
17
|
+
type Item = { image: StruqImage; title: string; text?: string };
|
|
18
|
+
|
|
19
|
+
type Props = {
|
|
20
|
+
title: React.ReactNode;
|
|
21
|
+
intro?: string;
|
|
22
|
+
items: Item[];
|
|
23
|
+
className?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function HorizontalGallery({ title, intro, items, className = "" }: Props) {
|
|
27
|
+
const section = useRef<HTMLElement>(null);
|
|
28
|
+
const track = useRef<HTMLDivElement>(null);
|
|
29
|
+
|
|
30
|
+
useGSAP(
|
|
31
|
+
() => {
|
|
32
|
+
const el = section.current;
|
|
33
|
+
const row = track.current;
|
|
34
|
+
if (!el || !row) return;
|
|
35
|
+
const mm = gsap.matchMedia();
|
|
36
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
37
|
+
const distance = () => Math.max(0, row.scrollWidth - window.innerWidth);
|
|
38
|
+
const slide = gsap.to(row, {
|
|
39
|
+
x: () => -distance(),
|
|
40
|
+
ease: "none",
|
|
41
|
+
scrollTrigger: {
|
|
42
|
+
trigger: el,
|
|
43
|
+
start: "top top",
|
|
44
|
+
end: () => `+=${distance()}`,
|
|
45
|
+
pin: true,
|
|
46
|
+
scrub: 1, // no pin anticipation: with smooth scroll it jerks the pin on engage
|
|
47
|
+
invalidateOnRefresh: true,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
// Each photo pans against the slide, which gives the row depth.
|
|
51
|
+
gsap.utils.toArray<HTMLElement>("[data-pan]", el).forEach((photo) => {
|
|
52
|
+
gsap.fromTo(
|
|
53
|
+
photo,
|
|
54
|
+
{ xPercent: -6 },
|
|
55
|
+
{
|
|
56
|
+
xPercent: 6,
|
|
57
|
+
ease: "none",
|
|
58
|
+
scrollTrigger: { trigger: photo, containerAnimation: slide, start: "left right", end: "right left", scrub: true },
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
return () => mm.revert();
|
|
64
|
+
},
|
|
65
|
+
{ scope: section },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<section ref={section} className={`relative overflow-hidden bg-surface-1 ${className}`}>
|
|
70
|
+
<div className="flex min-h-svh flex-col justify-center gap-12 py-24 md:py-0">
|
|
71
|
+
<div
|
|
72
|
+
ref={track}
|
|
73
|
+
className="flex snap-x snap-mandatory gap-6 overflow-x-auto px-6 md:w-max md:snap-none md:gap-10 md:overflow-visible md:px-[8vw]"
|
|
74
|
+
>
|
|
75
|
+
<div className="flex w-[80vw] shrink-0 snap-start flex-col justify-end gap-6 md:w-[34vw] md:pr-[4vw]">
|
|
76
|
+
<h2 className="font-display text-h2">{title}</h2>
|
|
77
|
+
{intro ? <p className="max-w-[40ch] text-lead text-ink-muted">{intro}</p> : null}
|
|
78
|
+
</div>
|
|
79
|
+
{items.map((item, i) => (
|
|
80
|
+
<figure
|
|
81
|
+
key={item.image.src}
|
|
82
|
+
className={`flex shrink-0 snap-start flex-col gap-4 ${i % 2 ? "w-[72vw] md:w-[26vw] md:pt-[12vh]" : "w-[80vw] md:w-[34vw]"}`}
|
|
83
|
+
>
|
|
84
|
+
<div className="relative overflow-hidden bg-surface-2" style={{ aspectRatio: i % 2 ? "4/5" : "3/2" }}>
|
|
85
|
+
<div data-pan="" className="absolute inset-[-8%]">
|
|
86
|
+
<Image
|
|
87
|
+
src={item.image.src}
|
|
88
|
+
alt={item.image.alt}
|
|
89
|
+
fill
|
|
90
|
+
sizes="(min-width: 768px) 34vw, 80vw"
|
|
91
|
+
unoptimized={item.image.unoptimized}
|
|
92
|
+
className="object-cover"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<figcaption className="flex flex-col gap-1">
|
|
97
|
+
<span className="font-display text-h3">{item.title}</span>
|
|
98
|
+
{item.text ? <span className="max-w-[36ch] text-small text-ink-muted">{item.text}</span> : null}
|
|
99
|
+
</figcaption>
|
|
100
|
+
</figure>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</section>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/MagneticButton.tsx (Struq recipe)
|
|
3
|
+
// The page's primary call to action. It leans toward the pointer (the label leans further, which
|
|
4
|
+
// reads as depth), a fill rises from the bottom edge on hover, and it springs back on leave.
|
|
5
|
+
// quickTo runs on gsap.ticker, the page's one clock. Touch screens and reduced motion get a still
|
|
6
|
+
// button with the same fill.
|
|
7
|
+
//
|
|
8
|
+
// <MagneticButton href="#reserveren">Reserveer een tafel</MagneticButton>
|
|
9
|
+
import { useRef } from "react";
|
|
10
|
+
import { gsap, useGSAP } from "@/lib/gsap";
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
href?: string;
|
|
15
|
+
onClick?: () => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
/** "solid" is the accent button; "line" is ink on a hairline. */
|
|
18
|
+
variant?: "solid" | "line";
|
|
19
|
+
/** "lg" for the hero opener: a confident rectangle, never a pill. */
|
|
20
|
+
size?: "md" | "lg";
|
|
21
|
+
strength?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function MagneticButton({ children, href, onClick, className = "", variant = "solid", size = "md", strength = 0.3 }: Props) {
|
|
25
|
+
const ref = useRef<HTMLElement>(null);
|
|
26
|
+
|
|
27
|
+
useGSAP(
|
|
28
|
+
() => {
|
|
29
|
+
const el = ref.current;
|
|
30
|
+
if (!el) return;
|
|
31
|
+
const mm = gsap.matchMedia();
|
|
32
|
+
mm.add("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)", () => {
|
|
33
|
+
const label = el.querySelector<HTMLElement>("[data-label]");
|
|
34
|
+
const x = gsap.quickTo(el, "x", { duration: 0.6, ease: "power3.out" });
|
|
35
|
+
const y = gsap.quickTo(el, "y", { duration: 0.6, ease: "power3.out" });
|
|
36
|
+
const lx = label ? gsap.quickTo(label, "x", { duration: 0.6, ease: "power3.out" }) : null;
|
|
37
|
+
const ly = label ? gsap.quickTo(label, "y", { duration: 0.6, ease: "power3.out" }) : null;
|
|
38
|
+
const move = (e: PointerEvent) => {
|
|
39
|
+
const r = el.getBoundingClientRect();
|
|
40
|
+
const dx = e.clientX - (r.left + r.width / 2);
|
|
41
|
+
const dy = e.clientY - (r.top + r.height / 2);
|
|
42
|
+
x(dx * strength);
|
|
43
|
+
y(dy * strength);
|
|
44
|
+
lx?.(dx * strength * 0.5);
|
|
45
|
+
ly?.(dy * strength * 0.5);
|
|
46
|
+
};
|
|
47
|
+
const leave = () => {
|
|
48
|
+
gsap.to(label ? [el, label] : el, { x: 0, y: 0, duration: 0.9, ease: "elastic.out(1, 0.4)" });
|
|
49
|
+
};
|
|
50
|
+
el.addEventListener("pointermove", move);
|
|
51
|
+
el.addEventListener("pointerleave", leave);
|
|
52
|
+
return () => {
|
|
53
|
+
el.removeEventListener("pointermove", move);
|
|
54
|
+
el.removeEventListener("pointerleave", leave);
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
return () => mm.revert();
|
|
58
|
+
},
|
|
59
|
+
{ scope: ref },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const look =
|
|
63
|
+
variant === "solid"
|
|
64
|
+
? "bg-accent text-accent-ink"
|
|
65
|
+
: "text-ink shadow-[inset_0_0_0_1px_var(--line)]";
|
|
66
|
+
// The hover fill is the ink on both variants; the label turns to the page surface over it.
|
|
67
|
+
const inner = (
|
|
68
|
+
<>
|
|
69
|
+
<span
|
|
70
|
+
aria-hidden
|
|
71
|
+
className="absolute inset-0 origin-bottom scale-y-0 bg-ink transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-y-100"
|
|
72
|
+
/>
|
|
73
|
+
<span data-label="" className="relative inline-block transition-colors duration-500 group-hover:text-surface-0">
|
|
74
|
+
{children}
|
|
75
|
+
</span>
|
|
76
|
+
</>
|
|
77
|
+
);
|
|
78
|
+
const base = `group relative inline-flex items-center overflow-hidden ${size === "lg" ? "px-10 py-5 text-lead md:px-12 md:py-6" : "px-8 py-4 text-small"} font-medium ${look} ${className}`;
|
|
79
|
+
|
|
80
|
+
if (href) {
|
|
81
|
+
return (
|
|
82
|
+
<a ref={ref as React.RefObject<HTMLAnchorElement>} href={href} className={base}>
|
|
83
|
+
{inner}
|
|
84
|
+
</a>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return (
|
|
88
|
+
<button ref={ref as React.RefObject<HTMLButtonElement>} type="button" onClick={onClick} className={base}>
|
|
89
|
+
{inner}
|
|
90
|
+
</button>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/MediaFrame.tsx (Struq recipe)
|
|
3
|
+
// The only way an image goes on the page. It always holds a real image from lib/struq-images.ts
|
|
4
|
+
// (written by scripts/images.mjs), so a frame can never ship empty.
|
|
5
|
+
//
|
|
6
|
+
// - reveal "scroll": the frame opens bottom-up like a curtain as it enters, the photo settles from 1.3x.
|
|
7
|
+
// - reveal "load": the same curtain on mount, for the hero.
|
|
8
|
+
// - parallax: the photo pans inside the frame while the frame scrolls past (scrubbed).
|
|
9
|
+
//
|
|
10
|
+
// <MediaFrame image={images.hero[0]} ratio="16/9" priority sizes="100vw" reveal="load" />
|
|
11
|
+
// <MediaFrame image={images.card[2]} ratio="3/2" sizes="(min-width: 768px) 40vw, 100vw" />
|
|
12
|
+
import Image from "next/image";
|
|
13
|
+
import { useRef } from "react";
|
|
14
|
+
import { gsap, useGSAP } from "@/lib/gsap";
|
|
15
|
+
import type { StruqImage } from "@/lib/struq-images";
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
image: StruqImage;
|
|
19
|
+
/** CSS aspect ratio, for example "16/9", "3/2", "4/5", "1/1", "21/9". Defaults to the file's own. */
|
|
20
|
+
ratio?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
sizes?: string;
|
|
23
|
+
/** The page's one LCP image (the hero). Exactly one per page. */
|
|
24
|
+
priority?: boolean;
|
|
25
|
+
reveal?: "scroll" | "load" | "none";
|
|
26
|
+
parallax?: boolean;
|
|
27
|
+
/** Override the alt, for example a translation on a non-Dutch site. "" marks it decorative. */
|
|
28
|
+
alt?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function MediaFrame({
|
|
32
|
+
image,
|
|
33
|
+
ratio,
|
|
34
|
+
className = "",
|
|
35
|
+
sizes = "100vw",
|
|
36
|
+
priority = false,
|
|
37
|
+
reveal = "scroll",
|
|
38
|
+
parallax = true,
|
|
39
|
+
alt,
|
|
40
|
+
}: Props) {
|
|
41
|
+
const frame = useRef<HTMLElement>(null);
|
|
42
|
+
const inner = useRef<HTMLDivElement>(null);
|
|
43
|
+
|
|
44
|
+
useGSAP(
|
|
45
|
+
() => {
|
|
46
|
+
const el = frame.current;
|
|
47
|
+
const photo = inner.current;
|
|
48
|
+
if (!el || !photo) return;
|
|
49
|
+
const mm = gsap.matchMedia();
|
|
50
|
+
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
51
|
+
if (reveal !== "none") {
|
|
52
|
+
const trigger = reveal === "scroll" ? { trigger: el, start: "top 88%", once: true } : undefined;
|
|
53
|
+
gsap.fromTo(
|
|
54
|
+
el,
|
|
55
|
+
{ clipPath: "inset(100% 0% 0% 0%)" },
|
|
56
|
+
{ clipPath: "inset(0% 0% 0% 0%)", duration: 1.4, ease: "power4.inOut", delay: reveal === "load" ? 0.2 : 0, scrollTrigger: trigger },
|
|
57
|
+
);
|
|
58
|
+
gsap.fromTo(photo, { scale: 1.3 }, { scale: parallax ? 1.12 : 1, duration: 1.8, ease: "expo.out", delay: reveal === "load" ? 0.2 : 0, scrollTrigger: trigger });
|
|
59
|
+
}
|
|
60
|
+
if (parallax) {
|
|
61
|
+
gsap.fromTo(
|
|
62
|
+
photo,
|
|
63
|
+
{ yPercent: -5 },
|
|
64
|
+
{ yPercent: 5, ease: "none", scrollTrigger: { trigger: el, start: "top bottom", end: "bottom top", scrub: true } },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
el.dataset.ready = "1";
|
|
69
|
+
return () => mm.revert();
|
|
70
|
+
},
|
|
71
|
+
{ scope: frame },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<figure
|
|
76
|
+
ref={frame}
|
|
77
|
+
data-frame={reveal === "none" ? "still" : "mask"}
|
|
78
|
+
className={`relative overflow-hidden bg-surface-2 ${className}`}
|
|
79
|
+
style={{ aspectRatio: ratio ?? `${image.width}/${image.height}` }}
|
|
80
|
+
>
|
|
81
|
+
<div ref={inner} className="absolute inset-0" style={{ scale: parallax ? "1.12" : undefined }}>
|
|
82
|
+
<Image
|
|
83
|
+
src={image.src}
|
|
84
|
+
alt={alt ?? image.alt}
|
|
85
|
+
fill
|
|
86
|
+
sizes={sizes}
|
|
87
|
+
// eager + high fetch priority works on every Next.js major ("priority" is deprecated in 16).
|
|
88
|
+
loading={priority ? "eager" : "lazy"}
|
|
89
|
+
fetchPriority={priority ? "high" : "auto"}
|
|
90
|
+
unoptimized={image.unoptimized}
|
|
91
|
+
className="object-cover"
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
</figure>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/PinnedZoom.tsx (Struq recipe): SHOWPIECE
|
|
3
|
+
// The stage pins; a small framed photo opens to full bleed while the statement above it lifts
|
|
4
|
+
// away and a closing line arrives over the photo. One scrubbed timeline, so it plays with the
|
|
5
|
+
// scroll in both directions. Under reduced motion it is a still full-bleed photo with the text.
|
|
6
|
+
//
|
|
7
|
+
// <PinnedZoom
|
|
8
|
+
// image={images.band[0]}
|
|
9
|
+
// statement="Veertig plekken, één kaart."
|
|
10
|
+
// closing="Reserveer voor vanavond, of voor zaterdag."
|
|
11
|
+
// />
|
|
12
|
+
import Image from "next/image";
|
|
13
|
+
import { useRef } from "react";
|
|
14
|
+
import { gsap, useGSAP } from "@/lib/gsap";
|
|
15
|
+
import type { StruqImage } from "@/lib/struq-images";
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
image: StruqImage;
|
|
19
|
+
statement: string;
|
|
20
|
+
closing?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function PinnedZoom({ image, statement, closing, className = "" }: Props) {
|
|
25
|
+
const stage = useRef<HTMLElement>(null);
|
|
26
|
+
|
|
27
|
+
useGSAP(
|
|
28
|
+
() => {
|
|
29
|
+
const el = stage.current;
|
|
30
|
+
if (!el) return;
|
|
31
|
+
const q = gsap.utils.selector(el);
|
|
32
|
+
const mm = gsap.matchMedia();
|
|
33
|
+
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
34
|
+
gsap.set(q("[data-zoom-frame]"), { clipPath: "inset(22% 30% 22% 30%)" });
|
|
35
|
+
gsap.set(q("[data-zoom-closing]"), { autoAlpha: 0, yPercent: 40 });
|
|
36
|
+
const tl = gsap.timeline({
|
|
37
|
+
defaults: { ease: "none" },
|
|
38
|
+
scrollTrigger: { trigger: el, start: "top top", end: "+=160%", pin: true, scrub: 1 },
|
|
39
|
+
});
|
|
40
|
+
tl.to(q("[data-zoom-frame]"), { clipPath: "inset(0% 0% 0% 0%)", duration: 1 }, 0)
|
|
41
|
+
.fromTo(q("[data-zoom-photo]"), { scale: 1.35 }, { scale: 1, duration: 1 }, 0)
|
|
42
|
+
.to(q("[data-zoom-statement]"), { yPercent: -60, autoAlpha: 0, duration: 0.45 }, 0)
|
|
43
|
+
.to(q("[data-zoom-closing]"), { yPercent: 0, autoAlpha: 1, duration: 0.35 }, 0.62);
|
|
44
|
+
});
|
|
45
|
+
// Still version: the photo is full bleed from the start, so the statement turns light.
|
|
46
|
+
mm.add("(prefers-reduced-motion: reduce)", () => {
|
|
47
|
+
gsap.set(q("[data-zoom-statement]"), { color: "var(--on-photo)" });
|
|
48
|
+
});
|
|
49
|
+
return () => mm.revert();
|
|
50
|
+
},
|
|
51
|
+
{ scope: stage },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<section ref={stage} className={`relative h-svh overflow-hidden bg-surface-0 ${className}`}>
|
|
56
|
+
<div data-zoom-frame="" className="absolute inset-0 overflow-hidden">
|
|
57
|
+
<div data-zoom-photo="" className="absolute inset-0">
|
|
58
|
+
<Image src={image.src} alt={image.alt} fill sizes="100vw" unoptimized={image.unoptimized} className="object-cover" />
|
|
59
|
+
</div>
|
|
60
|
+
{/* A flat black scrim, so the closing line reads on any photo, on light and dark palettes. */}
|
|
61
|
+
<div aria-hidden className="absolute inset-0 bg-black opacity-35" />
|
|
62
|
+
</div>
|
|
63
|
+
<p
|
|
64
|
+
data-zoom-statement=""
|
|
65
|
+
className="font-display text-h2 absolute inset-x-6 top-[9vh] mx-auto max-w-[22ch] text-center text-ink md:inset-x-[8vw]"
|
|
66
|
+
>
|
|
67
|
+
{statement}
|
|
68
|
+
</p>
|
|
69
|
+
{closing ? (
|
|
70
|
+
<p
|
|
71
|
+
data-zoom-closing=""
|
|
72
|
+
className="font-display text-h2 absolute inset-x-6 bottom-[12vh] max-w-[20ch] text-on-photo md:left-[8vw]"
|
|
73
|
+
>
|
|
74
|
+
{closing}
|
|
75
|
+
</p>
|
|
76
|
+
) : null}
|
|
77
|
+
</section>
|
|
78
|
+
);
|
|
79
|
+
}
|