@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,358 @@
1
+ "use client";
2
+ // preloader-gate (Struq preloader): the gate
3
+ // A film leader in the site's own voice. The first paint is the whole leader on a shut gate: two
4
+ // bars meeting on one line, a dial (two rings and a crosshair) and one figure in the display face.
5
+ // Only the sweep moves: the hand and an accent arc run once per figure, 3, 2, 1, on an eased count
6
+ // that waits for the fonts and the hero image. Then the leader drops away, the line through the
7
+ // middle runs out to both edges and lights in the accent, and the gate opens on it: the top bar
8
+ // rises, the bottom bar falls, each carrying half of the light, onto the page's own entrance.
9
+ //
10
+ // The clock starts at the first paint, not at hydration:
11
+ // 0.00 the gate shut, the leader whole; the count runs (CSS until React hydrates, then GSAP)
12
+ // 1.15 the count holds at its last beat until the fonts and the hero image are in (at most 0.15s)
13
+ // 1.15 the count completes (0.26s); the leader falls back; the line runs out and lights
14
+ // 1.45 the gate opens (0.78s, the curtain ease); the page's entrance starts 0.1s in
15
+ // 2.23 done, and never later than 2.4s after the first paint.
16
+ //
17
+ // Mount it first in <body> (app/layout.tsx), before the page it covers, and start the hero's own
18
+ // entrance with onGateOpen() inside its useGSAP, so the hero moves while the gate opens:
19
+ //
20
+ // <body><PreloaderGate name="Noord, Rotterdam" />{children}</body>
21
+ //
22
+ // It needs no `js` class or head script of its own setup: the gate renders a small inline script
23
+ // just before itself, which shows it (before the first paint) only when JS runs, motion is allowed
24
+ // and this session has not seen it; blocks.css keeps it display none otherwise. Without JS it is
25
+ // never painted. If the bundle never arrives, the CSS still opens it and removes it by 2.4s.
26
+ // `force` plays it on every load (the lab, a launch page). The tone is "deep" (a step darker than
27
+ // the page in every palette), "inverse" or "field".
28
+ import { useRef, useState, useSyncExternalStore } from "react";
29
+ import { gsap, useGSAP } from "../kit/gsap";
30
+
31
+ type Props = {
32
+ /** The site's name, set small in the lower left while the leader runs. */
33
+ name?: string;
34
+ /** Play on every load (the lab, a launch page). Default: once per session. */
35
+ force?: boolean;
36
+ tone?: "deep" | "inverse" | "field";
37
+ };
38
+
39
+ const KEY = "struq:gate";
40
+ const SHOW_ID = "struq-gate-show";
41
+ /** Fired on window when the gate starts to open (or at once when it will not play). */
42
+ export const GATE_OPEN_EVENT = "struq:gate-open";
43
+
44
+ /**
45
+ * A CSS cubic-bezier as a function, so GSAP can continue exactly the curve CSS started: one easing shape from the
46
+ * first paint to the last beat, whichever clock is drawing it.
47
+ */
48
+ function cubicBezier(x1: number, y1: number, x2: number, y2: number) {
49
+ const cx = 3 * x1;
50
+ const bx = 3 * (x2 - x1) - cx;
51
+ const ax = 1 - cx - bx;
52
+ const cy = 3 * y1;
53
+ const by = 3 * (y2 - y1) - cy;
54
+ const ay = 1 - cy - by;
55
+ const x = (t: number) => ((ax * t + bx) * t + cx) * t;
56
+ const y = (t: number) => ((ay * t + by) * t + cy) * t;
57
+ const slope = (t: number) => (3 * ax * t + 2 * bx) * t + cx;
58
+ return (progress: number) => {
59
+ const target = Math.min(1, Math.max(0, progress));
60
+ let t = target;
61
+ for (let i = 0; i < 8; i += 1) {
62
+ const error = x(t) - target;
63
+ const d = slope(t);
64
+ if (Math.abs(error) < 1e-6 || Math.abs(d) < 1e-6) break;
65
+ t -= error / d;
66
+ }
67
+ return y(Math.min(1, Math.max(0, t)));
68
+ };
69
+ }
70
+
71
+ /** The count's floor: 0 to 0.86 over 1.15s, in-out, as Lowlight. The same curve as blocks.css struq-gate-count. */
72
+ const FLOOR_CURVE = cubicBezier(0.45, 0, 0.55, 1);
73
+ const FLOOR_P = 0.86;
74
+
75
+ /** The schedule, in seconds from the first paint; blocks.css runs the same one when no script takes over. */
76
+ const FLOOR = 1.15;
77
+ const LATEST = 1.3;
78
+ const BUDGET = 2.4;
79
+ /** Hydrating later than this, the CSS clock has started the exit: the script lets it finish. */
80
+ const TAKEOVER = 1.4;
81
+
82
+ /**
83
+ * Runs in the HTML before the gate is parsed, so before its first paint: it shows the gate only with motion
84
+ * allowed and an unseen session (or force), and marks the first paint for the script that takes the clock over.
85
+ */
86
+ const showScript = (force: boolean) =>
87
+ `try{if(!matchMedia("(prefers-reduced-motion: reduce)").matches&&(${force}||sessionStorage.getItem("${KEY}")!=="1")){window.__struqGateT0=performance.now();var s=document.createElement("style");s.id="${SHOW_ID}";s.textContent="[data-preloader-gate]{display:block!important}";document.head.appendChild(s)}}catch(e){}`;
88
+
89
+ /** True while the gate is shut over the page: set an entrance's start state only then, so nothing blinks on a revisit. */
90
+ export function preloaderCovers(): boolean {
91
+ const gate = document.querySelector<HTMLElement>("[data-preloader-gate]");
92
+ return Boolean(gate && gate.dataset.phase === "shut" && getComputedStyle(gate).display !== "none");
93
+ }
94
+
95
+ /**
96
+ * Calls `ready` when the gate starts to open, or at once when no gate covers the page (seen this
97
+ * session, reduced motion, no gate mounted). Returns the cancel. Use it for the hero's entrance.
98
+ */
99
+ export function onGateOpen(ready: () => void): () => void {
100
+ if (!preloaderCovers()) {
101
+ ready();
102
+ return () => {};
103
+ }
104
+ let fired = false;
105
+ const fire = () => {
106
+ if (fired) return;
107
+ fired = true;
108
+ window.removeEventListener(GATE_OPEN_EVENT, fire);
109
+ ceiling.kill();
110
+ ready();
111
+ };
112
+ window.addEventListener(GATE_OPEN_EVENT, fire);
113
+ // A gate that never reports can never keep the page blank.
114
+ const ceiling = gsap.delayedCall(BUDGET, fire);
115
+ return () => {
116
+ fired = true;
117
+ window.removeEventListener(GATE_OPEN_EVENT, fire);
118
+ ceiling.kill();
119
+ };
120
+ }
121
+
122
+ /** Keys that scroll the page; held while the gate is shut, so the page cannot move under it. */
123
+ const SCROLL_KEYS = new Set([" ", "PageDown", "PageUp", "ArrowDown", "ArrowUp", "Home", "End"]);
124
+ function holdScroll() {
125
+ const stop = (e: Event) => {
126
+ if (e instanceof KeyboardEvent && !SCROLL_KEYS.has(e.key)) return;
127
+ e.preventDefault();
128
+ e.stopPropagation();
129
+ };
130
+ const opts = { capture: true, passive: false } as const;
131
+ window.addEventListener("wheel", stop, opts);
132
+ window.addEventListener("touchmove", stop, opts);
133
+ window.addEventListener("keydown", stop, opts);
134
+ return () => {
135
+ window.removeEventListener("wheel", stop, opts);
136
+ window.removeEventListener("touchmove", stop, opts);
137
+ window.removeEventListener("keydown", stop, opts);
138
+ };
139
+ }
140
+
141
+ /** The script exists only in the server HTML and the hydration pass; a client-side mount never renders one. */
142
+ const noop = () => () => {};
143
+ function useServerPass() {
144
+ return useSyncExternalStore(
145
+ noop,
146
+ () => false,
147
+ () => true,
148
+ );
149
+ }
150
+
151
+ const DIGITS = ["3", "2", "1"];
152
+
153
+ export function PreloaderGate({ name, force = false, tone = "deep" }: Props) {
154
+ const root = useRef<HTMLDivElement>(null);
155
+ const [done, setDone] = useState(false);
156
+ const serverPass = useServerPass();
157
+
158
+ useGSAP(
159
+ () => {
160
+ const el = root.current;
161
+ if (!el) return;
162
+ const open = () => {
163
+ el.dataset.phase = "open";
164
+ window.dispatchEvent(new Event(GATE_OPEN_EVENT));
165
+ };
166
+ const finish = () => {
167
+ if (!force) {
168
+ try {
169
+ sessionStorage.setItem(KEY, "1");
170
+ } catch {
171
+ // Private mode: the gate plays again on the next load. Acceptable.
172
+ }
173
+ }
174
+ document.getElementById(SHOW_ID)?.remove();
175
+ setDone(true);
176
+ };
177
+ // The inline script decided: not shown means it never plays (no motion, seen, or a client-side mount).
178
+ if (getComputedStyle(el).display === "none") {
179
+ open();
180
+ setDone(true);
181
+ return;
182
+ }
183
+
184
+ const t0 = (window as Window & { __struqGateT0?: number }).__struqGateT0 ?? performance.now();
185
+ const elapsed = (performance.now() - t0) / 1000;
186
+ const release = holdScroll();
187
+ window.scrollTo(0, 0);
188
+
189
+ // Hydrated late: the CSS clock is already into the exit. Let it finish, keep the hand-over on time.
190
+ if (elapsed >= TAKEOVER) {
191
+ const handover = gsap.delayedCall(Math.max(0, 1.7 - elapsed), open);
192
+ const end = gsap.delayedCall(Math.max(0, BUDGET - elapsed), () => {
193
+ release();
194
+ finish();
195
+ });
196
+ return () => {
197
+ handover.kill();
198
+ end.kill();
199
+ release();
200
+ };
201
+ }
202
+
203
+ // Take the count over from the CSS clock: the same number, now written by GSAP.
204
+ const q = gsap.utils.selector(el);
205
+ const [top, bottom] = q("[data-bar]");
206
+ const edges = q("[data-edge]");
207
+ const lights = q("[data-light]") as HTMLElement[];
208
+ const leader = q("[data-leader]")[0] as HTMLElement;
209
+ const count = { p: parseFloat(getComputedStyle(el).getPropertyValue("--gate-p")) || 0 };
210
+ const paint = () => el.style.setProperty("--gate-p", count.p.toFixed(4));
211
+ el.dataset.driven = "";
212
+ lights.forEach((l) => (l.style.scale = "none"));
213
+ gsap.set(lights, { scaleX: 0 });
214
+ paint();
215
+
216
+ // The hero and the fonts, so the gate opens onto a finished picture.
217
+ const hero = Array.from(document.images).find(
218
+ (img) => !el.contains(img) && !img.complete && img.getBoundingClientRect().top < window.innerHeight,
219
+ );
220
+ const ready = Promise.all([document.fonts?.ready, hero?.decode().catch(() => undefined)]);
221
+
222
+ const tl = gsap.timeline({
223
+ onComplete: () => {
224
+ release();
225
+ finish();
226
+ },
227
+ });
228
+ // The rest of the eased floor on the very curve the CSS clock was drawing (blocks.css, struq-gate-count), read
229
+ // at the same moment from the first paint, so the count never changes pace at the hand-over. Then a hold on
230
+ // the last beat while the page is not ready.
231
+ if (elapsed < FLOOR) {
232
+ const clock = { t: elapsed };
233
+ const onFloor = () => {
234
+ count.p = FLOOR_P * FLOOR_CURVE(clock.t / FLOOR);
235
+ paint();
236
+ };
237
+ onFloor();
238
+ tl.to(clock, { t: FLOOR, duration: FLOOR - elapsed, ease: "none", onUpdate: onFloor });
239
+ } else {
240
+ tl.to({}, { duration: 0.01 }); // a pause at time zero never fires
241
+ }
242
+ tl.addPause("+=0", () => {
243
+ const now = (performance.now() - t0) / 1000;
244
+ let resumed = false;
245
+ const go = () => {
246
+ if (resumed) return;
247
+ resumed = true;
248
+ cap.kill();
249
+ tl.resume();
250
+ };
251
+ const cap = gsap.delayedCall(Math.max(0, LATEST - now), go);
252
+ ready.then(go, go);
253
+ });
254
+
255
+ // The count completes, the leader falls away, the line runs out and lights, the gate opens on it.
256
+ const span = window.innerWidth / Math.max(1, (edges[0] as HTMLElement).offsetWidth);
257
+ tl.addLabel("exit")
258
+ .to(count, { p: 1, duration: 0.26, ease: "power2.out", onUpdate: paint }, "exit")
259
+ .to(leader, { scale: 0.92, autoAlpha: 0, duration: 0.3, ease: "power2.in" }, "exit+=0.2")
260
+ .to(q("[data-name]"), { autoAlpha: 0, duration: 0.3, ease: "power2.in" }, "exit+=0.2")
261
+ .to(edges, { scaleX: span, duration: 0.42, ease: "power3.inOut" }, "exit+=0.2")
262
+ .to(lights, { scaleX: 1, duration: 0.4, ease: "expo.out" }, "exit+=0.26")
263
+ .to(top, { yPercent: -100, duration: 0.78, ease: "power4.inOut" }, "exit+=0.3")
264
+ .to(bottom, { yPercent: 100, duration: 0.78, ease: "power4.inOut" }, "exit+=0.3")
265
+ .call(open, [], "exit+=0.4");
266
+
267
+ // The count stays where it was (data-driven and the inline number are kept), so a second run in strict mode
268
+ // picks the leader up mid-beat instead of restarting the CSS clock.
269
+ return () => {
270
+ tl.kill();
271
+ release();
272
+ };
273
+ },
274
+ { scope: root },
275
+ );
276
+
277
+ if (done) return null;
278
+
279
+ // Hairlines on the gate in its own ink, strong enough to hold on the darkest ground. Never a glow, never a fill.
280
+ const ring = "color-mix(in oklch, var(--on-gate) 46%, transparent)";
281
+ const inner = "color-mix(in oklch, var(--on-gate) 30%, transparent)";
282
+ const cross = "color-mix(in oklch, var(--on-gate) 24%, transparent)";
283
+ return (
284
+ <>
285
+ {serverPass ? <script dangerouslySetInnerHTML={{ __html: showScript(force) }} /> : null}
286
+ <div
287
+ ref={root}
288
+ data-preloader-gate=""
289
+ data-gate=""
290
+ data-tone={tone}
291
+ data-phase="shut"
292
+ aria-hidden="true"
293
+ className="fixed inset-0 z-[100] select-none"
294
+ >
295
+ {(["top", "bottom"] as const).map((side) => (
296
+ <div
297
+ key={side}
298
+ data-bar={side}
299
+ className={`absolute inset-x-0 h-1/2 bg-[var(--gate)] ${side === "top" ? "top-0" : "bottom-0 shadow-[0_-1px_0_0_var(--gate)]"}`}
300
+ >
301
+ {/* Half of the crosshair's line across rides on each bar's inner edge; at the exit it runs to both
302
+ edges and the accent lights along it. */}
303
+ <div
304
+ data-edge=""
305
+ className={`absolute inset-x-0 mx-auto h-px w-[min(89.6vw,30.8rem)] ${side === "top" ? "bottom-0" : "top-0"}`}
306
+ style={{ background: cross }}
307
+ >
308
+ <div data-light="" className={`absolute inset-x-0 h-[2px] bg-accent ${side === "top" ? "bottom-0" : "top-0"}`} />
309
+ </div>
310
+ </div>
311
+ ))}
312
+
313
+ <div data-leader="" className="absolute left-1/2 top-1/2 aspect-square w-[min(64vw,22rem)] -translate-x-1/2 -translate-y-1/2">
314
+ <svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible" fill="none">
315
+ <circle cx="100" cy="100" r="92" style={{ stroke: ring, strokeWidth: 0.8 }} />
316
+ <circle cx="100" cy="100" r="78" style={{ stroke: inner, strokeWidth: 0.7 }} />
317
+ <line x1="100" y1="-14" x2="100" y2="214" style={{ stroke: cross, strokeWidth: 0.7 }} />
318
+ {/* The sweep: one accent arc per figure, drawn round the inner ring as the hand passes. */}
319
+ {DIGITS.map((d, i) => (
320
+ <circle
321
+ key={d}
322
+ data-beat=""
323
+ data-sweep=""
324
+ cx="100"
325
+ cy="100"
326
+ r="78"
327
+ pathLength={1}
328
+ strokeDasharray="1 1"
329
+ transform="rotate(-90 100 100)"
330
+ className="stroke-accent"
331
+ style={{ strokeWidth: 1.4, ["--i" as string]: i }}
332
+ />
333
+ ))}
334
+ <line data-hand="" x1="100" y1="100" x2="100" y2="8" className="stroke-accent" style={{ strokeWidth: 1.1 }} />
335
+ </svg>
336
+
337
+ {/* One figure at a time, cut on the beat. */}
338
+ <div className="absolute inset-0 grid place-items-center font-display text-[min(32vw,10.5rem)] leading-none tracking-[-0.02em] text-[var(--on-gate)] [font-variant-numeric:lining-nums]">
339
+ {DIGITS.map((d, i) => (
340
+ <span key={d} data-beat="" className="[grid-area:1/1]" style={{ ["--i" as string]: i }}>
341
+ {d}
342
+ </span>
343
+ ))}
344
+ </div>
345
+ </div>
346
+
347
+ {name ? (
348
+ <p
349
+ data-name=""
350
+ className="absolute bottom-[max(1.5rem,4vh)] left-6 text-small text-[color-mix(in_oklch,var(--on-gate)_64%,transparent)] md:left-[3vw]"
351
+ >
352
+ {name}
353
+ </p>
354
+ ) : null}
355
+ </div>
356
+ </>
357
+ );
358
+ }
@@ -0,0 +1,50 @@
1
+ // preloader-slices-uncovered (Struq helper, ships with preloader-slices and page-transition-slices)
2
+ // The one handover signal of the slice language. While a cover is closed, <html> carries data-curtain="closed"; the
3
+ // moment the slices start to leave, the attribute goes and "curtain:lift" fires on window. A hero runs its entrance
4
+ // through whenUncovered, so the same entrance serves the first load and every navigation.
5
+ //
6
+ // useGSAP((_, contextSafe) => whenUncovered(contextSafe!(() => { ...the entrance... })), { scope })
7
+
8
+ export const CURTAIN_LIFT = "curtain:lift";
9
+
10
+ /** True while a slice cover is closed over the page. */
11
+ export function covered(): boolean {
12
+ return document.documentElement.dataset.curtain === "closed";
13
+ }
14
+
15
+ /** Marks the cover closed: whenUncovered now waits. */
16
+ export function closeCurtain(): void {
17
+ document.documentElement.dataset.curtain = "closed";
18
+ }
19
+
20
+ /** The slices start to leave: whatever waits in whenUncovered runs now. */
21
+ export function liftCurtain(): void {
22
+ delete document.documentElement.dataset.curtain;
23
+ window.dispatchEvent(new Event(CURTAIN_LIFT));
24
+ }
25
+
26
+ /**
27
+ * Runs `play` once nothing covers the page: at once when nothing does, or the moment the slices start to leave.
28
+ * The ceiling keeps a cover that never reports from leaving the page without its entrance. Returns the cancel.
29
+ */
30
+ export function whenUncovered(play: () => void, ceilingMs = 4000): () => void {
31
+ if (!covered()) {
32
+ play();
33
+ return () => {};
34
+ }
35
+ let fired = false;
36
+ const fire = () => {
37
+ if (fired) return;
38
+ fired = true;
39
+ window.removeEventListener(CURTAIN_LIFT, fire);
40
+ window.clearTimeout(ceiling);
41
+ play();
42
+ };
43
+ window.addEventListener(CURTAIN_LIFT, fire);
44
+ const ceiling = window.setTimeout(fire, ceilingMs);
45
+ return () => {
46
+ fired = true;
47
+ window.removeEventListener(CURTAIN_LIFT, fire);
48
+ window.clearTimeout(ceiling);
49
+ };
50
+ }