@struq/skill 0.3.0 → 0.4.1

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 (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +4 -2
  3. package/skills/struq/SKILL.md +139 -307
  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/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
  19. package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
  20. package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
  21. package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
  22. package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
  23. package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
  24. package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
  25. package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
  26. package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/images.ts +17 -0
  29. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  30. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  31. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  32. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  33. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  34. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  35. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  36. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  37. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  38. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  39. package/skills/struq/blocks/sections/approach.tsx +661 -0
  40. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  41. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  42. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  43. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  44. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  45. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  46. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  47. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  48. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  49. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  50. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  51. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  52. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  53. package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
  54. package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
  55. package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
  56. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  57. package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
  58. package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
  59. package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
  60. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  61. package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
  62. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  63. package/skills/struq/canon/canon.json +627 -0
  64. package/skills/struq/cinematic/README.md +7 -18
  65. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  66. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  67. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  68. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  69. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  70. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  71. package/skills/struq/recipes/app/fonts.ts +12 -22
  72. package/skills/struq/recipes/app/globals.css +34 -177
  73. package/skills/struq/recipes/app/layout.tsx +13 -11
  74. package/skills/struq/references/craft.md +77 -0
  75. package/skills/struq/references/iconography.md +28 -230
  76. package/skills/struq/references/stack-targets.md +23 -74
  77. package/skills/struq/references/tells.md +52 -0
  78. package/skills/struq/references/triage.md +6 -7
  79. package/skills/struq/scripts/blocks.mjs +156 -0
  80. package/skills/struq/scripts/canon.mjs +106 -281
  81. package/skills/struq/scripts/check.mjs +92 -126
  82. package/skills/struq/scripts/images.mjs +2 -2
  83. package/skills/struq/scripts/log.mjs +6 -23
  84. package/skills/struq/scripts/tokens.mjs +222 -0
  85. package/skills/struq/cinematic/showpieces/README.md +0 -18
  86. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
  87. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
  88. package/skills/struq/references/anti-patterns.md +0 -330
  89. package/skills/struq/references/baseline-prompt.md +0 -122
  90. package/skills/struq/references/brand.md +0 -143
  91. package/skills/struq/references/brief-router.md +0 -218
  92. package/skills/struq/references/composition.md +0 -190
  93. package/skills/struq/references/depth-parallax.md +0 -235
  94. package/skills/struq/references/direction.md +0 -188
  95. package/skills/struq/references/feel-layer.md +0 -107
  96. package/skills/struq/references/gsap-api.md +0 -293
  97. package/skills/struq/references/hero-scenes.md +0 -107
  98. package/skills/struq/references/imagery.md +0 -125
  99. package/skills/struq/references/loaders.md +0 -287
  100. package/skills/struq/references/micro-interactions.md +0 -270
  101. package/skills/struq/references/mobile-composition.md +0 -70
  102. package/skills/struq/references/nav-header.md +0 -296
  103. package/skills/struq/references/niche-packets.md +0 -338
  104. package/skills/struq/references/page-architecture.md +0 -134
  105. package/skills/struq/references/page-entrances.md +0 -198
  106. package/skills/struq/references/performance.md +0 -235
  107. package/skills/struq/references/pinning-scrub.md +0 -340
  108. package/skills/struq/references/pitfalls.md +0 -290
  109. package/skills/struq/references/route-transitions.md +0 -281
  110. package/skills/struq/references/scaling.md +0 -151
  111. package/skills/struq/references/scroll-reveals.md +0 -279
  112. package/skills/struq/references/sections.md +0 -141
  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
  123. package/skills/struq/scripts/font-names.mjs +0 -40
  124. package/skills/struq/scripts/recipes.mjs +0 -135
  125. /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
  126. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
  127. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
  128. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
  129. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
@@ -14,8 +14,8 @@
14
14
  import Image from "next/image";
15
15
  import Link from "next/link";
16
16
  import { useRef } from "react";
17
- import { gsap, useGSAP } from "@/lib/gsap";
18
- import type { StruqImage } from "@/lib/struq-images";
17
+ import { gsap, useGSAP } from "../kit/gsap";
18
+ import type { StruqImage } from "../kit/images";
19
19
 
20
20
  type Item = { image: StruqImage; title: string; meta?: string; href?: string };
21
21
 
@@ -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
+ }
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // Reduced motion and phones: the name stands still at full width; the clock still ticks.
12
12
  import { useEffect, useRef, useState } from "react";
13
- import { gsap, SplitText, useGSAP } from "@/lib/gsap";
13
+ import { gsap, SplitText, useGSAP } from "../kit/gsap";
14
14
 
15
15
  type Props = {
16
16
  name: string;