@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.
Files changed (122) hide show
  1. package/README.md +23 -35
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +148 -212
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/blocks/kit/Cursor.tsx +117 -0
  19. package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
  20. package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
  21. package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
  22. package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
  23. package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
  24. package/skills/struq/blocks/kit/Reveal.tsx +64 -0
  25. package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
  26. package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/gsap.ts +21 -0
  29. package/skills/struq/blocks/kit/images.ts +17 -0
  30. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  31. package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
  32. package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
  33. package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
  34. package/skills/struq/blocks/kit/scenes/types.ts +114 -0
  35. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  36. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  37. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  38. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  39. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  40. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  41. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  42. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  43. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  44. package/skills/struq/blocks/sections/approach.tsx +661 -0
  45. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  46. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  47. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  48. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  49. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  50. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  51. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  52. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  53. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  54. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  55. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  56. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  57. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  58. package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
  59. package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
  60. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
  61. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  62. package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
  63. package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
  64. package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
  65. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  66. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
  67. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  68. package/skills/struq/canon/canon.json +627 -0
  69. package/skills/struq/cinematic/README.md +7 -18
  70. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  71. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  72. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  73. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  74. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  75. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  76. package/skills/struq/recipes/app/fonts.ts +12 -0
  77. package/skills/struq/recipes/app/globals.css +42 -0
  78. package/skills/struq/recipes/app/layout.tsx +30 -0
  79. package/skills/struq/references/craft.md +77 -0
  80. package/skills/struq/references/iconography.md +28 -230
  81. package/skills/struq/references/stack-targets.md +23 -74
  82. package/skills/struq/references/tells.md +52 -0
  83. package/skills/struq/references/triage.md +6 -7
  84. package/skills/struq/scripts/blocks.mjs +156 -0
  85. package/skills/struq/scripts/canon.mjs +134 -0
  86. package/skills/struq/scripts/check.mjs +513 -0
  87. package/skills/struq/scripts/images.mjs +360 -0
  88. package/skills/struq/scripts/log.mjs +75 -0
  89. package/skills/struq/scripts/packs.mjs +156 -0
  90. package/skills/struq/scripts/tokens.mjs +222 -0
  91. package/skills/struq/references/anti-patterns.md +0 -265
  92. package/skills/struq/references/baseline-prompt.md +0 -118
  93. package/skills/struq/references/brand.md +0 -143
  94. package/skills/struq/references/brief-router.md +0 -219
  95. package/skills/struq/references/composition.md +0 -191
  96. package/skills/struq/references/depth-parallax.md +0 -235
  97. package/skills/struq/references/direction.md +0 -188
  98. package/skills/struq/references/feel-layer.md +0 -107
  99. package/skills/struq/references/gsap-api.md +0 -293
  100. package/skills/struq/references/imagery.md +0 -144
  101. package/skills/struq/references/loaders.md +0 -287
  102. package/skills/struq/references/micro-interactions.md +0 -270
  103. package/skills/struq/references/mobile-composition.md +0 -70
  104. package/skills/struq/references/nav-header.md +0 -296
  105. package/skills/struq/references/page-architecture.md +0 -134
  106. package/skills/struq/references/page-entrances.md +0 -198
  107. package/skills/struq/references/performance.md +0 -235
  108. package/skills/struq/references/pinning-scrub.md +0 -341
  109. package/skills/struq/references/pitfalls.md +0 -290
  110. package/skills/struq/references/route-transitions.md +0 -281
  111. package/skills/struq/references/scaling.md +0 -151
  112. package/skills/struq/references/scroll-reveals.md +0 -280
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
@@ -0,0 +1,307 @@
1
+ "use client";
2
+ // toonstuk-scrub-connector (Struq showpiece recipe)
3
+ // Two words and one bar that joins them. The words start far apart, one high on the left, one low
4
+ // on the right, divided by a tall upright slab of ink. As you scroll the stage pins and the bar
5
+ // becomes the showpiece: the slab swings a third of a turn and runs out past both edges as one
6
+ // slash through both words (their letters turn to the ground colour where it crosses). It then
7
+ // draws back to the words' inner ends and carries them in, turning to the accent as it travels,
8
+ // and seats long and slightly rising between them. The joined pair holds, and the pin lets the
9
+ // whole picture scroll away. The pin is 300%: every key frame holds, the moves are short.
10
+ // The bar is drawn from plain numbers on every frame, never by tweening a clip-path string.
11
+ // Lead for an agency or a creative brand (or toonstuk-fill-rows instead).
12
+ //
13
+ // <ScrubConnector left="Kijken" right="Maken" leftText="Elk project begint met wat er al staat." rightText="Daarna tekenen we pas." />
14
+ //
15
+ // Reduced motion: the joined pair, the accent bar rising between the words. Phones: the words
16
+ // stacked, joined by an upright accent bar with the two lines of copy beside it.
17
+ import { useRef } from "react";
18
+ import { gsap, useGSAP } from "../kit/gsap";
19
+
20
+ type Props = {
21
+ left: string;
22
+ right: string;
23
+ leftText?: string;
24
+ rightText?: string;
25
+ className?: string;
26
+ };
27
+
28
+ type Tone = "base" | "ink" | "accent";
29
+
30
+ const DEG = Math.PI / 180;
31
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
32
+
33
+ /** The landed bar rises by this angle: its ends meet the words 0.12em below and above the line (atan(0.24 / 2.44)). */
34
+ const RISE = 5.6;
35
+ /** How far each word sits below (first) or above (second) the shared line, in em of the display size. */
36
+ const STEP = 0.12;
37
+ /** The opening slab: its height as a share of the screen, its thickness in em. */
38
+ const SLAB = 0.66;
39
+ const SLAB_T = 0.3;
40
+ /** The slash: its thickness in em, and how far past the two words it runs, as a multiple of their distance. */
41
+ const SLASH_T = 0.42;
42
+ const SLASH = 2.4;
43
+
44
+ export function ScrubConnector({ left, right, leftText, rightText, className = "" }: Props) {
45
+ const stage = useRef<HTMLElement>(null);
46
+
47
+ useGSAP(
48
+ () => {
49
+ const el = stage.current;
50
+ if (!el) return;
51
+ const q = (s: string) => Array.from(el.querySelectorAll<HTMLElement>(s));
52
+ const base = q("[data-row='base']")[0];
53
+ const still = base?.querySelector<HTMLElement>("[data-still]");
54
+ const wordL = base?.querySelector<HTMLElement>("[data-word='l']");
55
+ const wordR = base?.querySelector<HTMLElement>("[data-word='r']");
56
+ const sideL0 = base?.querySelector<HTMLElement>("[data-side='l']");
57
+ const sideR0 = base?.querySelector<HTMLElement>("[data-side='r']");
58
+ const gap = base?.querySelector<HTMLElement>("[data-gap]");
59
+ if (!base || !still || !wordL || !wordR || !sideL0 || !sideR0 || !gap) return;
60
+
61
+ // Fit: the pair never wraps or overflows, whatever the face, and a narrow face grows (up to a third) so the
62
+ // joined pair still spans the frame. Measured at the natural size, then scaled.
63
+ const fit = () => {
64
+ el.style.setProperty("--fit", "1");
65
+ const avail = base.clientWidth;
66
+ const wide = window.matchMedia("(min-width: 768px)").matches;
67
+ const need = wide
68
+ ? sideL0.offsetWidth + gap.offsetWidth + sideR0.offsetWidth
69
+ : Math.max(wordL.offsetWidth, wordR.offsetWidth);
70
+ if (need <= 0) return;
71
+ const k = Math.min(wide ? 1.33 : 1, avail / need);
72
+ if (Math.abs(k - 1) > 0.005) el.style.setProperty("--fit", String(Math.floor(k * 1000) / 1000));
73
+ };
74
+ fit();
75
+ document.fonts?.ready.then(fit);
76
+ window.addEventListener("resize", fit);
77
+
78
+ const mm = gsap.matchMedia();
79
+ mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
80
+ el.dataset.motion = "1";
81
+ const sidesL = q("[data-side='l']");
82
+ const sidesR = q("[data-side='r']");
83
+ const inkLayer = q("[data-layer='ink']")[0];
84
+ const accentLayer = q("[data-layer='accent']")[0];
85
+
86
+ // Layout positions, untouched by transforms: offsets walk up to the stage.
87
+ const box = (n: HTMLElement) => {
88
+ let x = 0;
89
+ let y = 0;
90
+ let cur: HTMLElement | null = n;
91
+ while (cur && cur !== el) {
92
+ x += cur.offsetLeft;
93
+ y += cur.offsetTop;
94
+ cur = cur.offsetParent as HTMLElement | null;
95
+ }
96
+ return { x, y, w: n.offsetWidth, h: n.offsetHeight };
97
+ };
98
+
99
+ // Each word has a home point on the shared line (its centre, at its place beside the bar). The bar hangs
100
+ // on two anchors that ride with the words: their centres while it slashes, their inner ends once seated.
101
+ const g = {
102
+ H: 1,
103
+ offL: { x: 0, y: 0 }, offR: { x: 0, y: 0 },
104
+ homeL: { x: 0, y: 0 }, homeR: { x: 0, y: 0 },
105
+ endL: { x: 0, y: 0 }, endR: { x: 0, y: 0 },
106
+ l0: 0, t0: 0, t1: 0, t2: 0,
107
+ };
108
+ const measure = () => {
109
+ fit();
110
+ const W = el.clientWidth;
111
+ g.H = el.clientHeight;
112
+ const em = parseFloat(getComputedStyle(wordL).fontSize) || 100;
113
+ const step = STEP * em; // the sides carry this as their CSS translate, below and above the line
114
+ const b = box(still);
115
+ const line = b.y + b.h / 2;
116
+ const wl = box(wordL);
117
+ const wr = box(wordR);
118
+ const sl = box(sideL0);
119
+ const sr = box(sideR0);
120
+ g.homeL = { x: wl.x + wl.w / 2, y: line + step };
121
+ g.homeR = { x: wr.x + wr.w / 2, y: line - step };
122
+ // The seated bar is the still bar's own box turned by RISE about its centre; its ends, relative to homes.
123
+ const hx = (Math.cos(RISE * DEG) * b.w) / 2;
124
+ const hy = (Math.sin(RISE * DEG) * b.w) / 2;
125
+ const cx = b.x + b.w / 2;
126
+ g.endL = { x: cx - hx - g.homeL.x, y: line + hy - g.homeL.y };
127
+ g.endR = { x: cx + hx - g.homeR.x, y: line - hy - g.homeR.y };
128
+ g.t2 = b.h;
129
+ const m = W * 0.05;
130
+ // The start: the first word high on the left, the second low on the right, both whole in the frame.
131
+ g.offL = {
132
+ x: m - sl.x,
133
+ y: Math.max(g.H * 0.27 - g.homeL.y, g.H * 0.08 - (sl.y + step)),
134
+ };
135
+ g.offR = {
136
+ x: W - m - (sr.x + sr.w),
137
+ y: Math.min(g.H * 0.7 - g.homeR.y, g.H * 0.93 - (sr.y + sr.h - step)),
138
+ };
139
+ g.l0 = g.H * SLAB;
140
+ g.t0 = SLAB_T * em;
141
+ g.t1 = SLASH_T * em;
142
+ };
143
+
144
+ // Every value the scroll drives, as plain numbers.
145
+ const s = { sweep: 0, reach: SLASH, pull: 0, anchor: 0, thin: 0, fill: 0 };
146
+
147
+ // A bar about its centre, `from` and `to` the stretch of its length that is drawn (0 is the end the angle
148
+ // points away from).
149
+ const quad = (cx: number, cy: number, ang: number, len: number, t: number, from = 0, to = 1) => {
150
+ const dx = Math.cos(ang);
151
+ const dy = Math.sin(ang);
152
+ const a = -len / 2 + len * from;
153
+ const z = -len / 2 + len * to;
154
+ const o = t / 2;
155
+ const p = (k: number, n: number) => `${(cx + dx * k - dy * n).toFixed(2)}px ${(cy + dy * k + dx * n).toFixed(2)}px`;
156
+ return `polygon(${p(a, -o)}, ${p(z, -o)}, ${p(z, o)}, ${p(a, o)})`;
157
+ };
158
+
159
+ const paint = () => {
160
+ // The words: from their corners to their places, carried in by the bar.
161
+ const lx = g.offL.x * (1 - s.pull);
162
+ const ly = g.offL.y * (1 - s.pull);
163
+ const rx = g.offR.x * (1 - s.pull);
164
+ const ry = g.offR.y * (1 - s.pull);
165
+ const tL = `translate3d(${lx.toFixed(2)}px, ${ly.toFixed(2)}px, 0)`;
166
+ const tR = `translate3d(${rx.toFixed(2)}px, ${ry.toFixed(2)}px, 0)`;
167
+ for (const n of sidesL) n.style.transform = tL;
168
+ for (const n of sidesR) n.style.transform = tR;
169
+
170
+ // The rod between the two anchors. Its angle points from the second word back to the first, so the first
171
+ // word's end is the far end of its length.
172
+ const ax = g.homeL.x + lx + g.endL.x * s.anchor;
173
+ const ay = g.homeL.y + ly + g.endL.y * s.anchor;
174
+ const bx = g.homeR.x + rx + g.endR.x * s.anchor;
175
+ const by = g.homeR.y + ry + g.endR.y * s.anchor;
176
+ const rodAng = Math.atan2(by - ay, bx - ax) / DEG + 180;
177
+ const rodLen = Math.hypot(bx - ax, by - ay) * s.reach;
178
+ const rodT = lerp(g.t1, g.t2, s.thin);
179
+ // Before the slash the bar is the upright slab between the words, swinging onto the rod.
180
+ const ang = lerp(90, rodAng, s.sweep) * DEG;
181
+ const len = lerp(g.l0, rodLen, s.sweep);
182
+ const t = lerp(g.t0, rodT, s.sweep);
183
+ const cx = (ax + bx) / 2;
184
+ const cy = (ay + by) / 2;
185
+ inkLayer.style.clipPath = quad(cx, cy, ang, len, t);
186
+ // The accent runs along the bar from the first word's end while it travels.
187
+ accentLayer.style.clipPath = s.fill > 0 ? quad(cx, cy, ang, len, t + 1, 1 - s.fill, 1) : "polygon(0 0, 0 0, 0 0)";
188
+ };
189
+
190
+ measure();
191
+ paint();
192
+
193
+ const tl = gsap.timeline({
194
+ defaults: { ease: "none" },
195
+ onUpdate: paint,
196
+ scrollTrigger: {
197
+ trigger: el,
198
+ start: "top top",
199
+ end: "+=300%",
200
+ pin: true,
201
+ scrub: 1,
202
+ onRefreshInit: () => {
203
+ for (const n of [...sidesL, ...sidesR]) n.style.transform = "";
204
+ },
205
+ onRefresh: () => {
206
+ measure();
207
+ paint();
208
+ },
209
+ },
210
+ });
211
+ // Hold: the two words apart, the slab standing between them.
212
+ tl.to({}, { duration: 0.6 })
213
+ // The showpiece: the slab swings a third of a turn, thickens and runs out past both edges, onto the
214
+ // line through both words.
215
+ .to(s, { sweep: 1, duration: 1.2, ease: "power2.inOut" })
216
+ // Hold on the slash through both words.
217
+ .to({}, { duration: 0.9 })
218
+ // The join: the bar draws back to the words' inner ends and carries them in to their places, turning
219
+ // to the accent and thinning as it goes, and seats long and rising between them.
220
+ .to(s, { pull: 1, duration: 1.3, ease: "power3.inOut" })
221
+ .to(s, { anchor: 1, reach: 1, duration: 0.7, ease: "power3.inOut" }, "<")
222
+ .to(s, { thin: 1, duration: 1.3, ease: "power2.inOut" }, "<")
223
+ .to(s, { fill: 1, duration: 1.1, ease: "power2.inOut" }, "<0.1")
224
+ // Hold on the joined pair; the pin then lets the whole picture scroll away.
225
+ .to({}, { duration: 1.4 });
226
+
227
+ return () => {
228
+ delete el.dataset.motion;
229
+ for (const n of [...sidesL, ...sidesR]) n.style.transform = "";
230
+ for (const n of [inkLayer, accentLayer]) n.style.clipPath = "";
231
+ };
232
+ });
233
+ return () => {
234
+ window.removeEventListener("resize", fit);
235
+ mm.revert();
236
+ };
237
+ },
238
+ { scope: stage },
239
+ );
240
+
241
+ // One fluid display size: 19vw on phones, about 10vw on desktop, scaled by --fit so the pair spans the frame. The
242
+ // bar, the gap, the step between the words and the copy's clearance below the descenders all follow it in em.
243
+ const word =
244
+ "font-display block w-fit whitespace-nowrap text-[length:var(--cw)] leading-[0.9] tracking-[-0.035em]";
245
+
246
+ const row = (tone: Tone) => {
247
+ const wordTone = tone === "base" ? "text-ink" : tone === "ink" ? "text-surface-0" : "text-accent-ink";
248
+ const copyTone = tone === "base" ? "text-ink-muted" : wordTone;
249
+ const copy = `max-w-[26ch] text-small ${copyTone}`;
250
+ return (
251
+ <div
252
+ data-row={tone}
253
+ className="grid w-full grid-cols-[auto_1fr] gap-x-5 md:flex md:items-start md:justify-center md:gap-x-0"
254
+ >
255
+ <div data-side="l" className="contents md:flex md:flex-col md:items-start md:translate-y-[calc(var(--cw)*0.12)]">
256
+ <span data-word="l" className={`${word} col-span-2 ${wordTone}`}>
257
+ {left}
258
+ </span>
259
+ {leftText ? (
260
+ <p className={`${copy} col-start-2 row-start-2 mt-[calc(var(--cw)*0.32)] self-start md:mt-[calc(var(--cw)*0.36)]`}>
261
+ {leftText}
262
+ </p>
263
+ ) : null}
264
+ </div>
265
+ <span
266
+ data-gap=""
267
+ aria-hidden
268
+ className="font-display col-start-1 row-span-2 row-start-2 my-[0.1em] ml-[0.05em] block min-h-[28svh] w-[0.1em] text-[length:var(--cw)] leading-[0.9] md:my-0 md:ml-0 md:min-h-0 md:w-[2.8em] md:shrink-0 md:px-[0.18em]"
269
+ >
270
+ <span
271
+ data-still=""
272
+ className={`block h-full w-full bg-accent md:inline-block md:h-[0.18em] md:-rotate-[5.6deg] ${
273
+ tone === "base" ? "group-data-[motion]:invisible" : "invisible"
274
+ }`}
275
+ style={{ verticalAlign: "calc(0.5ex - 0.09em)" }}
276
+ />
277
+ </span>
278
+ <div data-side="r" className="contents md:flex md:flex-col md:items-end md:text-right md:-translate-y-[calc(var(--cw)*0.12)]">
279
+ <span data-word="r" className={`${word} col-span-2 row-start-4 ${wordTone}`}>
280
+ {right}
281
+ </span>
282
+ {rightText ? (
283
+ <p className={`${copy} col-start-2 row-start-3 self-end pb-1 md:mt-[calc(var(--cw)*0.36)] md:pb-0`}>{rightText}</p>
284
+ ) : null}
285
+ </div>
286
+ </div>
287
+ );
288
+ };
289
+
290
+ const layer = "pointer-events-none absolute inset-0 hidden items-center px-[5vw] group-data-[motion]:md:flex";
291
+ return (
292
+ <section
293
+ ref={stage}
294
+ data-showpiece="scrub-connector"
295
+ style={{ "--cw": "calc(var(--cw-base) * var(--fit, 1))" } as React.CSSProperties}
296
+ className={`group relative flex items-center overflow-hidden bg-surface-0 px-6 py-24 [--cw-base:19vw] md:h-svh md:px-[5vw] md:py-0 md:[--cw-base:clamp(3rem,1rem_+_10.5vw,14rem)] ${className}`}
297
+ >
298
+ {row("base")}
299
+ <div data-layer="ink" aria-hidden className={`${layer} bg-ink`}>
300
+ {row("ink")}
301
+ </div>
302
+ <div data-layer="accent" aria-hidden className={`${layer} bg-accent`}>
303
+ {row("accent")}
304
+ </div>
305
+ </section>
306
+ );
307
+ }
@@ -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 "../kit/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
+ }