@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
@@ -0,0 +1,222 @@
1
+ // The token block and the font requests, derived exactly as Struq's block lab derives them (lib/lab/tokens.ts,
2
+ // lib/canon/surface-ladder.ts, lib/canon/palette-roles.ts, lib/canon/font-sources.ts), so a block in a client
3
+ // project wears the same colours and faces it was judged in. Pure functions, no I/O: canon.mjs writes, tests compare.
4
+
5
+ /* ------------------------------------------------------------------ colour */
6
+ const linearise = (byte) => {
7
+ const v = byte / 255;
8
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
9
+ };
10
+ const encode = (linear) => {
11
+ const v = linear <= 0.0031308 ? linear * 12.92 : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
12
+ return Math.round(Math.min(255, Math.max(0, v * 255)));
13
+ };
14
+
15
+ function oklabToLinear({ L, a, b }) {
16
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
17
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
18
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
19
+ return {
20
+ r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
21
+ g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
22
+ b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
23
+ };
24
+ }
25
+ function linearToOklab({ r, g, b }) {
26
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
27
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
28
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
29
+ return {
30
+ L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
31
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
32
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
33
+ };
34
+ }
35
+
36
+ /** A canon colour (hex or oklch) as linear RGB, or null (background-exposure.ts parseColor). */
37
+ export function parseLinear(value) {
38
+ const v = String(value).trim();
39
+ if (v.startsWith("#")) {
40
+ const body = v.slice(1);
41
+ const parts =
42
+ body.length === 3
43
+ ? body.split("").map((c) => parseInt(c + c, 16))
44
+ : body.length === 6
45
+ ? [0, 2, 4].map((i) => parseInt(body.slice(i, i + 2), 16))
46
+ : null;
47
+ if (!parts || parts.some(Number.isNaN)) return null;
48
+ return { r: linearise(parts[0]), g: linearise(parts[1]), b: linearise(parts[2]) };
49
+ }
50
+ if (!v.toLowerCase().startsWith("oklch")) return null;
51
+ const m = /oklch\(\s*([\d.]+)%?\s+([\d.]+)\s+([\d.]+)/i.exec(v);
52
+ if (!m) return null;
53
+ let L = parseFloat(m[1]);
54
+ if (v.includes("%")) L /= 100;
55
+ const C = parseFloat(m[2]);
56
+ const h = (parseFloat(m[3]) * Math.PI) / 180;
57
+ return oklabToLinear({ L, a: C * Math.cos(h), b: C * Math.sin(h) });
58
+ }
59
+
60
+ export function toHex(c) {
61
+ const clamp = (n) => Math.min(1, Math.max(0, n));
62
+ return `#${[c.r, c.g, c.b].map((x) => encode(clamp(x)).toString(16).padStart(2, "0")).join("")}`;
63
+ }
64
+
65
+ /** sRGB bytes, oklch clamped into gamut first (facets.ts parseColor). */
66
+ function parseBytes(input) {
67
+ const value = String(input).trim().toLowerCase();
68
+ const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
69
+ if (hex) {
70
+ const d = hex[1].length === 3 ? hex[1].split("").map((x) => x + x).join("") : hex[1];
71
+ return { r: parseInt(d.slice(0, 2), 16), g: parseInt(d.slice(2, 4), 16), b: parseInt(d.slice(4, 6), 16) };
72
+ }
73
+ const ok = value.match(/^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/);
74
+ if (!ok) return null;
75
+ const L = ok[1].endsWith("%") ? Number(ok[1].slice(0, -1)) / 100 : Number(ok[1]);
76
+ const h = (Number(ok[3]) * Math.PI) / 180;
77
+ const lin = oklabToLinear({ L, a: Number(ok[2]) * Math.cos(h), b: Number(ok[2]) * Math.sin(h) });
78
+ const enc = (c) => {
79
+ const x = Math.min(1, Math.max(0, c));
80
+ const g = x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
81
+ return Math.round(Math.min(1, Math.max(0, g)) * 255);
82
+ };
83
+ return { r: enc(lin.r), g: enc(lin.g), b: enc(lin.b) };
84
+ }
85
+ const luminance = ({ r, g, b }) => 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
86
+
87
+ /** WCAG contrast ratio between two canon colours. */
88
+ export function contrast(a, b) {
89
+ const ca = parseBytes(a);
90
+ const cb = parseBytes(b);
91
+ if (!ca || !cb) return 0;
92
+ const [hi, lo] = [luminance(ca), luminance(cb)].sort((x, y) => y - x);
93
+ return (hi + 0.05) / (lo + 0.05);
94
+ }
95
+
96
+ /** Lightness for light or dark: OKLCH L, else the HSL lightness of a hex (lib/lab/tokens.ts). */
97
+ export function lightness(v) {
98
+ const m = String(v).match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/i);
99
+ if (m) return m[1].endsWith("%") ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
100
+ const h = String(v).match(/^#([0-9a-f]{6})$/i);
101
+ if (!h) return 0.5;
102
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(h[1].slice(i, i + 2), 16) / 255);
103
+ return (Math.max(r, g, b) + Math.min(r, g, b)) / 2;
104
+ }
105
+
106
+ /* ------------------------------------------------------------------- roles */
107
+ const NEAR_WHITE = "oklch(0.98 0.005 85)";
108
+ const NEAR_BLACK = "oklch(0.16 0.01 60)";
109
+
110
+ function inkFor(ground, own) {
111
+ const ranked = [...own, NEAR_WHITE, NEAR_BLACK]
112
+ .map((ink, order) => ({ ink, order, ratio: contrast(ground, ink) }))
113
+ .sort((a, b) => b.ratio - a.ratio || a.order - b.order);
114
+ const ownPassing = ranked.find((e) => e.order < own.length && e.ratio >= 4.5);
115
+ return (ownPassing ?? ranked[0]).ink;
116
+ }
117
+
118
+ function deepenedField(accent, light) {
119
+ const base = parseLinear(accent);
120
+ if (!base) return { field: accent, ink: inkFor(accent, light) };
121
+ for (let step = 0; step <= 12; step += 1) {
122
+ const f = Math.pow(0.9, step);
123
+ const field = step === 0 ? accent : toHex({ r: base.r * f, g: base.g * f, b: base.b * f });
124
+ const ink = inkFor(field, light);
125
+ if (contrast(field, ink) >= 4.5) return { field, ink };
126
+ }
127
+ return { field: accent, ink: inkFor(accent, light) };
128
+ }
129
+
130
+ /** The field and inverse roles: authored on the palette, else derived from its own colours (palette-roles.ts). */
131
+ export function paletteRoles(palette) {
132
+ if (palette.roles) return palette.roles;
133
+ const { background, text, accent } = palette.colors;
134
+ const ink = inkFor(accent, [background, text]);
135
+ const { field, ink: fieldInk } =
136
+ contrast(accent, ink) >= 4.5 ? { field: accent, ink } : deepenedField(accent, [background, text]);
137
+ return { field, fieldInk, inverse: text, inverseInk: background };
138
+ }
139
+
140
+ /** A deeper surface step held on the ground's own hue (surface-ladder.ts). */
141
+ export function surfaceStep(surface, ink, pct) {
142
+ const s = parseLinear(surface);
143
+ const k = parseLinear(ink);
144
+ if (!s || !k) return `color-mix(in oklab, ${surface}, ${ink} ${pct}%)`;
145
+ const a = linearToOklab(s);
146
+ const b = linearToOklab(k);
147
+ const t = pct / 100;
148
+ const mixed = { L: a.L + (b.L - a.L) * t, a: a.a + (b.a - a.a) * t, b: a.b + (b.b - a.b) * t };
149
+ let C = Math.hypot(mixed.a, mixed.b);
150
+ const H = Math.hypot(a.a, a.b) < 0.002 ? Math.atan2(mixed.b, mixed.a) : Math.atan2(a.b, a.a);
151
+ const inGamut = (c) => [c.r, c.g, c.b].every((v) => v >= -1e-4 && v <= 1 + 1e-4);
152
+ while (C > 0 && !inGamut(oklabToLinear({ L: mixed.L, a: C * Math.cos(H), b: C * Math.sin(H) }))) C -= 0.001;
153
+ const deg = ((H * 180) / Math.PI + 360) % 360;
154
+ return `oklch(${mixed.L.toFixed(4)} ${Math.max(0, C).toFixed(4)} ${deg.toFixed(2)})`;
155
+ }
156
+
157
+ /* ------------------------------------------------------------------- faces */
158
+ export const headFamily = (stack) => (String(stack ?? "").split(",")[0] ?? "").trim().replace(/^['"]|['"]$/g, "");
159
+
160
+ /** The token block, as [name, value] pairs in the lab's order (lib/lab/tokens.ts paletteTokens). */
161
+ export function paletteTokens(palette, pairing) {
162
+ const c = palette.colors;
163
+ const roles = paletteRoles(palette);
164
+ const dark = lightness(c.background) < 0.5;
165
+ const accentInk = contrast(c.accent, c.background) >= contrast(c.accent, c.text) ? c.background : c.text;
166
+ const tokens = [
167
+ ["--surface-0", c.background],
168
+ ["--surface-1", c.surface],
169
+ ["--surface-2", surfaceStep(c.surface, c.text, dark ? 9 : 7)],
170
+ ["--surface-3", surfaceStep(c.surface, c.text, dark ? 18 : 16)],
171
+ ["--ink", c.text],
172
+ ["--ink-muted", `color-mix(in oklab, var(--ink) ${dark ? 70 : 66}%, var(--surface-0))`],
173
+ ["--accent", c.accent],
174
+ ["--accent-ink", accentInk],
175
+ ["--accent-2", c.accentAlt ?? c.surface],
176
+ ["--line", "color-mix(in oklab, var(--ink) 13%, transparent)"],
177
+ ["--on-photo", dark ? c.text : c.background],
178
+ ["--photo-ground", dark ? c.background : roles.inverse],
179
+ ["--inverse", roles.inverse],
180
+ ["--on-inverse", roles.inverseInk],
181
+ ["--field", roles.field],
182
+ ["--on-field", roles.fieldInk],
183
+ ["color-scheme", dark ? "dark" : "light"],
184
+ ["--display-weight", String(pairing.displayScale?.weight ?? 300)],
185
+ ["--face-display", `"${headFamily(pairing.display)}"`],
186
+ ["--face-body", `"${headFamily(pairing.body)}"`],
187
+ ];
188
+ if (pairing.accentItalic) tokens.push(["--face-accent", `"${headFamily(pairing.accentItalic)}"`]);
189
+ return tokens;
190
+ }
191
+
192
+ /**
193
+ * Stylesheet URLs for a pairing's faces (lib/lab/tokens.ts fontHrefs): per Google family the face's real weight range
194
+ * and the bare family as a fallback, because css2 refuses a request that names a weight the family lacks.
195
+ */
196
+ export function fontHrefs(pairing, { fontWeights = {}, fontProviders = {} } = {}) {
197
+ const hrefs = [];
198
+ const faces = [
199
+ { stack: pairing.display, italic: false },
200
+ { stack: pairing.body, italic: false },
201
+ ...(pairing.accentItalic ? [{ stack: pairing.accentItalic, italic: true }] : []),
202
+ ];
203
+ for (const { stack, italic } of faces) {
204
+ const family = headFamily(stack);
205
+ const provider = fontProviders[family];
206
+ if (!provider || provider === "system") continue;
207
+ if (provider === "fontshare") {
208
+ hrefs.push(`https://api.fontshare.com/v2/css?f[]=${family.toLowerCase().replace(/\s+/g, "-")}@300,400,500,600,700&display=swap`);
209
+ continue;
210
+ }
211
+ const name = family.replace(/\s+/g, "+");
212
+ const range = fontWeights[family];
213
+ const axis = range && range[0] < range[1] ? `${range[0]}..${range[1]}` : null;
214
+ hrefs.push(
215
+ italic
216
+ ? `https://fonts.googleapis.com/css2?family=${name}:${axis ? `ital,wght@1,${axis}` : "ital@1"}&display=swap`
217
+ : `https://fonts.googleapis.com/css2?family=${name}${axis ? `:wght@${axis}` : ""}&display=swap`,
218
+ );
219
+ hrefs.push(`https://fonts.googleapis.com/css2?family=${name}${italic ? ":ital@1" : ""}&display=swap`);
220
+ }
221
+ return [...new Set(hrefs)];
222
+ }
@@ -1,18 +0,0 @@
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.
@@ -1,81 +0,0 @@
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
- }
@@ -1,78 +0,0 @@
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
- }