@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.
Files changed (59) hide show
  1. package/README.md +9 -7
  2. package/package.json +2 -2
  3. package/skills/struq/SKILL.md +304 -200
  4. package/skills/struq/cinematic/showpieces/README.md +18 -0
  5. package/skills/struq/cinematic/showpieces/toonstuk-fill-rows.tsx +74 -0
  6. package/skills/struq/cinematic/showpieces/toonstuk-hero-opener.tsx +128 -0
  7. package/skills/struq/cinematic/showpieces/toonstuk-image-to-fullbleed.tsx +78 -0
  8. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +81 -0
  9. package/skills/struq/cinematic/showpieces/toonstuk-panel-wipe.tsx +89 -0
  10. package/skills/struq/cinematic/showpieces/toonstuk-pixel-resolve.tsx +107 -0
  11. package/skills/struq/cinematic/showpieces/toonstuk-project-stack.tsx +86 -0
  12. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +78 -0
  13. package/skills/struq/cinematic/showpieces/toonstuk-wordmark-finale.tsx +105 -0
  14. package/skills/struq/recipes/app/fonts.ts +22 -0
  15. package/skills/struq/recipes/app/globals.css +185 -0
  16. package/skills/struq/recipes/app/layout.tsx +28 -0
  17. package/skills/struq/recipes/components/struq/Cursor.tsx +117 -0
  18. package/skills/struq/recipes/components/struq/HeroCanvas.tsx +278 -0
  19. package/skills/struq/recipes/components/struq/HorizontalGallery.tsx +106 -0
  20. package/skills/struq/recipes/components/struq/MagneticButton.tsx +92 -0
  21. package/skills/struq/recipes/components/struq/MediaFrame.tsx +96 -0
  22. package/skills/struq/recipes/components/struq/PinnedZoom.tsx +79 -0
  23. package/skills/struq/recipes/components/struq/Reveal.tsx +64 -0
  24. package/skills/struq/recipes/components/struq/SmoothScroll.tsx +57 -0
  25. package/skills/struq/recipes/components/struq/SplitHeadline.tsx +68 -0
  26. package/skills/struq/recipes/components/struq/scenes/contours.ts +161 -0
  27. package/skills/struq/recipes/components/struq/scenes/dots.ts +110 -0
  28. package/skills/struq/recipes/components/struq/scenes/ripple.ts +99 -0
  29. package/skills/struq/recipes/components/struq/scenes/types.ts +114 -0
  30. package/skills/struq/recipes/lib/gsap.ts +21 -0
  31. package/skills/struq/references/anti-patterns.md +71 -6
  32. package/skills/struq/references/baseline-prompt.md +18 -14
  33. package/skills/struq/references/brief-router.md +7 -8
  34. package/skills/struq/references/composition.md +0 -1
  35. package/skills/struq/references/depth-parallax.md +1 -1
  36. package/skills/struq/references/direction.md +5 -5
  37. package/skills/struq/references/hero-scenes.md +107 -0
  38. package/skills/struq/references/iconography.md +1 -1
  39. package/skills/struq/references/imagery.md +10 -29
  40. package/skills/struq/references/micro-interactions.md +1 -1
  41. package/skills/struq/references/mobile-composition.md +1 -1
  42. package/skills/struq/references/niche-packets.md +338 -0
  43. package/skills/struq/references/page-architecture.md +2 -2
  44. package/skills/struq/references/pinning-scrub.md +3 -4
  45. package/skills/struq/references/scroll-reveals.md +1 -2
  46. package/skills/struq/references/sections.md +141 -0
  47. package/skills/struq/references/seo.md +2 -2
  48. package/skills/struq/references/showcase.md +2 -2
  49. package/skills/struq/references/svg-motion.md +390 -390
  50. package/skills/struq/references/typography.md +39 -39
  51. package/skills/struq/references/vibe-presets.md +5 -5
  52. package/skills/struq/references/webgl-scenes.md +593 -593
  53. package/skills/struq/scripts/canon.mjs +309 -0
  54. package/skills/struq/scripts/check.mjs +547 -0
  55. package/skills/struq/scripts/font-names.mjs +40 -0
  56. package/skills/struq/scripts/images.mjs +360 -0
  57. package/skills/struq/scripts/log.mjs +92 -0
  58. package/skills/struq/scripts/packs.mjs +156 -0
  59. package/skills/struq/scripts/recipes.mjs +135 -0
@@ -0,0 +1,18 @@
1
+ # Showpieces (toonstuk)
2
+
3
+ The award-tier scenes the Build checklist picks from (SKILL.md, Step 8). One file per showpiece, named by its stable slug, the same slug the Struq MCP and the website library use. `scripts/recipes.mjs` copies them to `components/struq/showpieces/`.
4
+
5
+ | Slug | Component | Lead for |
6
+ |---|---|---|
7
+ | `toonstuk-image-to-fullbleed` | `ImageToFullbleed` | product launch, a portfolio without project photos, anything else |
8
+ | `toonstuk-panel-wipe` | `PanelWipe` | restaurant, hospitality, hotel |
9
+ | `toonstuk-scrub-connector` | `ScrubConnector` | agency, creative brand |
10
+ | `toonstuk-project-stack` | `ProjectStack` | architect or studio with real project photos |
11
+ | `toonstuk-manifesto-scrub` | `ManifestoScrub` | editorial; the supporting move for hospitality |
12
+ | `toonstuk-fill-rows` | `FillRows` | the supporting move for an agency |
13
+ | `toonstuk-wordmark-finale` | `WordmarkFinale` | every site's footer |
14
+ | `toonstuk-pixel-resolve` | `PixelResolve` | the supporting move for a product launch hero |
15
+
16
+ Every recipe runs on the one clock (GSAP ScrollTrigger on Lenis), pins with `scrub` and never `anticipatePin`, holds on its key frames, and has a composed still for phones and reduced motion. The first key frame of the clip-based ones is set in `globals.css` (the motion gate), so nothing flashes before it.
17
+
18
+ Learned from Roy's award-tier references (struq-assets/references/award-tier-2026-11); no layout, copy or image of a reference is in these files.
@@ -0,0 +1,74 @@
1
+ "use client";
2
+ // toonstuk-fill-rows (Struq showpiece recipe)
3
+ // A list of services or chapters set as large hairline rows. On hover and on keyboard focus a row
4
+ // floods with the accent from the bottom edge, its title turns to the accent's ink and nudges in,
5
+ // and its detail slides in beside it. The rows enter with a staggered rise as the list arrives.
6
+ // Lead for an agency or a creative brand (or toonstuk-scrub-connector instead).
7
+ //
8
+ // <FillRows rows={[{ title: "Identiteit", text: "Een merk dat zich in een oogopslag laat herkennen.", href: "#identiteit" }]} />
9
+ //
10
+ // Reduced motion: the fill changes without travel. Phones: the detail sits under each title.
11
+ import Link from "next/link";
12
+ import { useRef } from "react";
13
+ import { gsap, useGSAP } from "@/lib/gsap";
14
+
15
+ type Row = { title: string; text: string; href?: string };
16
+
17
+ export function FillRows({ rows, className = "" }: { rows: Row[]; className?: string }) {
18
+ const list = useRef<HTMLUListElement>(null);
19
+
20
+ useGSAP(
21
+ () => {
22
+ const el = list.current;
23
+ if (!el) return;
24
+ const mm = gsap.matchMedia();
25
+ mm.add("(prefers-reduced-motion: no-preference)", () => {
26
+ gsap.from(el.children, {
27
+ yPercent: 40,
28
+ autoAlpha: 0,
29
+ duration: 1,
30
+ ease: "expo.out",
31
+ stagger: 0.08,
32
+ scrollTrigger: { trigger: el, start: "top 85%", once: true },
33
+ });
34
+ });
35
+ return () => mm.revert();
36
+ },
37
+ { scope: list },
38
+ );
39
+
40
+ return (
41
+ <ul ref={list} data-showpiece="fill-rows" className={`border-t border-line ${className}`}>
42
+ {rows.map((row) => {
43
+ const inner = (
44
+ <>
45
+ <span
46
+ aria-hidden
47
+ className="absolute inset-0 origin-bottom scale-y-0 bg-accent transition-transform duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-y-100 group-focus-visible:scale-y-100 motion-reduce:transition-none"
48
+ />
49
+ <span className="font-display relative block text-[11vw] leading-[1] tracking-[-0.03em] transition-[color,translate] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:translate-x-[1.5vw] group-hover:text-accent-ink group-focus-visible:translate-x-[1.5vw] group-focus-visible:text-accent-ink motion-reduce:transition-none md:text-[6vw]">
50
+ {row.title}
51
+ </span>
52
+ <span className="relative block max-w-[34ch] text-body text-ink-muted transition-[color,opacity,translate] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:text-accent-ink group-focus-visible:text-accent-ink motion-reduce:transition-none md:translate-y-4 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-visible:translate-y-0 md:group-focus-visible:opacity-100">
53
+ {row.text}
54
+ </span>
55
+ </>
56
+ );
57
+ const cls = "group relative grid gap-3 overflow-hidden px-6 py-8 outline-none md:grid-cols-[1fr_auto] md:items-end md:px-[5vw] md:py-[3vw]";
58
+ return (
59
+ <li key={row.title} className="border-b border-line">
60
+ {row.href ? (
61
+ <Link href={row.href} className={cls}>
62
+ {inner}
63
+ </Link>
64
+ ) : (
65
+ <div tabIndex={0} className={cls}>
66
+ {inner}
67
+ </div>
68
+ )}
69
+ </li>
70
+ );
71
+ })}
72
+ </ul>
73
+ );
74
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+ // toonstuk-hero-opener (Struq showpiece recipe): the full-bleed cinematic opener
3
+ // The first screen is the photo (or the WebGL scene over it), edge to edge, with the display line
4
+ // set across it at 14 to 20vw: either one poster line along the held-clear bottom ("across"), or
5
+ // the name split to the left and right edges with the photo between ("split"). One short lead and
6
+ // one confident rectangular CTA sit above the line. On scroll the opener hands over with a real
7
+ // move: it pins, scales down and darkens while the next section slides up over it.
8
+ //
9
+ // <HeroOpener image={images.hero[0]} title="Koken op" accent="vuur." lead="..." cta={{ href: "#reserveren", label: "Reserveer een tafel" }} header={<SiteHeader />} />
10
+ // <HeroOpener image={images.hero[0]} split={["Vuur", "Noord"]} lead="..." cta={...} />
11
+ //
12
+ // The line is the display face at poster size, with at most one italic accent word. The flat scrim
13
+ // keeps the copy at 4.5:1; never a gradient. Reduced motion and phones: the composed still, no pin.
14
+ // The section after the opener slides over it: globals.css gives it a ground and a higher layer.
15
+ import { useRef } from "react";
16
+ import { HeroCanvas } from "@/components/struq/HeroCanvas";
17
+ import { MagneticButton } from "@/components/struq/MagneticButton";
18
+ import { SplitHeadline } from "@/components/struq/SplitHeadline";
19
+ import { gsap, useGSAP } from "@/lib/gsap";
20
+ import type { StruqImage } from "@/lib/struq-images";
21
+
22
+ type Props = {
23
+ /** The beeldbank or client photo. Without one (imageFit "none"), the opener is typographic on the inverse ground over the dots scene. */
24
+ image?: StruqImage;
25
+ /** The poster line ("across"). */
26
+ title?: string;
27
+ /** One word in the display face's italic, after the title. */
28
+ accent?: string;
29
+ /** Two words for the split wordmark; replaces title. */
30
+ split?: [string, string];
31
+ lead: string;
32
+ cta: { href: string; label: string };
33
+ /** The site header (with the data-wordmark name), set over the photo. */
34
+ header?: React.ReactNode;
35
+ /** 0.2 to 0.5: the flat scrim under the copy. */
36
+ scrim?: number;
37
+ /**
38
+ * The photo's calm side, where the lead reads. Defaults to the image's own negativeSpace from the
39
+ * beeldbank. With no calm left or right side, the lead gets a fixed backing and a step up in size.
40
+ */
41
+ calm?: "left" | "right" | "none";
42
+ className?: string;
43
+ };
44
+
45
+ export function HeroOpener({ image, title, accent, split, lead, cta, header, scrim = 0.34, calm, className = "" }: Props) {
46
+ // The lead goes on the photo's calm side; the CTA takes the other. Over a busy frame (no calm
47
+ // side known) the lead gets a flat backing at a fixed opacity and is set a step larger.
48
+ const side = calm ?? (image?.negativeSpace === "left" || image?.negativeSpace === "right" ? image.negativeSpace : image ? "none" : "left");
49
+ const stage = useRef<HTMLElement>(null);
50
+
51
+ useGSAP(
52
+ () => {
53
+ const el = stage.current;
54
+ if (!el) return;
55
+ const q = gsap.utils.selector(el);
56
+ const mm = gsap.matchMedia();
57
+ mm.add("(prefers-reduced-motion: no-preference)", () => {
58
+ if (split) {
59
+ gsap.from(q("[data-split-word]"), { yPercent: 110, duration: 1.4, ease: "expo.out", stagger: 0.12, delay: 0.25 });
60
+ }
61
+ gsap.from(q("[data-opener-copy] > *"), { y: 32, autoAlpha: 0, duration: 1, ease: "expo.out", stagger: 0.1, delay: 0.7 });
62
+ });
63
+ mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
64
+ // The handover: the opener holds, then scales down and darkens under the next section.
65
+ gsap.timeline({
66
+ scrollTrigger: { trigger: el, start: "top top", end: "bottom top", pin: true, pinSpacing: false, scrub: true },
67
+ })
68
+ .to(q("[data-opener-inner]"), { scale: 0.84, yPercent: -4, ease: "none" }, 0)
69
+ .to(q("[data-opener-dim]"), { opacity: 0.6, ease: "none" }, 0);
70
+ });
71
+ return () => mm.revert();
72
+ },
73
+ { scope: stage },
74
+ );
75
+
76
+ // At poster size the words need air of their own: tight tracking pulls the letters together, and
77
+ // word spacing in em keeps the gap in step with the size for any face.
78
+ const poster = "font-display block text-[19vw] leading-[0.92] tracking-[-0.045em] [word-spacing:0.09em] md:text-[16vw]";
79
+ return (
80
+ <section ref={stage} data-showpiece="hero-opener" className={`relative h-svh overflow-hidden ${image ? "bg-black text-on-photo" : "scene-inverse"} ${className}`}>
81
+ <div data-opener-inner="" className="absolute inset-0 origin-top">
82
+ {image ? <HeroCanvas scene="ripple" image={image} grade={0.15} priority /> : <HeroCanvas scene="dots" />}
83
+ {image ? <div aria-hidden className="absolute inset-0 bg-black" style={{ opacity: scrim }} /> : null}
84
+ <div data-opener-dim="" aria-hidden className="absolute inset-0 bg-black opacity-0" />
85
+ {header ? <div className="absolute inset-x-0 top-0 z-20">{header}</div> : null}
86
+ {split ? (
87
+ <h1 aria-label={split.join(" ")} className="absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 justify-between px-6 md:px-[3vw]">
88
+ {split.map((word) => (
89
+ <span key={word} className="block overflow-hidden pb-[0.06em]">
90
+ <span data-split-word="" data-quiet="" className={poster}>
91
+ {word}
92
+ </span>
93
+ </span>
94
+ ))}
95
+ </h1>
96
+ ) : null}
97
+ <div className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-[3vh] px-6 pb-[3vh] md:px-[3vw]">
98
+ <div data-opener-copy="" className={`flex flex-col gap-6 md:flex-row md:items-end md:justify-between ${side === "right" ? "md:flex-row-reverse md:text-right" : ""}`}>
99
+ {side === "none" ? (
100
+ <p data-quiet="" className="relative isolate max-w-[30ch] text-h3">
101
+ <span aria-hidden className="absolute -inset-x-5 -inset-y-4 -z-10 bg-black opacity-55" />
102
+ {lead}
103
+ </p>
104
+ ) : (
105
+ <p data-quiet="" className="max-w-[32ch] text-lead">{lead}</p>
106
+ )}
107
+ <div>
108
+ <MagneticButton href={cta.href} size="lg">
109
+ {cta.label}
110
+ </MagneticButton>
111
+ </div>
112
+ </div>
113
+ {!split && title ? (
114
+ <SplitHeadline as="h1" className={poster}>
115
+ {title}
116
+ {accent ? (
117
+ <>
118
+ {" "}
119
+ <em className="font-accent font-light italic tracking-[-0.02em] [word-spacing:0.09em]">{accent}</em>
120
+ </>
121
+ ) : null}
122
+ </SplitHeadline>
123
+ ) : null}
124
+ </div>
125
+ </div>
126
+ </section>
127
+ );
128
+ }
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ // toonstuk-image-to-fullbleed (Struq showpiece recipe)
3
+ // A small image in the middle of the ground, the name split to either side of it. As you scroll
4
+ // the stage pins, the image unclips to the full viewport while the two words travel apart and off
5
+ // the edges, the photo settles from a push-in, and one closing line rises over it. Then it holds.
6
+ // Lead for a product launch (with the hero scene) and for a studio portfolio.
7
+ //
8
+ // <ImageToFullbleed image={images.hero[0]} left="Zout" right="Noord" line="Koken met wat het seizoen geeft." />
9
+ //
10
+ // Reduced motion and phones: the composed still, the photo full bleed with both words and the line.
11
+ import Image from "next/image";
12
+ import { useRef } from "react";
13
+ import { gsap, useGSAP } from "@/lib/gsap";
14
+ import type { StruqImage } from "@/lib/struq-images";
15
+
16
+ type Props = {
17
+ image: StruqImage;
18
+ left: string;
19
+ right: string;
20
+ /** One closing line over the open photo. */
21
+ line?: string;
22
+ className?: string;
23
+ };
24
+
25
+ export function ImageToFullbleed({ image, left, right, line, className = "" }: Props) {
26
+ const stage = useRef<HTMLElement>(null);
27
+
28
+ useGSAP(
29
+ () => {
30
+ const el = stage.current;
31
+ if (!el) return;
32
+ const q = gsap.utils.selector(el);
33
+ const mm = gsap.matchMedia();
34
+ mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
35
+ el.dataset.motion = "1";
36
+ gsap.set(q("[data-frame]"), { clipPath: "inset(31% 40% 31% 40%)" });
37
+ gsap.set(q("[data-photo]"), { scale: 1.35 });
38
+ gsap.set(q("[data-line]"), { yPercent: 100, autoAlpha: 0 });
39
+ gsap.set(q("[data-scrim]"), { autoAlpha: 0 }); // the scrim ends at its class opacity, never solid
40
+ const tl = gsap.timeline({
41
+ defaults: { ease: "none" },
42
+ scrollTrigger: { trigger: el, start: "top top", end: "+=220%", pin: true, scrub: 1 },
43
+ });
44
+ tl.to({}, { duration: 0.12 }) // hold on the small frame
45
+ .to(q("[data-frame]"), { clipPath: "inset(0% 0% 0% 0%)", duration: 1, ease: "power2.inOut" })
46
+ .to(q("[data-photo]"), { scale: 1, duration: 1, ease: "power2.inOut" }, "<")
47
+ .to(q("[data-left]"), { xPercent: -120, duration: 0.9, ease: "power2.in" }, "<")
48
+ .to(q("[data-right]"), { xPercent: 120, duration: 0.9, ease: "power2.in" }, "<")
49
+ .to(q("[data-scrim]"), { autoAlpha: 0.3, duration: 0.4 }, "-=0.3")
50
+ .to(q("[data-line]"), { yPercent: 0, autoAlpha: 1, duration: 0.45, ease: "expo.out" }, "-=0.2")
51
+ .to({}, { duration: 0.35 }); // hold on the open frame
52
+ return () => delete el.dataset.motion;
53
+ });
54
+ return () => mm.revert();
55
+ },
56
+ { scope: stage },
57
+ );
58
+
59
+ return (
60
+ <section ref={stage} data-showpiece="image-to-fullbleed" className={`relative h-svh overflow-hidden bg-surface-0 ${className}`}>
61
+ <div data-frame="" className="absolute inset-0 overflow-hidden">
62
+ <div data-photo="" className="absolute inset-0">
63
+ <Image src={image.src} alt={image.alt} fill sizes="100vw" unoptimized={image.unoptimized} className="object-cover" />
64
+ </div>
65
+ <div data-scrim="" aria-hidden className="absolute inset-0 bg-black opacity-30" />
66
+ </div>
67
+ <div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 items-center justify-between px-6 text-on-photo md:px-[4vw] motion-safe:md:text-ink">
68
+ <span data-left="" className="font-display block text-[18vw] leading-[0.85] tracking-[-0.04em] md:text-[12vw]">{left}</span>
69
+ <span data-right="" className="font-display block text-[18vw] leading-[0.85] tracking-[-0.04em] md:text-[12vw]">{right}</span>
70
+ </div>
71
+ {line ? (
72
+ <p data-line="" className="font-display text-h2 absolute bottom-[10vh] left-6 max-w-[18ch] text-on-photo md:left-[8vw]">
73
+ {line}
74
+ </p>
75
+ ) : null}
76
+ </section>
77
+ );
78
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+ // toonstuk-manifesto-scrub (Struq showpiece recipe)
3
+ // One large statement paragraph that fills word by word as it scrolls through the screen, from a
4
+ // faint ink to full ink, with small photos set into the line that open as their place is reached.
5
+ // Write the text with {0}, {1} where a photo goes: "We koken {0} wat de markt brengt en {1} niets meer."
6
+ // The inline photos are a word's size, so they need a subject (card, column, hero photos): a texture
7
+ // (detail, backdrop) reads as a grey swatch at that size, and check.mjs fails it.
8
+ // Lead for editorial sites; the supporting move for restaurants and hospitality.
9
+ //
10
+ // <ManifestoScrub text="Wij koken {0} met wat de zee en het land die week geven, {1} en verder niets." images={[images.detail[3], images.card[1]]} />
11
+ //
12
+ // Reduced motion and phones: the composed still, the whole paragraph in full ink with its photos.
13
+ import Image from "next/image";
14
+ import { Fragment, useRef } from "react";
15
+ import { gsap, useGSAP } from "@/lib/gsap";
16
+ import type { StruqImage } from "@/lib/struq-images";
17
+
18
+ type Props = { text: string; images?: StruqImage[]; className?: string };
19
+
20
+ export function ManifestoScrub({ text, images = [], className = "" }: Props) {
21
+ const stage = useRef<HTMLElement>(null);
22
+
23
+ useGSAP(
24
+ () => {
25
+ const el = stage.current;
26
+ if (!el) return;
27
+ const q = gsap.utils.selector(el);
28
+ const mm = gsap.matchMedia();
29
+ mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
30
+ gsap.set(q("[data-word]"), { opacity: 0.16 });
31
+ gsap.set(q("[data-inline]"), { width: 0 });
32
+ const tl = gsap.timeline({
33
+ defaults: { ease: "none" },
34
+ scrollTrigger: { trigger: el, start: "top top", end: "+=200%", pin: true, scrub: 1 },
35
+ });
36
+ q("[data-word], [data-inline]").forEach((node: Element, i: number) => {
37
+ const at = i * 0.12;
38
+ if ((node as HTMLElement).dataset.inline !== undefined) {
39
+ tl.to(node, { width: "1.9em", duration: 0.5, ease: "power2.inOut" }, at);
40
+ } else {
41
+ tl.to(node, { opacity: 1, duration: 0.3 }, at);
42
+ }
43
+ });
44
+ tl.to({}, { duration: 0.6 });
45
+ });
46
+ return () => mm.revert();
47
+ },
48
+ { scope: stage },
49
+ );
50
+
51
+ const parts = text.split(/(\{\d+\})/);
52
+ return (
53
+ <section ref={stage} data-showpiece="manifesto-scrub" className={`relative flex items-center bg-surface-0 px-6 py-32 md:min-h-svh md:px-[8vw] md:py-0 ${className}`}>
54
+ <p className="font-display max-w-[22ch] text-[9vw] leading-[1.02] tracking-[-0.025em] md:text-[5.2vw]">
55
+ {parts.map((part, i) => {
56
+ const slot = part.match(/^\{(\d+)\}$/);
57
+ if (slot) {
58
+ const image = images[Number(slot[1])];
59
+ if (!image) return null;
60
+ return (
61
+ <span
62
+ key={i}
63
+ data-inline=""
64
+ className="relative mx-[0.12em] inline-block h-[0.82em] w-[1.9em] translate-y-[0.08em] overflow-hidden align-baseline"
65
+ >
66
+ <Image src={image.src} alt={image.alt} fill sizes="12vw" loading="eager" unoptimized={image.unoptimized} className="object-cover" />
67
+ </span>
68
+ );
69
+ }
70
+ return (
71
+ <Fragment key={i}>
72
+ {part
73
+ .split(/(\s+)/)
74
+ .map((w, j) => (w.trim() ? <span key={j} data-word="">{w}</span> : w))}
75
+ </Fragment>
76
+ );
77
+ })}
78
+ </p>
79
+ </section>
80
+ );
81
+ }
@@ -0,0 +1,89 @@
1
+ "use client";
2
+ // toonstuk-panel-wipe (Struq showpiece recipe)
3
+ // Two ground-colour panels meet in the middle, each carrying one word of the headline. As you
4
+ // scroll the stage pins, the panels part to the edges and reveal a full-bleed photo behind them,
5
+ // the photo settling from a push-in; the panels then close again over it, so the headline carries
6
+ // you into the next scene. A scene change by mask, never a fade between boxes.
7
+ // Lead for restaurants and hospitality (with toonstuk-manifesto-scrub as the supporting move).
8
+ //
9
+ // <PanelWipe image={images.band[0]} left="Vuur" right="Zout" caption="De oven brandt vanaf vier uur." />
10
+ //
11
+ // Reduced motion and phones: the composed still, both words above the full-bleed photo.
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
+ left: string;
20
+ right: string;
21
+ /** One quiet line on the open photo. */
22
+ caption?: string;
23
+ /** Close the panels again at the end (default), or leave the photo open. */
24
+ close?: boolean;
25
+ className?: string;
26
+ };
27
+
28
+ export function PanelWipe({ image, left, right, caption, close = true, className = "" }: Props) {
29
+ const stage = useRef<HTMLElement>(null);
30
+
31
+ useGSAP(
32
+ () => {
33
+ const el = stage.current;
34
+ if (!el) return;
35
+ const q = gsap.utils.selector(el);
36
+ const mm = gsap.matchMedia();
37
+ mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
38
+ gsap.set(q("[data-photo]"), { scale: 1.25 });
39
+ gsap.set(q("[data-caption]"), { autoAlpha: 0, yPercent: 60 });
40
+ const tl = gsap.timeline({
41
+ defaults: { ease: "none" },
42
+ scrollTrigger: { trigger: el, start: "top top", end: close ? "+=260%" : "+=170%", pin: true, scrub: 1 },
43
+ });
44
+ tl.to({}, { duration: 0.15 })
45
+ .to(q("[data-panel-left]"), { xPercent: -100, duration: 1, ease: "power3.inOut" })
46
+ .to(q("[data-panel-right]"), { xPercent: 100, duration: 1, ease: "power3.inOut" }, "<")
47
+ .to(q("[data-photo]"), { scale: 1, duration: 1.2, ease: "power2.out" }, "<")
48
+ .to(q("[data-caption]"), { autoAlpha: 1, yPercent: 0, duration: 0.4, ease: "expo.out" }, "-=0.3")
49
+ .to({}, { duration: 0.5 });
50
+ if (close) {
51
+ tl.to(q("[data-caption]"), { autoAlpha: 0, duration: 0.2 })
52
+ .to(q("[data-panel-left]"), { xPercent: 0, duration: 1, ease: "power3.inOut" }, "<")
53
+ .to(q("[data-panel-right]"), { xPercent: 0, duration: 1, ease: "power3.inOut" }, "<")
54
+ .to({}, { duration: 0.15 });
55
+ }
56
+ });
57
+ return () => mm.revert();
58
+ },
59
+ { scope: stage },
60
+ );
61
+
62
+ const word = "font-display block text-[15vw] leading-[0.85] tracking-[-0.04em] md:text-[11vw]";
63
+ return (
64
+ <section ref={stage} data-showpiece="panel-wipe" className={`relative overflow-hidden bg-surface-0 motion-safe:md:h-svh ${className}`}>
65
+ {/* Still layout (phones, reduced motion): the words, then the photo. */}
66
+ <div className="flex justify-between px-6 pb-8 pt-24 md:hidden motion-reduce:md:flex">
67
+ <span className={word}>{left}</span>
68
+ <span className={word}>{right}</span>
69
+ </div>
70
+ <div className="relative aspect-[4/5] overflow-hidden motion-safe:md:absolute motion-safe:md:inset-0 motion-safe:md:aspect-auto motion-reduce:md:aspect-[16/9]">
71
+ <div data-photo="" className="absolute inset-0">
72
+ <Image src={image.src} alt={image.alt} fill sizes="100vw" unoptimized={image.unoptimized} className="object-cover" />
73
+ </div>
74
+ {caption ? (
75
+ <p data-caption="" className="absolute z-10 bottom-[8vh] left-6 max-w-[34ch] text-lead text-on-photo md:left-[8vw]">
76
+ <span aria-hidden className="absolute -inset-x-4 -inset-y-3 -z-10 bg-black opacity-35" />
77
+ {caption}
78
+ </p>
79
+ ) : null}
80
+ </div>
81
+ <div data-panel-left="" className="absolute inset-y-0 left-0 hidden w-[calc(50%+1px)] items-center justify-end bg-surface-0 pr-[1.5vw] motion-safe:md:flex">
82
+ <span className={word}>{left}</span>
83
+ </div>
84
+ <div data-panel-right="" className="absolute inset-y-0 right-0 hidden w-[calc(50%+1px)] items-center justify-start bg-surface-0 pl-[1.5vw] motion-safe:md:flex">
85
+ <span className={word}>{right}</span>
86
+ </div>
87
+ </section>
88
+ );
89
+ }
@@ -0,0 +1,107 @@
1
+ "use client";
2
+ // toonstuk-pixel-resolve (Struq showpiece recipe)
3
+ // The hero photo arrives as coarse pixels and resolves to sharp in a few held steps, on a stepped
4
+ // 2D canvas over the real image (no WebGL needed). When the last step lands, the canvas is hidden
5
+ // and the plain next/image is what stays, so the page always ends on the real photo.
6
+ // A supporting move for a product launch or a creative brand's hero.
7
+ //
8
+ // <PixelResolve image={images.hero[0]} priority className="h-svh" />
9
+ //
10
+ // Reduced motion, phones and any failure: the plain image, immediately.
11
+ import Image from "next/image";
12
+ import { useEffect, useRef } from "react";
13
+ import type { StruqImage } from "@/lib/struq-images";
14
+
15
+ /** Pixel sizes in CSS pixels, coarse to fine. */
16
+ const STEPS = [64, 40, 24, 14, 8, 4];
17
+
18
+ type Props = {
19
+ image: StruqImage;
20
+ className?: string;
21
+ priority?: boolean;
22
+ sizes?: string;
23
+ /** Milliseconds each step holds. */
24
+ hold?: number;
25
+ };
26
+
27
+ export function PixelResolve({ image, className = "", priority = false, sizes = "100vw", hold = 170 }: Props) {
28
+ const box = useRef<HTMLDivElement>(null);
29
+ const canvasRef = useRef<HTMLCanvasElement>(null);
30
+
31
+ useEffect(() => {
32
+ const el = box.current;
33
+ const canvas = canvasRef.current;
34
+ if (!el || !canvas) return;
35
+ // React owns the canvas, so it is hidden, never removed.
36
+ const hideCanvas = () => {
37
+ canvas.style.display = "none";
38
+ };
39
+ const still = window.matchMedia("(prefers-reduced-motion: reduce), (max-width: 767px)").matches;
40
+ if (still) {
41
+ hideCanvas();
42
+ return;
43
+ }
44
+ let cancelled = false;
45
+ const timers: number[] = [];
46
+ const img = new window.Image();
47
+ img.decoding = "async";
48
+ img.src = image.src;
49
+ img.onerror = () => hideCanvas();
50
+ img.onload = () => {
51
+ if (cancelled) return;
52
+ const w = el.clientWidth;
53
+ const h = el.clientHeight;
54
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
55
+ canvas.width = Math.round(w * dpr);
56
+ canvas.height = Math.round(h * dpr);
57
+ const ctx = canvas.getContext("2d");
58
+ const small = document.createElement("canvas");
59
+ const sctx = small.getContext("2d");
60
+ if (!ctx || !sctx) return hideCanvas();
61
+ // object-fit: cover, by hand
62
+ const scale = Math.max(w / img.naturalWidth, h / img.naturalHeight);
63
+ const sw = w / scale;
64
+ const sh = h / scale;
65
+ const sx = (img.naturalWidth - sw) / 2;
66
+ const sy = (img.naturalHeight - sh) / 2;
67
+ const draw = (px: number) => {
68
+ small.width = Math.max(1, Math.round(w / px));
69
+ small.height = Math.max(1, Math.round(h / px));
70
+ sctx.drawImage(img, sx, sy, sw, sh, 0, 0, small.width, small.height);
71
+ ctx.imageSmoothingEnabled = false;
72
+ ctx.drawImage(small, 0, 0, canvas.width, canvas.height);
73
+ };
74
+ STEPS.forEach((px, i) => timers.push(window.setTimeout(() => !cancelled && draw(px), 120 + i * hold)));
75
+ timers.push(
76
+ window.setTimeout(() => {
77
+ if (cancelled) return;
78
+ canvas.style.transition = "opacity 400ms cubic-bezier(0.16, 1, 0.3, 1)";
79
+ canvas.style.opacity = "0";
80
+ timers.push(window.setTimeout(() => hideCanvas(), 450));
81
+ }, 120 + STEPS.length * hold),
82
+ );
83
+ draw(STEPS[0]);
84
+ canvas.style.opacity = "1";
85
+ };
86
+ return () => {
87
+ cancelled = true;
88
+ timers.forEach((t) => window.clearTimeout(t));
89
+ };
90
+ }, [image.src, hold]);
91
+
92
+ return (
93
+ <div ref={box} data-showpiece="pixel-resolve" className={`relative overflow-hidden bg-surface-2 ${className}`}>
94
+ <Image
95
+ src={image.src}
96
+ alt={image.alt}
97
+ fill
98
+ sizes={sizes}
99
+ loading={priority ? "eager" : "lazy"}
100
+ fetchPriority={priority ? "high" : "auto"}
101
+ unoptimized={image.unoptimized}
102
+ className="object-cover"
103
+ />
104
+ <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 h-full w-full opacity-0" />
105
+ </div>
106
+ );
107
+ }