@struq/skill 0.2.2 → 0.4.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 +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- 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/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -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/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- 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 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- 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 +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- 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/imagery.md +0 -144
- 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/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 -341
- 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 -280
- 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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/Cursor.tsx (Struq recipe)
|
|
3
|
+
// A small dot that follows the pointer exactly and a ring that trails it, inverted against
|
|
4
|
+
// whatever is under it (mix-blend difference), so it reads on light and dark sections alike.
|
|
5
|
+
// The ring grows over links and buttons. Over anything marked data-cursor="Bekijk" (media, project
|
|
6
|
+
// cards) it becomes a round label in the accent with that word: a round cursor label, never a pill.
|
|
7
|
+
// Mounted once in the layout.
|
|
8
|
+
// It stays hidden until a real pointer moves, and rests out of sight when the pointer rests.
|
|
9
|
+
// Only on a fine pointer with motion allowed: touch screens and reduced motion keep the
|
|
10
|
+
// native cursor and render nothing.
|
|
11
|
+
//
|
|
12
|
+
// Leave it out for a genre where a custom cursor fights the job (a booking flow, a dense shop).
|
|
13
|
+
import { useRef } from "react";
|
|
14
|
+
import { gsap, useGSAP } from "./gsap";
|
|
15
|
+
|
|
16
|
+
export function Cursor() {
|
|
17
|
+
const root = useRef<HTMLDivElement>(null);
|
|
18
|
+
|
|
19
|
+
useGSAP(
|
|
20
|
+
() => {
|
|
21
|
+
const el = root.current;
|
|
22
|
+
if (!el) return;
|
|
23
|
+
const dot = el.querySelector<HTMLElement>("[data-dot]");
|
|
24
|
+
const ring = el.querySelector<HTMLElement>("[data-ring]");
|
|
25
|
+
const tag = el.querySelector<HTMLElement>("[data-tag]");
|
|
26
|
+
const tagText = el.querySelector<HTMLElement>("[data-tag-text]");
|
|
27
|
+
if (!dot || !ring || !tag || !tagText) return;
|
|
28
|
+
const mm = gsap.matchMedia();
|
|
29
|
+
mm.add("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)", () => {
|
|
30
|
+
const html = document.documentElement;
|
|
31
|
+
const dx = gsap.quickTo(dot, "x", { duration: 0.08, ease: "power2.out" });
|
|
32
|
+
const dy = gsap.quickTo(dot, "y", { duration: 0.08, ease: "power2.out" });
|
|
33
|
+
const rx = gsap.quickTo(ring, "x", { duration: 0.5, ease: "power3.out" });
|
|
34
|
+
const ry = gsap.quickTo(ring, "y", { duration: 0.5, ease: "power3.out" });
|
|
35
|
+
const tx = gsap.quickTo(tag, "x", { duration: 0.45, ease: "power3.out" });
|
|
36
|
+
const ty = gsap.quickTo(tag, "y", { duration: 0.45, ease: "power3.out" });
|
|
37
|
+
gsap.set(tag, { scale: 0 });
|
|
38
|
+
let labelled = false;
|
|
39
|
+
// Hidden until a real hand moves the pointer: a first event, a parked pointer or a
|
|
40
|
+
// scripted jump never shows it. It appears after 24px of actual travel, and it fades out
|
|
41
|
+
// again when the pointer rests, so a still pointer never leaves a ring mid-screen.
|
|
42
|
+
let shown = false;
|
|
43
|
+
let travel = 0;
|
|
44
|
+
let last: { x: number; y: number } | null = null;
|
|
45
|
+
let rest: ReturnType<typeof setTimeout> | undefined;
|
|
46
|
+
const hide = () => {
|
|
47
|
+
shown = false;
|
|
48
|
+
travel = 0;
|
|
49
|
+
html.classList.remove("has-cursor");
|
|
50
|
+
gsap.to([dot, ring], { autoAlpha: 0, duration: 0.4 });
|
|
51
|
+
gsap.to(tag, { scale: 0, autoAlpha: 0, duration: 0.3 });
|
|
52
|
+
};
|
|
53
|
+
const move = (e: PointerEvent) => {
|
|
54
|
+
if (e.pointerType && e.pointerType !== "mouse") return;
|
|
55
|
+
if (last) travel += Math.hypot(e.clientX - last.x, e.clientY - last.y);
|
|
56
|
+
last = { x: e.clientX, y: e.clientY };
|
|
57
|
+
dx(e.clientX);
|
|
58
|
+
dy(e.clientY);
|
|
59
|
+
rx(e.clientX);
|
|
60
|
+
ry(e.clientY);
|
|
61
|
+
tx(e.clientX);
|
|
62
|
+
ty(e.clientY);
|
|
63
|
+
if (!shown && travel > 24) {
|
|
64
|
+
shown = true;
|
|
65
|
+
html.classList.add("has-cursor");
|
|
66
|
+
gsap.to([dot, ring], { autoAlpha: labelled ? 0 : 1, duration: 0.3 });
|
|
67
|
+
if (labelled) gsap.to(tag, { scale: 1, autoAlpha: 1, duration: 0.5, ease: "expo.out" });
|
|
68
|
+
}
|
|
69
|
+
clearTimeout(rest);
|
|
70
|
+
rest = setTimeout(hide, 2200);
|
|
71
|
+
};
|
|
72
|
+
const over = (e: PointerEvent) => {
|
|
73
|
+
const target = e.target as Element | null;
|
|
74
|
+
const media = target?.closest<HTMLElement>("[data-cursor]");
|
|
75
|
+
const word = media?.dataset.cursor?.trim();
|
|
76
|
+
labelled = Boolean(word);
|
|
77
|
+
if (word) tagText.textContent = word;
|
|
78
|
+
if (shown) {
|
|
79
|
+
gsap.to(tag, { scale: labelled ? 1 : 0, autoAlpha: labelled ? 1 : 0, duration: labelled ? 0.5 : 0.3, ease: labelled ? "expo.out" : "power2.in" });
|
|
80
|
+
gsap.to([dot, ring], { autoAlpha: labelled ? 0 : 1, duration: 0.25 });
|
|
81
|
+
}
|
|
82
|
+
const hit = target?.closest("a, button");
|
|
83
|
+
gsap.to(ring, { scale: hit && !labelled ? 1.8 : 1, duration: 0.4, ease: "power3.out" });
|
|
84
|
+
};
|
|
85
|
+
const out = () => {
|
|
86
|
+
clearTimeout(rest);
|
|
87
|
+
last = null;
|
|
88
|
+
hide();
|
|
89
|
+
};
|
|
90
|
+
window.addEventListener("pointermove", move, { passive: true });
|
|
91
|
+
window.addEventListener("pointerover", over, { passive: true });
|
|
92
|
+
document.addEventListener("pointerleave", out);
|
|
93
|
+
return () => {
|
|
94
|
+
clearTimeout(rest);
|
|
95
|
+
html.classList.remove("has-cursor");
|
|
96
|
+
window.removeEventListener("pointermove", move);
|
|
97
|
+
window.removeEventListener("pointerover", over);
|
|
98
|
+
document.removeEventListener("pointerleave", out);
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
return () => mm.revert();
|
|
102
|
+
},
|
|
103
|
+
{ scope: root },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div ref={root} aria-hidden className="pointer-events-none fixed inset-0 z-[90]">
|
|
108
|
+
<div className="absolute inset-0 mix-blend-difference">
|
|
109
|
+
<div data-dot="" className="invisible absolute left-0 top-0 -ml-1 -mt-1 size-2 rounded-full bg-white opacity-0" />
|
|
110
|
+
<div data-ring="" className="invisible absolute left-0 top-0 -ml-5 -mt-5 size-10 rounded-full border border-white opacity-0" />
|
|
111
|
+
</div>
|
|
112
|
+
<div data-tag="" className="invisible absolute left-0 top-0 -ml-12 -mt-12 flex size-24 items-center justify-center rounded-full bg-accent text-small text-accent-ink opacity-0">
|
|
113
|
+
<span data-tag-text="">Bekijk</span>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -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 "./gsap";
|
|
31
|
+
import type { StruqImage } from "./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 "./gsap";
|
|
15
|
+
import type { StruqImage } from "./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 "./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
|
+
}
|