@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.
- package/README.md +15 -5
- package/package.json +4 -4
- 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
- /package/{bin → cli}/struq.mjs +0 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-project-stack (Struq showpiece recipe)
|
|
3
|
+
// The work, one project per screen. The stage pins, each next project slides up over the last
|
|
4
|
+
// one full bleed, while the one beneath recedes (scale and a darker veil), so the stack reads as
|
|
5
|
+
// layered rather than cut. Over the photos the cursor turns into a round "Bekijk" label
|
|
6
|
+
// (data-cursor="Bekijk"; the Cursor recipe renders it). Names are set large, the meta small.
|
|
7
|
+
// Lead for an architect or a studio portfolio.
|
|
8
|
+
//
|
|
9
|
+
// <ProjectStack items={[{ image: images.hero[0], title: "De Spinnerij", meta: "Transformatie, Charlois", href: "/werk/spinnerij" }]} />
|
|
10
|
+
//
|
|
11
|
+
// Only real projects from the brief, with the client's own photos. Beeldbank photos never stand in
|
|
12
|
+
// for a client's project: without project photos, use a different showpiece.
|
|
13
|
+
// Reduced motion and phones: the composed still, the projects one under the other.
|
|
14
|
+
import Image from "next/image";
|
|
15
|
+
import Link from "next/link";
|
|
16
|
+
import { useRef } from "react";
|
|
17
|
+
import { gsap, useGSAP } from "@/lib/gsap";
|
|
18
|
+
import type { StruqImage } from "@/lib/struq-images";
|
|
19
|
+
|
|
20
|
+
type Item = { image: StruqImage; title: string; meta?: string; href?: string };
|
|
21
|
+
|
|
22
|
+
export function ProjectStack({ items, label = "Bekijk", className = "" }: { items: Item[]; label?: string; className?: string }) {
|
|
23
|
+
const stage = useRef<HTMLElement>(null);
|
|
24
|
+
|
|
25
|
+
useGSAP(
|
|
26
|
+
() => {
|
|
27
|
+
const el = stage.current;
|
|
28
|
+
if (!el) return;
|
|
29
|
+
const cards = gsap.utils.toArray<HTMLElement>("[data-card]", el);
|
|
30
|
+
if (cards.length < 2) return;
|
|
31
|
+
const mm = gsap.matchMedia();
|
|
32
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
33
|
+
gsap.set(cards.slice(1), { yPercent: 100 });
|
|
34
|
+
const tl = gsap.timeline({
|
|
35
|
+
defaults: { ease: "none" },
|
|
36
|
+
scrollTrigger: { trigger: el, start: "top top", end: () => `+=${(cards.length - 1) * 110}%`, pin: true, scrub: 1 },
|
|
37
|
+
});
|
|
38
|
+
cards.slice(1).forEach((card, i) => {
|
|
39
|
+
const below = cards[i];
|
|
40
|
+
tl.to({}, { duration: 0.25 }) // hold on each project
|
|
41
|
+
.to(card, { yPercent: 0, duration: 1, ease: "power2.inOut" })
|
|
42
|
+
.to(below, { scale: 0.9, duration: 1, ease: "power2.inOut" }, "<")
|
|
43
|
+
.to(below.querySelector("[data-veil]"), { opacity: 0.55, duration: 1 }, "<")
|
|
44
|
+
.fromTo(card.querySelector("[data-card-photo]"), { scale: 1.2 }, { scale: 1, duration: 1.2, ease: "power2.out" }, "<");
|
|
45
|
+
});
|
|
46
|
+
tl.to({}, { duration: 0.25 });
|
|
47
|
+
});
|
|
48
|
+
return () => mm.revert();
|
|
49
|
+
},
|
|
50
|
+
{ scope: stage },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<section
|
|
55
|
+
ref={stage}
|
|
56
|
+
data-showpiece="project-stack"
|
|
57
|
+
className={`relative flex flex-col gap-6 bg-ink px-6 py-24 motion-safe:md:block motion-safe:md:h-svh motion-safe:md:overflow-hidden motion-safe:md:p-0 ${className}`}
|
|
58
|
+
>
|
|
59
|
+
{items.map((item, i) => {
|
|
60
|
+
const body = (
|
|
61
|
+
<>
|
|
62
|
+
<div data-card-photo="" className="absolute inset-0">
|
|
63
|
+
<Image src={item.image.src} alt={item.image.alt} fill sizes="100vw" unoptimized={item.image.unoptimized} className="object-cover" />
|
|
64
|
+
</div>
|
|
65
|
+
<div aria-hidden className="absolute inset-0 bg-black opacity-25" />
|
|
66
|
+
<div data-veil="" aria-hidden className="absolute inset-0 bg-black opacity-0" />
|
|
67
|
+
<div className="absolute inset-x-6 bottom-8 flex flex-col gap-2 text-on-photo md:inset-x-[5vw] md:bottom-[8vh] md:flex-row md:items-end md:justify-between">
|
|
68
|
+
<span className="font-display block text-[12vw] leading-[0.9] tracking-[-0.03em] md:text-[9vw]">{item.title}</span>
|
|
69
|
+
{item.meta ? <span className="text-small md:pb-[1.2vw]">{item.meta}</span> : null}
|
|
70
|
+
</div>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
const cls = "relative block aspect-[4/5] overflow-hidden motion-safe:md:absolute motion-safe:md:inset-0 motion-safe:md:aspect-auto";
|
|
74
|
+
return item.href ? (
|
|
75
|
+
<Link key={item.title} href={item.href} data-card="" data-cursor={label} className={cls} style={{ zIndex: i + 1 }}>
|
|
76
|
+
{body}
|
|
77
|
+
</Link>
|
|
78
|
+
) : (
|
|
79
|
+
<div key={item.title} data-card="" data-cursor={label} className={cls} style={{ zIndex: i + 1 }}>
|
|
80
|
+
{body}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</section>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-scrub-connector (Struq showpiece recipe)
|
|
3
|
+
// Two words on either side of an empty ground and one bar of the accent between them. As you
|
|
4
|
+
// scroll the stage pins and the bar rotates, stretches and travels: it swings up from under the
|
|
5
|
+
// first word, crosses the screen on a steep diagonal, turns the other way, and lands as a level
|
|
6
|
+
// line joining the two words. Each word carries one short line of copy under it.
|
|
7
|
+
// Lead for an agency or a creative brand (or toonstuk-fill-rows instead).
|
|
8
|
+
//
|
|
9
|
+
// <ScrubConnector left="Kijken" right="Maken" leftText="Elk project begint met wat er al staat." rightText="Daarna tekenen we pas." />
|
|
10
|
+
//
|
|
11
|
+
// Reduced motion and phones: the composed still, the two words joined by the level bar.
|
|
12
|
+
import { useRef } from "react";
|
|
13
|
+
import { gsap, useGSAP } from "@/lib/gsap";
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
left: string;
|
|
17
|
+
right: string;
|
|
18
|
+
leftText?: string;
|
|
19
|
+
rightText?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function ScrubConnector({ left, right, leftText, rightText, className = "" }: Props) {
|
|
24
|
+
const stage = useRef<HTMLElement>(null);
|
|
25
|
+
|
|
26
|
+
useGSAP(
|
|
27
|
+
() => {
|
|
28
|
+
const el = stage.current;
|
|
29
|
+
if (!el) return;
|
|
30
|
+
const q = gsap.utils.selector(el);
|
|
31
|
+
const mm = gsap.matchMedia();
|
|
32
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
33
|
+
const bar = q("[data-bar]");
|
|
34
|
+
gsap.set(bar, { rotate: -62, scaleX: 0.18, xPercent: -95, yPercent: 900 });
|
|
35
|
+
gsap.set(q("[data-word-text]"), { autoAlpha: 0, y: 24 });
|
|
36
|
+
const tl = gsap.timeline({
|
|
37
|
+
defaults: { ease: "none" },
|
|
38
|
+
scrollTrigger: { trigger: el, start: "top top", end: "+=280%", pin: true, scrub: 1.2 },
|
|
39
|
+
});
|
|
40
|
+
tl.to(bar, { rotate: -38, scaleX: 0.9, xPercent: -10, yPercent: 0, duration: 1, ease: "power2.inOut" })
|
|
41
|
+
.to({}, { duration: 0.2 })
|
|
42
|
+
.to(bar, { rotate: 41, scaleX: 1.05, xPercent: 4, duration: 1, ease: "power2.inOut" })
|
|
43
|
+
.to({}, { duration: 0.2 })
|
|
44
|
+
.to(bar, { rotate: 0, scaleX: 1, xPercent: 0, yPercent: 0, duration: 1, ease: "power3.inOut" })
|
|
45
|
+
.to(q("[data-word-text]"), { autoAlpha: 1, y: 0, stagger: 0.15, duration: 0.4, ease: "expo.out" }, "-=0.4")
|
|
46
|
+
.to({}, { duration: 0.4 });
|
|
47
|
+
});
|
|
48
|
+
return () => mm.revert();
|
|
49
|
+
},
|
|
50
|
+
{ scope: stage },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// One fluid size, no breakpoint ladder and no px: the words scale with clamp(), and the bar's
|
|
54
|
+
// thickness and its line-up with the words follow the same size, so they stay in step at any width.
|
|
55
|
+
const word = "font-display block text-[length:var(--connector-word)] leading-[0.9] tracking-[-0.03em]";
|
|
56
|
+
return (
|
|
57
|
+
<section
|
|
58
|
+
ref={stage}
|
|
59
|
+
data-showpiece="scrub-connector"
|
|
60
|
+
style={{ "--connector-word": "clamp(2.75rem, 1.2rem + 7vw, 9.5rem)" } as React.CSSProperties}
|
|
61
|
+
className={`relative flex items-center overflow-hidden bg-surface-0 px-6 py-24 md:h-svh md:px-[5vw] md:py-0 ${className}`}
|
|
62
|
+
>
|
|
63
|
+
<div className="relative grid w-full grid-cols-[auto_1fr_auto] items-start gap-4">
|
|
64
|
+
<div className="flex flex-col gap-4">
|
|
65
|
+
<span className={`${word} text-accent`}>{left}</span>
|
|
66
|
+
{leftText ? <p data-word-text="" className="max-w-[24ch] text-small text-ink-muted">{leftText}</p> : null}
|
|
67
|
+
</div>
|
|
68
|
+
<div className="relative mt-[calc(var(--connector-word)*0.42)] h-[calc(var(--connector-word)*0.16)] min-h-2">
|
|
69
|
+
<div data-bar="" aria-hidden className="absolute inset-0 origin-center bg-ink" />
|
|
70
|
+
</div>
|
|
71
|
+
<div className="flex flex-col items-end gap-4 text-right">
|
|
72
|
+
<span className={`${word} text-accent`}>{right}</span>
|
|
73
|
+
{rightText ? <p data-word-text="" className="max-w-[24ch] text-small text-ink-muted">{rightText}</p> : null}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-wordmark-finale (Struq showpiece recipe)
|
|
3
|
+
// The footer as a finale: the brand name set edge to edge at the full width of the page, its
|
|
4
|
+
// letters rising out of a mask as the footer arrives, with one closing line and a live local clock
|
|
5
|
+
// (or just the line) above it. The name is measured and fitted to the width, so it never wraps or
|
|
6
|
+
// falls short, whatever the face.
|
|
7
|
+
// Every site can close with it; it pairs with any lead showpiece.
|
|
8
|
+
//
|
|
9
|
+
// <WordmarkFinale name="Zout" line="Tot vanavond." place="Amsterdam" timeZone="Europe/Amsterdam" />
|
|
10
|
+
//
|
|
11
|
+
// Reduced motion and phones: the name stands still at full width; the clock still ticks.
|
|
12
|
+
import { useEffect, useRef, useState } from "react";
|
|
13
|
+
import { gsap, SplitText, useGSAP } from "@/lib/gsap";
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
name: string;
|
|
17
|
+
/** One closing line. */
|
|
18
|
+
line?: string;
|
|
19
|
+
/** With a time zone, a live local clock: "Amsterdam 21:42". */
|
|
20
|
+
place?: string;
|
|
21
|
+
timeZone?: string;
|
|
22
|
+
children?: React.ReactNode;
|
|
23
|
+
className?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function useClock(timeZone?: string) {
|
|
27
|
+
const [now, setNow] = useState<string | null>(null);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!timeZone) return;
|
|
30
|
+
const format = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone });
|
|
31
|
+
const tick = () => setNow(format.format(new Date()));
|
|
32
|
+
tick();
|
|
33
|
+
const id = window.setInterval(tick, 1000);
|
|
34
|
+
return () => window.clearInterval(id);
|
|
35
|
+
}, [timeZone]);
|
|
36
|
+
return now;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function WordmarkFinale({ name, line, place, timeZone, children, className = "" }: Props) {
|
|
40
|
+
const root = useRef<HTMLElement>(null);
|
|
41
|
+
const mark = useRef<HTMLParagraphElement>(null);
|
|
42
|
+
const now = useClock(timeZone);
|
|
43
|
+
|
|
44
|
+
// Fit the name to the full width: measure once at 100px, then scale.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const el = mark.current;
|
|
47
|
+
const box = root.current;
|
|
48
|
+
if (!el || !box) return;
|
|
49
|
+
const fit = () => {
|
|
50
|
+
el.style.fontSize = "100px";
|
|
51
|
+
const width = el.scrollWidth || 1;
|
|
52
|
+
const style = getComputedStyle(box);
|
|
53
|
+
const avail = box.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
54
|
+
el.style.fontSize = `${Math.floor((100 * avail) / width)}px`;
|
|
55
|
+
};
|
|
56
|
+
fit();
|
|
57
|
+
document.fonts?.ready.then(fit);
|
|
58
|
+
const ro = new ResizeObserver(fit);
|
|
59
|
+
ro.observe(box);
|
|
60
|
+
return () => ro.disconnect();
|
|
61
|
+
}, [name]);
|
|
62
|
+
|
|
63
|
+
useGSAP(
|
|
64
|
+
() => {
|
|
65
|
+
const el = mark.current;
|
|
66
|
+
if (!el) return;
|
|
67
|
+
const mm = gsap.matchMedia();
|
|
68
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
69
|
+
const split = SplitText.create(el, {
|
|
70
|
+
type: "chars",
|
|
71
|
+
mask: "chars",
|
|
72
|
+
onSplit(self) {
|
|
73
|
+
return gsap.from(self.chars, {
|
|
74
|
+
yPercent: 105,
|
|
75
|
+
duration: 1.4,
|
|
76
|
+
ease: "expo.out",
|
|
77
|
+
stagger: 0.05,
|
|
78
|
+
scrollTrigger: { trigger: el, start: "top 95%", once: true },
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
return () => split.revert();
|
|
83
|
+
});
|
|
84
|
+
return () => mm.revert();
|
|
85
|
+
},
|
|
86
|
+
{ scope: root },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<footer ref={root} data-showpiece="wordmark-finale" className={`overflow-hidden bg-surface-0 px-6 pb-6 pt-24 md:px-[3vw] ${className}`}>
|
|
91
|
+
<div className="flex flex-col gap-6 border-t border-line pt-6 md:flex-row md:items-end md:justify-between">
|
|
92
|
+
{line ? <p className="font-display text-h3 max-w-[24ch]">{line}</p> : <span />}
|
|
93
|
+
{children}
|
|
94
|
+
{place && timeZone ? (
|
|
95
|
+
<p className="text-small tabular-nums text-ink-muted">
|
|
96
|
+
{place} {now ?? ""}
|
|
97
|
+
</p>
|
|
98
|
+
) : null}
|
|
99
|
+
</div>
|
|
100
|
+
<p ref={mark} data-wordmark="" aria-label={name} className="font-display mt-10 inline-block whitespace-nowrap leading-[0.8] tracking-[-0.04em]">
|
|
101
|
+
{name}
|
|
102
|
+
</p>
|
|
103
|
+
</footer>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// app/fonts.ts (Struq recipe)
|
|
2
|
+
// Exactly two faces: a display face and a text face. Swap these two imports for the pairing you
|
|
3
|
+
// picked in SKILL.md ("Pick the two faces"); keep the variable names --face-display and --face-body,
|
|
4
|
+
// globals.css maps them to font-display and font-body.
|
|
5
|
+
// Default: S2, Fraunces (soft serif display) over Urbanist (round geometric text).
|
|
6
|
+
import { Fraunces, Urbanist } from "next/font/google";
|
|
7
|
+
|
|
8
|
+
export const display = Fraunces({
|
|
9
|
+
subsets: ["latin"],
|
|
10
|
+
axes: ["SOFT", "opsz"],
|
|
11
|
+
variable: "--face-display",
|
|
12
|
+
display: "swap",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const body = Urbanist({
|
|
16
|
+
subsets: ["latin"],
|
|
17
|
+
variable: "--face-body",
|
|
18
|
+
display: "swap",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** Every face's CSS variable, for the <html> className in app/layout.tsx. */
|
|
22
|
+
export const fontVariables = [display, body].map((f) => f.variable).join(" ");
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* ==========================================================================
|
|
4
|
+
STRUQ TOKENS. Set the values from the palette you chose in SKILL.md.
|
|
5
|
+
Keep the names: every recipe and the hero canvas read them.
|
|
6
|
+
========================================================================== */
|
|
7
|
+
:root {
|
|
8
|
+
--surface-0: #f5efe6; /* the page */
|
|
9
|
+
--surface-1: #ede4d6; /* one step: panels, the showpiece ground */
|
|
10
|
+
--surface-2: #e1d4bf; /* two steps: image wells, hover grounds */
|
|
11
|
+
--surface-3: #cdbb9f; /* three steps: hairline accents, never text */
|
|
12
|
+
--ink: #1e1a15; /* headlines and body */
|
|
13
|
+
--ink-muted: #5d5446; /* secondary text, at least 4.5:1 on surface-0 */
|
|
14
|
+
--accent: #8a3b22; /* the one accent: the key CTA, one highlight per screen */
|
|
15
|
+
--accent-ink: #fbf6ee; /* text on the accent */
|
|
16
|
+
--line: rgb(30 26 21 / 0.12); /* hairlines: the ink at low alpha, never a grey hex */
|
|
17
|
+
--on-photo: #f7f3ec; /* type set over a photo or the ripple hero, on light and dark presets alike */
|
|
18
|
+
--inverse: #1e1a15; /* the contrast scene's ground: the palette's inverse role, else the ink */
|
|
19
|
+
--on-inverse: #f5efe6; /* type on it: else the page ground */
|
|
20
|
+
--field: #8a3b22; /* a full-strength colour field: the palette's field role, else the accent */
|
|
21
|
+
--on-field: #fbf6ee; /* type on it */
|
|
22
|
+
--display-weight: 300; /* light display: 300, or 400 for a face without a 300 */
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@theme inline {
|
|
26
|
+
--color-surface-0: var(--surface-0);
|
|
27
|
+
--color-surface-1: var(--surface-1);
|
|
28
|
+
--color-surface-2: var(--surface-2);
|
|
29
|
+
--color-surface-3: var(--surface-3);
|
|
30
|
+
--color-ink: var(--ink);
|
|
31
|
+
--color-ink-muted: var(--ink-muted);
|
|
32
|
+
--color-accent: var(--accent);
|
|
33
|
+
--color-accent-ink: var(--accent-ink);
|
|
34
|
+
--color-line: var(--line);
|
|
35
|
+
--color-on-photo: var(--on-photo);
|
|
36
|
+
--color-inverse: var(--inverse);
|
|
37
|
+
--color-on-inverse: var(--on-inverse);
|
|
38
|
+
--color-field: var(--field);
|
|
39
|
+
--color-on-field: var(--on-field);
|
|
40
|
+
--font-display: var(--face-display), ui-serif, Georgia, serif;
|
|
41
|
+
--font-body: var(--face-body), ui-sans-serif, system-ui, sans-serif;
|
|
42
|
+
--font-accent: var(--face-accent, var(--face-display)), ui-serif, Georgia, serif;
|
|
43
|
+
--font-sans: var(--face-body), ui-sans-serif, system-ui, sans-serif;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@theme {
|
|
47
|
+
/* Type roles: write text-display, text-h2, text-h3, text-lead, text-body, text-small. Never text-xl or text-5xl. */
|
|
48
|
+
--text-display: clamp(2.875rem, 1.2rem + 6.86vw, 8rem);
|
|
49
|
+
--text-display--line-height: 0.95;
|
|
50
|
+
--text-display--letter-spacing: -0.025em;
|
|
51
|
+
--text-h2: clamp(2.25rem, 1.3rem + 3.4vw, 4.75rem);
|
|
52
|
+
--text-h2--line-height: 1.04;
|
|
53
|
+
--text-h2--letter-spacing: -0.015em;
|
|
54
|
+
--text-h3: clamp(1.375rem, 1.15rem + 0.8vw, 1.875rem);
|
|
55
|
+
--text-h3--line-height: 1.2;
|
|
56
|
+
--text-lead: clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem);
|
|
57
|
+
--text-lead--line-height: 1.5;
|
|
58
|
+
--text-body: 1.0625rem;
|
|
59
|
+
--text-body--line-height: 1.65;
|
|
60
|
+
--text-small: 0.875rem;
|
|
61
|
+
--text-small--line-height: 1.5;
|
|
62
|
+
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
63
|
+
--ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ==========================================================================
|
|
67
|
+
THE CONTRAST SCENE. Every site has one, at most two: wrap a section in scene-inverse (the
|
|
68
|
+
palette's dark ground with light type) or scene-field (a full-strength colour field). The tokens
|
|
69
|
+
are remapped inside, so every recipe in it turns with it.
|
|
70
|
+
========================================================================== */
|
|
71
|
+
.scene-inverse {
|
|
72
|
+
--surface-0: var(--inverse);
|
|
73
|
+
--surface-1: color-mix(in oklch, var(--inverse), var(--on-inverse) 6%);
|
|
74
|
+
--surface-2: color-mix(in oklch, var(--inverse), var(--on-inverse) 12%);
|
|
75
|
+
--surface-3: color-mix(in oklch, var(--inverse), var(--on-inverse) 22%);
|
|
76
|
+
--ink: var(--on-inverse);
|
|
77
|
+
--ink-muted: color-mix(in oklch, var(--on-inverse) 72%, var(--inverse));
|
|
78
|
+
--line: color-mix(in oklch, var(--on-inverse) 18%, transparent);
|
|
79
|
+
background-color: var(--surface-0);
|
|
80
|
+
color: var(--ink);
|
|
81
|
+
}
|
|
82
|
+
.scene-field {
|
|
83
|
+
--surface-0: var(--field);
|
|
84
|
+
--surface-1: color-mix(in oklch, var(--field), var(--on-field) 8%);
|
|
85
|
+
--surface-2: color-mix(in oklch, var(--field), var(--on-field) 14%);
|
|
86
|
+
--surface-3: color-mix(in oklch, var(--field), var(--on-field) 24%);
|
|
87
|
+
--ink: var(--on-field);
|
|
88
|
+
--ink-muted: color-mix(in oklch, var(--on-field) 78%, var(--field));
|
|
89
|
+
--line: color-mix(in oklch, var(--on-field) 22%, transparent);
|
|
90
|
+
--accent: var(--on-field);
|
|
91
|
+
--accent-ink: var(--field);
|
|
92
|
+
background-color: var(--surface-0);
|
|
93
|
+
color: var(--ink);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@layer base {
|
|
97
|
+
/* The hero opener's handover: the section after it slides up over the pinned opener. */
|
|
98
|
+
[data-showpiece="hero-opener"] + *,
|
|
99
|
+
.pin-spacer:has(> [data-showpiece="hero-opener"]) + * {
|
|
100
|
+
position: relative;
|
|
101
|
+
z-index: 2;
|
|
102
|
+
background-color: var(--surface-0);
|
|
103
|
+
}
|
|
104
|
+
/* The one scaling knob: 16px at 390, about 17.6px at 1440, 18px from about 1640. */
|
|
105
|
+
html {
|
|
106
|
+
font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem);
|
|
107
|
+
background: var(--surface-0);
|
|
108
|
+
color: var(--ink);
|
|
109
|
+
}
|
|
110
|
+
body {
|
|
111
|
+
font-family: var(--font-body);
|
|
112
|
+
font-size: var(--text-body);
|
|
113
|
+
line-height: 1.65;
|
|
114
|
+
-webkit-font-smoothing: antialiased;
|
|
115
|
+
text-rendering: optimizeLegibility;
|
|
116
|
+
}
|
|
117
|
+
h1, h2, h3, .font-display {
|
|
118
|
+
font-family: var(--font-display);
|
|
119
|
+
font-weight: var(--display-weight);
|
|
120
|
+
text-wrap: balance;
|
|
121
|
+
}
|
|
122
|
+
p {
|
|
123
|
+
text-wrap: pretty;
|
|
124
|
+
}
|
|
125
|
+
::selection {
|
|
126
|
+
background: var(--accent);
|
|
127
|
+
color: var(--accent-ink);
|
|
128
|
+
}
|
|
129
|
+
:focus-visible {
|
|
130
|
+
outline: 2px solid var(--accent);
|
|
131
|
+
outline-offset: 3px;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ==========================================================================
|
|
136
|
+
STRUQ MOTION GATE. Do not edit.
|
|
137
|
+
With JavaScript on (the `js` class the layout sets before paint) the animated elements start
|
|
138
|
+
hidden, so nothing flashes before its entrance. Each recipe marks itself data-ready the moment
|
|
139
|
+
it has set its own start state. If the scripts never run, the failsafe shows everything after
|
|
140
|
+
3s, so no region can stay empty.
|
|
141
|
+
========================================================================== */
|
|
142
|
+
.js [data-split]:not([data-ready]) {
|
|
143
|
+
visibility: hidden;
|
|
144
|
+
animation: struq-show 0s 3s forwards;
|
|
145
|
+
}
|
|
146
|
+
.js [data-reveal]:not([data-ready]) > * {
|
|
147
|
+
opacity: 0;
|
|
148
|
+
animation: struq-fade 0s 3s forwards;
|
|
149
|
+
}
|
|
150
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
151
|
+
.js [data-frame="mask"]:not([data-ready]) {
|
|
152
|
+
clip-path: inset(100% 0 0 0);
|
|
153
|
+
animation: struq-open 0s 3s forwards;
|
|
154
|
+
}
|
|
155
|
+
.js [data-zoom-frame] {
|
|
156
|
+
clip-path: inset(22% 30% 22% 30%);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/* The cinematic showpieces start in their first key frame, so nothing flashes before it. */
|
|
160
|
+
@media (min-width: 768px) and (prefers-reduced-motion: no-preference) {
|
|
161
|
+
.js [data-showpiece="image-to-fullbleed"] [data-frame] {
|
|
162
|
+
clip-path: inset(31% 40% 31% 40%);
|
|
163
|
+
}
|
|
164
|
+
.js [data-showpiece="pixel-resolve"] canvas {
|
|
165
|
+
opacity: 1;
|
|
166
|
+
background: var(--surface-2);
|
|
167
|
+
animation: struq-unveil 0s 4s forwards;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
@keyframes struq-unveil { to { opacity: 0; visibility: hidden; } }
|
|
171
|
+
@keyframes struq-show { to { visibility: visible; } }
|
|
172
|
+
@keyframes struq-fade { to { opacity: 1; } }
|
|
173
|
+
@keyframes struq-open { to { clip-path: inset(0 0 0 0); } }
|
|
174
|
+
|
|
175
|
+
/* SplitText masks: room for descenders (g, j, p, y) inside each line's clip. */
|
|
176
|
+
.struq-line-mask {
|
|
177
|
+
padding-block: 0.06em 0.16em;
|
|
178
|
+
margin-block: -0.06em -0.16em;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* The custom cursor hides the native one only while it is active. */
|
|
182
|
+
html.has-cursor,
|
|
183
|
+
html.has-cursor * {
|
|
184
|
+
cursor: none;
|
|
185
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// app/layout.tsx (Struq recipe)
|
|
2
|
+
// Replace SITE_TITLE and SITE_DESCRIPTION, and set lang to the site's language.
|
|
3
|
+
// The inline script adds the `js` class before first paint: the motion gate in globals.css keys
|
|
4
|
+
// off it, so animated elements never flash before their entrance and stay visible without JS.
|
|
5
|
+
import type { Metadata } from "next";
|
|
6
|
+
import { Cursor } from "@/components/struq/Cursor";
|
|
7
|
+
import { SmoothScroll } from "@/components/struq/SmoothScroll";
|
|
8
|
+
import { fontVariables } from "./fonts";
|
|
9
|
+
import "./globals.css";
|
|
10
|
+
|
|
11
|
+
export const metadata: Metadata = {
|
|
12
|
+
title: "SITE_TITLE",
|
|
13
|
+
description: "SITE_DESCRIPTION",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
17
|
+
return (
|
|
18
|
+
<html lang="nl" className={fontVariables} suppressHydrationWarning>
|
|
19
|
+
<head>
|
|
20
|
+
<script dangerouslySetInnerHTML={{ __html: "document.documentElement.classList.add('js')" }} />
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<SmoothScroll>{children}</SmoothScroll>
|
|
24
|
+
<Cursor />
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -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 "@/lib/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
|
+
}
|