@struq/skill 0.3.0 → 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 (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +3 -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 +2 -2
  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,289 @@
1
+ "use client";
2
+ // preloader-slices (Struq preloader recipe)
3
+ // The first paint is a dark cover cut into vertical slices (four, three on phones), already composed: the client's
4
+ // name large in the top left, a caption beside it, a hairline across the foot and the count at 000 under it. The count
5
+ // runs to 100 in one continuous move while the line fills, holds a beat, and then the slices drop away one after the
6
+ // other, left to right. What they carry stays where it is: each slice's top edge, marked by a 5px accent line, wipes
7
+ // its strip of the name and the count away as it falls, and uncovers the page from the top, the title first, the
8
+ // photo after. The page's own entrance starts as the first slice moves (whenUncovered).
9
+ //
10
+ // First paint to clear never exceeds 2.4 seconds. The count gives up whatever hydration took; when the script takes
11
+ // over later than 1.1 seconds after the paint there is no count at all, only a shortened drop; later still (or if the
12
+ // bundle never comes), the cover simply goes.
13
+ //
14
+ // Mount it once, in app/layout.tsx, before {children}:
15
+ // <PreloaderSlices name="Noord" caption="Architectenbureau, Rotterdam" />
16
+ // A hero that wants its entrance in the cover's wake (helper in preloader-slices-uncovered.ts, which ships with it):
17
+ // useGSAP((_, contextSafe) => whenUncovered(contextSafe!(() => { ...the entrance... })), { scope })
18
+ //
19
+ // The block owns its own gate. A tiny script inside the cover runs while the browser parses it, before the first
20
+ // paint. Only with motion allowed and no earlier play this session does it arm the cover (data-armed), close the
21
+ // curtain on <html>, hold the page still (wheel, touch and scroll keys) and start the page at the top. So without
22
+ // JavaScript, under reduced motion and on a revisit the cover never paints and nothing waits or holds. If the bundle
23
+ // never arrives, a CSS failsafe hides the armed cover at 2.3 seconds and the hold lets go at 2.4.
24
+ // `always` plays it on every load (the lab, a showreel).
25
+ import { useRef, useState, type CSSProperties } from "react";
26
+ import { gsap, useGSAP } from "../kit/gsap";
27
+ import { closeCurtain, covered, liftCurtain, whenUncovered } from "./preloader-slices-uncovered";
28
+
29
+ export { whenUncovered };
30
+
31
+ const SEEN_KEY = "struq:preloader-slices";
32
+
33
+ /** Seconds, measured from the first paint. */
34
+ const BUDGET = 2.4;
35
+ const T = {
36
+ count: 1.15, // the count at its fullest; hydration time comes off it
37
+ minCount: 0.3,
38
+ late: 1.1, // a takeover later than this skips the count
39
+ hold: 0.12, // the beat at 100
40
+ exit: 0.7, // one slice dropping away
41
+ minExit: 0.25, // a drop shorter than this is not worth showing: the cover just goes
42
+ stagger: 0.05,
43
+ } as const;
44
+
45
+ /**
46
+ * Runs while the browser parses the cover, before the first paint: arms it, stamps the paint time, closes the
47
+ * curtain, starts the page at the top and holds it still until the lift (or 2.4s at most). Never throws.
48
+ */
49
+ const ARM = `(function(){try{var e=document.currentScript.parentElement;if(matchMedia("(prefers-reduced-motion: reduce)").matches)return;if(!e.hasAttribute("data-always")&&sessionStorage.getItem("${SEEN_KEY}")==="1")return;e.setAttribute("data-t0",String(performance.now()));e.setAttribute("data-armed","");document.documentElement.setAttribute("data-curtain","closed");if(!location.hash){history.scrollRestoration="manual";scrollTo(0,0)}var k={" ":1,PageDown:1,PageUp:1,ArrowDown:1,ArrowUp:1,Home:1,End:1},o={capture:true,passive:false},s=function(v){v.preventDefault();v.stopImmediatePropagation()},d=function(v){if(k[v.key])v.preventDefault()},r=function(){removeEventListener("wheel",s,o);removeEventListener("touchmove",s,o);removeEventListener("keydown",d,true);window.__preloaderSlicesHold=null};addEventListener("wheel",s,o);addEventListener("touchmove",s,o);addEventListener("keydown",d,true);window.__preloaderSlicesHold=r;setTimeout(r,${BUDGET * 1000})}catch(x){}})()`;
50
+
51
+ /** The gate: hidden unless armed, gone at 2.3s if the script never takes over, never under reduced motion. */
52
+ const GATE = `[data-preloader="slices"]{display:none}
53
+ [data-preloader="slices"][data-armed]{display:block;animation:preloader-slices-failsafe 0s 2.3s forwards}
54
+ [data-preloader="slices"][data-live]{animation:none}
55
+ @keyframes preloader-slices-failsafe{to{visibility:hidden}}
56
+ @media (prefers-reduced-motion:reduce){[data-preloader="slices"]{display:none!important}}`;
57
+
58
+ /** Lets go of the hold the arm script put on the page. */
59
+ function releaseHold() {
60
+ (window as Window & { __preloaderSlicesHold?: (() => void) | null }).__preloaderSlicesHold?.();
61
+ }
62
+
63
+ /**
64
+ * The cover is always the dark of the palette. On a light palette that is its ink (the inverse); on a dark one the
65
+ * inverse is the cream text, which would flash a light sheet over a dark site, so there the cover is the page's own
66
+ * ground lifted one step. Two layers do it without knowing the palette: the lifted ground below, and the inverse on
67
+ * top at full strength when it is dark and fully transparent when it is light. Browsers without relative colour get
68
+ * the plain inverse.
69
+ */
70
+ const COVER = {
71
+ "--slices-base": "oklch(from var(--surface-0) min(l + 0.07, 0.27) c h)",
72
+ "--slices-ink": "oklch(from var(--inverse) l c h / clamp(0, (0.5 - l) * 100, 1))",
73
+ } as CSSProperties;
74
+ // Written out in full, so Tailwind finds the classes.
75
+ const COVER_BG = "bg-inverse supports-[color:oklch(from_red_l_c_h)]:bg-[var(--slices-base)]";
76
+ const COVER_INK = "absolute inset-0 hidden bg-[var(--slices-ink)] supports-[color:oklch(from_red_l_c_h)]:block";
77
+
78
+ const SLICES = [0, 1, 2, 3];
79
+
80
+ /** Where a slice rests off screen: a little past it, so its 5px accent edge is out of view too. */
81
+ const PARK = 102;
82
+
83
+ /** Set once the cover has played in this document, so a remount never replays it. */
84
+ let playedThisDocument = false;
85
+ /** A teardown's lift, held back one task so a remount in the same commit (strict mode) can take it back. */
86
+ let pendingTeardown = 0;
87
+
88
+ type Props = {
89
+ /** The client's name, the display word of the cover. */
90
+ name?: string;
91
+ /** One short line beside the name: what and where. */
92
+ caption?: string;
93
+ /** Play on every load, ignoring the session (the lab uses it). */
94
+ always?: boolean;
95
+ /** Called the moment the slices start to leave. */
96
+ onReveal?: () => void;
97
+ className?: string;
98
+ };
99
+
100
+ export function PreloaderSlices({ name, caption, always = false, onReveal, className = "" }: Props) {
101
+ const root = useRef<HTMLDivElement>(null);
102
+ const [done, setDone] = useState(false);
103
+
104
+ useGSAP(
105
+ () => {
106
+ const el = root.current;
107
+ if (!el) return;
108
+ window.clearTimeout(pendingTeardown);
109
+ const armed = el.hasAttribute("data-armed");
110
+ const elapsed = Math.max(0, (performance.now() - Number(el.dataset.t0 ?? performance.now())) / 1000);
111
+ const sheets = gsap.utils.toArray<HTMLElement>("[data-sheet]", el).filter((s) => getComputedStyle(s).display !== "none");
112
+ const stagger = T.stagger * (sheets.length - 1);
113
+ const quit = () => {
114
+ releaseHold();
115
+ if (covered()) liftCurtain();
116
+ setDone(true);
117
+ };
118
+ // Not armed, already played, or the failsafe already took it away.
119
+ const gaveUp = armed && getComputedStyle(el).visibility === "hidden";
120
+ if (!armed || gaveUp || (playedThisDocument && !always)) return quit();
121
+ // Too late for any drop worth showing: the cover just goes, inside the budget.
122
+ const room = BUDGET - elapsed - stagger - 0.03;
123
+ if (room < T.minExit) return quit();
124
+
125
+ playedThisDocument = true;
126
+ el.dataset.live = "";
127
+ closeCurtain();
128
+ let finished = false;
129
+ let cancelled = false;
130
+
131
+ const q = gsap.utils.selector(el);
132
+ const faces = sheets.map((s) => s.querySelector("[data-face]") as HTMLElement);
133
+ const counts = q("[data-count]");
134
+ const fills = q("[data-fill]");
135
+ const state = { p: 0 };
136
+ const render = () => {
137
+ const text = String(Math.round(state.p * 100)).padStart(3, "0");
138
+ for (const c of counts) c.textContent = text;
139
+ for (const f of fills) f.style.transform = `scaleX(${state.p})`;
140
+ };
141
+
142
+ // The plan from what is left of the budget. On time: the count, the beat, the full drop. Late: straight to a
143
+ // drop that ends on the budget.
144
+ const onTime = elapsed <= T.late;
145
+ const exit = onTime ? T.exit : Math.min(T.exit, room);
146
+ const count = onTime ? gsap.utils.clamp(T.minCount, T.count, BUDGET - elapsed - T.hold - T.exit - stagger - 0.03) : 0;
147
+ const hold = onTime ? T.hold : 0;
148
+ // Fonts and the first screen's photos may stretch the beat at 100, never past the budget.
149
+ const spare = onTime ? Math.max(0, BUDGET - elapsed - count - hold - exit - stagger - 0.03) : 0;
150
+ const firstScreen = Array.from(document.images).filter((img) => {
151
+ const r = img.getBoundingClientRect();
152
+ return r.bottom > 0 && r.top < window.innerHeight && !img.complete;
153
+ });
154
+ let ready = false;
155
+ let parked = false;
156
+ let ceiling = 0;
157
+ Promise.all([
158
+ document.fonts?.ready ?? Promise.resolve(),
159
+ ...firstScreen.map((img) => img.decode().catch(() => undefined)),
160
+ ]).then(() => {
161
+ ready = true;
162
+ if (parked && !cancelled) {
163
+ window.clearTimeout(ceiling);
164
+ tl.resume();
165
+ }
166
+ });
167
+
168
+ const tl = gsap.timeline();
169
+ if (count > 0) {
170
+ // One move from 000 to 100: an even ease with no rest in it, so the number never stalls on the way.
171
+ tl.to(state, { p: 1, duration: count, ease: "power1.inOut", onUpdate: render });
172
+ tl.add(() => {
173
+ if (ready || spare < 0.05) return;
174
+ parked = true;
175
+ tl.pause();
176
+ ceiling = window.setTimeout(() => tl.resume(), spare * 1000);
177
+ });
178
+ } else {
179
+ state.p = 1;
180
+ render();
181
+ }
182
+ tl.addLabel("exit", `+=${hold}`)
183
+ .add(() => {
184
+ // Hand over: the page's entrance starts as the first slice moves, and the top of the page shows first.
185
+ el.dataset.lifted = "";
186
+ releaseHold();
187
+ liftCurtain();
188
+ onReveal?.();
189
+ }, "exit")
190
+ // The face holds still against the falling slice, so the top edge wipes it and nothing of the cover's type
191
+ // ever travels into the page's title.
192
+ .to(sheets, { yPercent: PARK, duration: exit, ease: "power3.inOut", stagger: T.stagger }, "exit")
193
+ .to(faces, { yPercent: -PARK, duration: exit, ease: "power3.inOut", stagger: T.stagger }, "exit")
194
+ .add(() => {
195
+ // Persist last: the next load's arm script reads it.
196
+ finished = true;
197
+ try {
198
+ sessionStorage.setItem(SEEN_KEY, "1");
199
+ } catch {
200
+ // Private mode: it plays again on the next load.
201
+ }
202
+ setDone(true);
203
+ });
204
+
205
+ return () => {
206
+ cancelled = true;
207
+ window.clearTimeout(ceiling);
208
+ if (finished) return;
209
+ // Torn down before it finished. In strict mode's double mount the remount comes in the same commit and
210
+ // takes this back; a real unmount lets the page go: hold released, curtain lifted, entrances run.
211
+ playedThisDocument = false;
212
+ pendingTeardown = window.setTimeout(() => {
213
+ releaseHold();
214
+ if (covered()) liftCurtain();
215
+ }, 0);
216
+ };
217
+ },
218
+ { scope: root },
219
+ );
220
+
221
+ if (done) return null;
222
+
223
+ return (
224
+ <div
225
+ ref={root}
226
+ data-preloader="slices"
227
+ data-always={always ? "" : undefined}
228
+ aria-hidden="true"
229
+ suppressHydrationWarning
230
+ className={`@container fixed inset-0 z-[100] [--n:4] max-md:[--n:3] ${className}`}
231
+ style={COVER}
232
+ >
233
+ <script dangerouslySetInnerHTML={{ __html: ARM }} />
234
+ <style href="preloader-slices" precedence="default">
235
+ {GATE}
236
+ </style>
237
+ {/* One face, cut into strips. Every strip holds the whole face, offset to its own place, so the name and the
238
+ count read as one until the strips part. */}
239
+ {SLICES.map((i) => (
240
+ <div
241
+ key={i}
242
+ data-sheet=""
243
+ className={`absolute inset-y-0 text-on-photo ${COVER_BG} ${i === 3 ? "max-md:hidden" : ""}`}
244
+ style={slice(i)}
245
+ >
246
+ {/* The leading edge: a fixed 5px of accent along the top, outside the sheet, seen only while it moves. */}
247
+ <div className="absolute inset-x-0 bottom-full h-[5px] bg-accent" />
248
+ <div className="absolute inset-0 overflow-hidden">
249
+ <div className={COVER_INK} />
250
+ <div data-face="" className="absolute inset-y-0" style={{ left: `calc(${-i} * 100cqw / var(--n))`, width: "100cqw" }}>
251
+ <Face name={name} caption={caption} />
252
+ </div>
253
+ </div>
254
+ </div>
255
+ ))}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ /** One strip's place: an n-th of the width, a pixel wider so no seam of the page shows between two strips. */
261
+ function slice(i: number): CSSProperties {
262
+ return { left: `calc(${i} * 100cqw / var(--n))`, width: "calc(100cqw / var(--n) + 1px)" };
263
+ }
264
+
265
+ function Face({ name, caption }: { name?: string; caption?: string }) {
266
+ return (
267
+ <div className="absolute inset-0 flex flex-col justify-between px-6 pb-5 pt-5 md:px-[4vw] md:pb-[2.2vw] md:pt-[2.2vw]">
268
+ <div className="flex items-start justify-between gap-6">
269
+ {name ? (
270
+ <p className="font-display text-[18vw] leading-[0.86] tracking-[-0.04em] md:text-[9.5vw]">{name}</p>
271
+ ) : (
272
+ <span />
273
+ )}
274
+ {caption ? <p className="text-small mt-[0.4em] max-w-[22ch] text-right text-on-photo/65 max-md:hidden">{caption}</p> : null}
275
+ </div>
276
+ <div>
277
+ {caption ? <p className="text-small mb-3 text-on-photo/65 md:hidden">{caption}</p> : null}
278
+ <div className="relative h-px w-full bg-on-photo/20">
279
+ <div data-fill="" className="absolute inset-0 origin-left bg-accent" style={{ transform: "scaleX(0)" }} />
280
+ </div>
281
+ <div className="font-display mt-3 flex justify-end pb-[0.02em] pt-[0.1em] text-[31vw] leading-[0.8] tracking-[-0.045em] md:mt-[0.6vw] md:text-[15vw]">
282
+ <p data-count="" className="tabular-nums lining-nums">
283
+ 000
284
+ </p>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ );
289
+ }
@@ -0,0 +1,367 @@
1
+ "use client";
2
+ // preloader-stack (Struq preloader recipe)
3
+ // The first paint is the page's own ground, with the client's name and a quiet count where a small portrait frame
4
+ // will sit. Once the photos are decoded the frame appears on a hard cut, and photos cut inside it, five or six of them,
5
+ // each held a little shorter than the last, like a thumb running through a stack of contact prints, while the count
6
+ // runs to 100. The last photo is the hero's own. It holds for a beat, and then the frame grows from its small size to
7
+ // the exact place and crop of the hero image on the page, so the cover becomes the hero with no seam: when the frame
8
+ // lands the cover is gone and the same image simply stays. Everything outside the hero image is uncovered as the grow
9
+ // begins, and the page's own entrance starts there (whenUncovered).
10
+ //
11
+ // First paint to clear never exceeds 2.4 seconds: the flip gets whatever is left after hydration, the hero's beat and
12
+ // the grow, and it cuts fewer photos rather than wait or rush. Never a blank or half-decoded frame: every photo is
13
+ // decoded before its cut (the flip photos at frame size, the hero at hero size); a photo that is not ready in time is
14
+ // skipped, and if the hero itself is not ready in time the cover simply goes.
15
+ //
16
+ // Mount it once, in app/layout.tsx or on the page, with the hero's own photo, and mark the hero image's box:
17
+ // <PreloaderStack name="Noord" photos={images.card.slice(0, 5)} hero={heroImage} />
18
+ // <div data-stack-target className="relative h-[72svh]"><Image src={heroImage.src} fill sizes="100vw" className="object-cover" /></div>
19
+ // The landing matches the page exactly when the page's hero is an object-cover image (centred) filling that box with
20
+ // sizes="100vw", as above, and runs no scale or move of its own on load. A hero that wants its entrance in the cover's
21
+ // wake (helper in preloader-slices-uncovered.ts, the signal the slice pieces speak too):
22
+ // useGSAP((_, contextSafe) => whenUncovered(contextSafe!(() => { ...the entrance... })), { scope })
23
+ //
24
+ // The block owns its gate. A tiny script inside the cover runs while the browser parses it, before the first paint.
25
+ // Only with motion allowed and no earlier play this session does it arm the cover, close the curtain on <html>, hold
26
+ // the page still (wheel, touch and scroll keys) and start the page at the top. So without JavaScript, under reduced
27
+ // motion and on a revisit the cover never paints and nothing waits or holds. The cover's CSS lives in blocks.css (the
28
+ // "--- preloader-stack ---" block): from the first paint it shows the first print and runs the count on a CSS clock
29
+ // (one number, --stack-n), which the script takes over when it hydrates. If the bundle never arrives, that CSS hides
30
+ // the armed cover at 2.3 seconds and the hold lets go at 2.4; once the script runs, its own ceiling does the same.
31
+ // `always` plays it on every load (the lab).
32
+ import Image from "next/image";
33
+ import { useRef, useState } from "react";
34
+ import { gsap, useGSAP } from "../kit/gsap";
35
+ import type { StruqImage } from "../kit/images";
36
+ import { closeCurtain, covered, liftCurtain, whenUncovered } from "./preloader-slices-uncovered";
37
+
38
+ export { whenUncovered };
39
+
40
+ const SEEN_KEY = "struq:preloader-stack";
41
+
42
+ /** Seconds, measured from the first paint. */
43
+ const BUDGET = 2.4;
44
+ const T = {
45
+ start: 0.75, // the flip starts by this time after the paint, with whichever photos are decoded
46
+ first: 0.26, // the first photo's hold; each next one is held SPEEDUP times as long
47
+ speedup: 0.78,
48
+ minCut: 0.06, // a cut shorter than this reads as a flicker: drop a photo instead
49
+ hero: 0.18, // the beat on the hero's photo before the grow
50
+ grow: 0.9,
51
+ minGrow: 0.5,
52
+ lateFlip: 0.3, // a late start keeps at least this much flip, taken from the grow
53
+ lateGrow: 0.65, // the shortest grow a late start may take that time from
54
+ } as const;
55
+
56
+ /** Runs while the browser parses the cover, before the first paint. Never throws. */
57
+ const ARM = `(function(){try{var e=document.currentScript.parentElement;if(matchMedia("(prefers-reduced-motion: reduce)").matches)return;if(!e.hasAttribute("data-always")&&sessionStorage.getItem("${SEEN_KEY}")==="1")return;e.setAttribute("data-t0",String(performance.now()));e.setAttribute("data-armed","");document.documentElement.setAttribute("data-curtain","closed");if(!location.hash){history.scrollRestoration="manual";scrollTo(0,0)}var k={" ":1,PageDown:1,PageUp:1,ArrowDown:1,ArrowUp:1,Home:1,End:1},o={capture:true,passive:false},s=function(v){v.preventDefault();v.stopImmediatePropagation()},d=function(v){if(k[v.key])v.preventDefault()},r=function(){removeEventListener("wheel",s,o);removeEventListener("touchmove",s,o);removeEventListener("keydown",d,true);window.__preloaderStackHold=null};addEventListener("wheel",s,o);addEventListener("touchmove",s,o);addEventListener("keydown",d,true);window.__preloaderStackHold=r;setTimeout(r,${BUDGET * 1000})}catch(x){}})()`;
58
+
59
+ /** Runs after the frame is parsed: marks the cover data-first once the first print is loaded and decoded. */
60
+ const FIRST = `(function(){try{var r=document.currentScript.closest("[data-preloader]"),i=r&&r.querySelector("[data-cut] img");if(!i)return;var s=function(){r.setAttribute("data-first","")},g=function(){i.decode?i.decode().then(s,function(){}):s()};i.complete?g():i.addEventListener("load",g,{once:true})}catch(x){}})()`;
61
+
62
+ /** Runs after the caption is parsed: marks the cover data-fonts once the caption's own faces are loaded. */
63
+ const FONTS = `(function(){var r;try{r=document.currentScript.closest("[data-preloader]");var f=document.fonts,d=function(){r.setAttribute("data-fonts","")};if(!f)return d();Promise.all([].map.call(r.querySelectorAll("[data-caption] p"),function(p){var c=getComputedStyle(p);return f.load(c.fontStyle+" "+c.fontWeight+" 16px "+c.fontFamily)})).then(d,d)}catch(x){r&&r.setAttribute("data-fonts","")}})()`;
64
+
65
+ function releaseHold() {
66
+ (window as Window & { __preloaderStackHold?: (() => void) | null }).__preloaderStackHold?.();
67
+ }
68
+
69
+ type Box = { x: number; y: number; w: number; h: number };
70
+ const boxOf = (r: DOMRect): Box => ({ x: r.left, y: r.top, w: r.width, h: r.height });
71
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
72
+
73
+ /** Each photo held a little shorter than the last, fitted into `room` seconds; drops photos rather than flicker. */
74
+ function flipPlan(count: number, room: number): number[] {
75
+ for (let n = count; n > 0; n -= 1) {
76
+ const holds = Array.from({ length: n }, (_, i) => T.first * T.speedup ** i);
77
+ const sum = holds.reduce((a, b) => a + b, 0);
78
+ const fit = Math.min(1, room / sum);
79
+ if (holds[n - 1] * fit >= T.minCut) return holds.map((h) => h * fit);
80
+ }
81
+ return [];
82
+ }
83
+
84
+ /** Set once the cover has played in this document, so a remount never replays it. */
85
+ let playedThisDocument = false;
86
+ /** A teardown's lift, held back one task so a remount in the same commit (strict mode) can take it back. */
87
+ let pendingTeardown = 0;
88
+
89
+ type Props = {
90
+ /** The flip-through, in order: four to six photos of the client's work. */
91
+ photos: StruqImage[];
92
+ /** The hero's own photo, the one the page shows in its [data-stack-target] box. */
93
+ hero: StruqImage;
94
+ /** The hero image's box on the page; the frame lands on it. Without one it lands on the full screen. */
95
+ target?: string;
96
+ /** The client's name, under the frame. */
97
+ name?: string;
98
+ /** Play on every load, ignoring the session (the lab uses it). */
99
+ always?: boolean;
100
+ /** Called as the grow begins. */
101
+ onReveal?: () => void;
102
+ className?: string;
103
+ };
104
+
105
+ export function PreloaderStack({ photos, hero, target = "[data-stack-target]", name, always = false, onReveal, className = "" }: Props) {
106
+ const root = useRef<HTMLDivElement>(null);
107
+ const [done, setDone] = useState(false);
108
+
109
+ useGSAP(
110
+ () => {
111
+ const el = root.current;
112
+ if (!el) return;
113
+ window.clearTimeout(pendingTeardown);
114
+ const armed = el.hasAttribute("data-armed");
115
+ const t0 = Number(el.dataset.t0 ?? performance.now());
116
+ const since = () => Math.max(0, (performance.now() - t0) / 1000);
117
+ const quit = () => {
118
+ releaseHold();
119
+ if (covered()) liftCurtain();
120
+ setDone(true);
121
+ };
122
+ const gaveUp = armed && getComputedStyle(el).visibility === "hidden";
123
+ if (!armed || gaveUp || (playedThisDocument && !always)) return quit();
124
+ if (BUDGET - since() < T.minGrow + 0.1) return quit();
125
+
126
+ // A link to a spot further down: the page opens there, not on the hero, so there is nothing to land on.
127
+ const found = document.querySelector(target);
128
+ if (location.hash && found) {
129
+ const r = found.getBoundingClientRect();
130
+ if (r.bottom <= 0 || r.top >= window.innerHeight) return quit();
131
+ }
132
+
133
+ playedThisDocument = true;
134
+ // Take the count over from the CSS clock where it stands, then stop the clock (and its failsafe).
135
+ const count = { p: (parseInt(getComputedStyle(el).getPropertyValue("--stack-n"), 10) || 0) / 100 };
136
+ const render = () => el.style.setProperty("--stack-n", String(Math.round(count.p * 100)));
137
+ render();
138
+ el.dataset.live = "";
139
+ closeCurtain();
140
+ let finished = false;
141
+ let cancelled = false;
142
+ let poll = 0;
143
+ // The script's own ceiling, now that the CSS one is off: whatever happens, the cover is gone by then.
144
+ const ceiling = window.setTimeout(() => {
145
+ if (!finished) quit();
146
+ }, Math.max(0, BUDGET + 0.3 - since()) * 1000);
147
+
148
+ const q = gsap.utils.selector(el);
149
+ const ground = q("[data-ground]")[0] as HTMLElement;
150
+ const frame = q("[data-frame]")[0] as HTMLElement;
151
+ const cuts = q("[data-cut]") as HTMLElement[];
152
+ const layer = q("[data-hero-layer]")[0] as HTMLElement;
153
+ const box = q("[data-hero-box]")[0] as HTMLElement;
154
+ const heroImg = box.querySelector("img") as HTMLImageElement;
155
+ const caption = q("[data-caption]")[0] as HTMLElement | undefined;
156
+
157
+ // Decode everything up front: a cut never shows a half-drawn photo.
158
+ const decoded = cuts.map(() => false);
159
+ let heroReady = false;
160
+ cuts.forEach((c, i) => {
161
+ const img = c.querySelector("img");
162
+ img?.decode().then(() => (decoded[i] = true), () => undefined);
163
+ });
164
+ heroImg.decode().then(() => (heroReady = true), () => undefined);
165
+
166
+ const begin = () => {
167
+ if (cancelled) return;
168
+ const now = since();
169
+ // The grow and the hero's beat are fixed; the flip gets what is left, the grow shrinks only when it must.
170
+ let grow: number = T.grow;
171
+ let beat: number = T.hero;
172
+ let room = BUDGET - now - beat - grow - 0.04;
173
+ // A late start still gets a short flip: the grow gives up some of its length first.
174
+ if (room < T.lateFlip) {
175
+ grow = Math.max(T.lateGrow, T.grow - (T.lateFlip - room));
176
+ room = BUDGET - now - beat - grow - 0.04;
177
+ }
178
+ if (room < 0) {
179
+ grow = Math.min(T.grow, Math.max(T.minGrow, BUDGET - now - 0.12 - 0.04));
180
+ beat = Math.max(0.12, BUDGET - now - grow - 0.04);
181
+ if (now + beat + grow > BUDGET + 0.01) return quit();
182
+ }
183
+ const ready = cuts.filter((_, i) => decoded[i]);
184
+ const holds = room > 0 ? flipPlan(ready.length, room) : [];
185
+ const shown = ready.slice(ready.length - holds.length); // drop from the front, keep the last before the hero
186
+
187
+ // Geometry: the frame where it sits, the hero box where the page shows it.
188
+ const vw = el.clientWidth;
189
+ const vh = el.clientHeight;
190
+ const S = boxOf(frame.getBoundingClientRect());
191
+ const R = found ? boxOf(found.getBoundingClientRect()) : { x: 0, y: 0, w: vw, h: vh };
192
+ const iw = heroImg.naturalWidth || hero.width;
193
+ const ih = heroImg.naturalHeight || hero.height;
194
+ // A hero whose window shows a larger registered photo marks that photo's box (data-stack-photo, inside the
195
+ // target): the print is then laid as object-cover of that box, while the frame still lands on the target.
196
+ const photoEl = found?.querySelector<HTMLElement>("[data-stack-photo]");
197
+ const P = photoEl ? boxOf(photoEl.getBoundingClientRect()) : R;
198
+ // The photo at its own aspect, scaled and centred exactly as object-cover shows it in P. Seen through R it
199
+ // is the page's hero, pixel for pixel; seen through a smaller frame, scaled to cover that frame.
200
+ const s1 = Math.max(P.w / iw, P.h / ih);
201
+ Object.assign(box.style, {
202
+ left: `${P.x + P.w / 2 - (iw * s1) / 2}px`,
203
+ top: `${P.y + P.h / 2 - (ih * s1) / 2}px`,
204
+ width: `${iw * s1}px`,
205
+ height: `${ih * s1}px`,
206
+ });
207
+ // With a photo box the print goes from covering the small frame to its place in that box; the frame lands
208
+ // on R either way.
209
+ const sS = Math.max(S.w / iw, S.h / ih);
210
+ const grown = { p: 0 };
211
+ // The caption under the frame leaves before the growing frame can reach it: it slides down out of its mask,
212
+ // done by the grow's progress at which the frame's lower edge would first touch it (a quarter at most).
213
+ const capTop = caption?.getBoundingClientRect().top ?? vh;
214
+ const reach = R.y + R.h - (S.y + S.h);
215
+ const contact = reach > 0 ? Math.max(0, capTop - (S.y + S.h)) / reach : 1;
216
+ const capDone = Math.max(0.04, Math.min(0.25, contact * 0.8));
217
+ // The grow, from plain numbers on every frame: a clip for the frame, a transform for the photo inside it.
218
+ const paint = () => {
219
+ if (caption) caption.style.transform = `translateY(${Math.min(1, grown.p / capDone) * 110}%)`;
220
+ const F = {
221
+ x: lerp(S.x, R.x, grown.p),
222
+ y: lerp(S.y, R.y, grown.p),
223
+ w: lerp(S.w, R.w, grown.p),
224
+ h: lerp(S.h, R.h, grown.p),
225
+ };
226
+ let k = Math.max(F.w / iw, F.h / ih) / s1;
227
+ let dx = F.x + F.w / 2 - (R.x + R.w / 2);
228
+ let dy = F.y + F.h / 2 - (R.y + R.h / 2);
229
+ if (photoEl) {
230
+ k = lerp(sS, s1, grown.p) / s1;
231
+ dx = lerp(S.x + S.w / 2, P.x + P.w / 2, grown.p) - (P.x + P.w / 2);
232
+ dy = lerp(S.y + S.h / 2, P.y + P.h / 2, grown.p) - (P.y + P.h / 2);
233
+ }
234
+ box.style.transform = `translate(${dx}px, ${dy}px) scale(${k})`;
235
+ layer.style.clipPath = `inset(${F.y}px ${vw - F.x - F.w}px ${vh - F.y - F.h}px ${F.x}px)`;
236
+ };
237
+ paint();
238
+
239
+ const show = (cut: HTMLElement | null) => {
240
+ for (const c of cuts) c.style.visibility = c === cut ? "inherit" : "hidden";
241
+ };
242
+
243
+ const flip = holds.reduce((a, b) => a + b, 0);
244
+ const tl = gsap.timeline();
245
+ let at = 0;
246
+ shown.forEach((cut, i) => {
247
+ tl.add(() => show(cut), at);
248
+ at += holds[i];
249
+ });
250
+ // The count runs with the flip and lands on 100 with the hero's photo.
251
+ tl.to(count, { p: 1, duration: Math.max(0.01, flip), ease: "power1.in", onUpdate: render }, 0);
252
+ tl.add(() => {
253
+ show(null);
254
+ layer.style.visibility = "inherit";
255
+ }, at);
256
+ at += beat;
257
+ tl.add(() => {
258
+ // Hand over: everything around the hero image is the page's own from here, and its entrance starts.
259
+ el.dataset.lifted = "";
260
+ // Whole pixels, rounded outward: a clip edge on a fractional pixel is drawn soft, and the page's photo
261
+ // would show through it as a hairline along the box.
262
+ const edge = (v: number) => Math.max(0, Math.floor(v));
263
+ ground.style.clipPath = `inset(${edge(R.y)}px ${edge(vw - R.x - R.w)}px ${edge(vh - R.y - R.h)}px ${edge(R.x)}px)`;
264
+ releaseHold();
265
+ liftCurtain();
266
+ onReveal?.();
267
+ }, at);
268
+ tl.to(grown, { p: 1, duration: grow, ease: "power2.inOut", onUpdate: paint }, at);
269
+ tl.add(() => {
270
+ // Landed: the cover's photo now sits exactly over the page's, so the cover can go.
271
+ finished = true;
272
+ window.clearTimeout(ceiling);
273
+ try {
274
+ sessionStorage.setItem(SEEN_KEY, "1");
275
+ } catch {
276
+ // Private mode: it plays again on the next load.
277
+ }
278
+ setDone(true);
279
+ });
280
+ };
281
+
282
+ // Start once every photo is decoded, or at the latest comfortable moment with those that are. The hero must be
283
+ // ready: it waits for it as long as a short beat and grow still fit, and otherwise lets the page go.
284
+ const latestStart = Math.max(0, T.start - since());
285
+ const lastChance = BUDGET - T.minGrow - 0.16;
286
+ const wait = () => {
287
+ if (cancelled) return;
288
+ const all = heroReady && decoded.every(Boolean);
289
+ const elapsed = since();
290
+ if (all || (heroReady && elapsed >= T.start)) return begin();
291
+ if (!heroReady && elapsed >= lastChance) return quit();
292
+ poll = window.setTimeout(wait, 30);
293
+ };
294
+ poll = window.setTimeout(wait, Math.min(30, latestStart * 1000));
295
+
296
+ return () => {
297
+ cancelled = true;
298
+ window.clearTimeout(poll);
299
+ window.clearTimeout(ceiling);
300
+ if (finished) return;
301
+ // Torn down before it finished. In strict mode's double mount the remount comes in the same commit and
302
+ // takes this back; a real unmount lets the page go: hold released, curtain lifted, entrances run.
303
+ playedThisDocument = false;
304
+ pendingTeardown = window.setTimeout(() => {
305
+ releaseHold();
306
+ if (covered()) liftCurtain();
307
+ }, 0);
308
+ };
309
+ },
310
+ { scope: root },
311
+ );
312
+
313
+ if (done) return null;
314
+
315
+ return (
316
+ <div
317
+ ref={root}
318
+ data-preloader="stack"
319
+ data-always={always ? "" : undefined}
320
+ aria-hidden="true"
321
+ suppressHydrationWarning
322
+ className={`fixed inset-0 z-[100] ${className}`}
323
+ >
324
+ <script dangerouslySetInnerHTML={{ __html: ARM }} />
325
+ {/* The page's own ground, with the frame, the name and the count. */}
326
+ <div data-ground="" className="absolute inset-0 bg-surface-0 text-ink">
327
+ <div className="absolute left-1/2 top-[45%] w-[44vw] -translate-x-1/2 -translate-y-1/2 md:w-[19vw]">
328
+ <div data-frame="" className="relative aspect-[3/4] w-full overflow-hidden">
329
+ {photos.map((photo, i) => (
330
+ <div key={photo.src} data-cut="" className="invisible absolute inset-0">
331
+ {/* The first print shows from the first paint (blocks.css), so it is fetched first. */}
332
+ <Image
333
+ src={photo.src}
334
+ alt=""
335
+ fill
336
+ sizes="(min-width: 768px) 19vw, 44vw"
337
+ {...(i === 0 ? { priority: true } : { loading: "eager" as const })}
338
+ unoptimized={photo.unoptimized}
339
+ className="object-cover"
340
+ />
341
+ </div>
342
+ ))}
343
+ </div>
344
+ {/* Parsed right after the frame: the first print shows (blocks.css) only once it is loaded and decoded. */}
345
+ <script dangerouslySetInnerHTML={{ __html: FIRST }} />
346
+ {/* The caption, in a mask it leaves through as the frame starts to grow. */}
347
+ <div className="mt-3 overflow-hidden pb-[0.15em] md:mt-4">
348
+ <div data-caption="" className="flex items-baseline justify-between gap-4">
349
+ {name ? <p className="font-display text-h3 leading-none">{name}</p> : <span />}
350
+ {/* The count is one number, --stack-n, shown by blocks.css: its CSS clock runs from the first paint and
351
+ the script takes the same number over when it hydrates. */}
352
+ <p data-count="" className="text-small tabular-nums lining-nums text-ink-muted" />
353
+ </div>
354
+ </div>
355
+ {/* Parsed right after the caption: it shows (blocks.css) only once its own faces are in, never in a fallback. */}
356
+ <script dangerouslySetInnerHTML={{ __html: FONTS }} />
357
+ </div>
358
+ </div>
359
+ {/* The hero's photo over everything, seen through the growing frame. Same file and sizes as the page's hero. */}
360
+ <div data-hero-layer="" className="invisible absolute inset-0">
361
+ <div data-hero-box="" className="absolute origin-center">
362
+ <Image src={hero.src} alt="" fill sizes="100vw" priority unoptimized={hero.unoptimized} className="object-cover" />
363
+ </div>
364
+ </div>
365
+ </div>
366
+ );
367
+ }