@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,469 @@
1
+ "use client";
2
+ // page-transition-slices (Struq page-transition recipe)
3
+ // The slice language of preloader-slices on every navigation. The slices always travel one way, downward: they come
4
+ // in from above, left to right, a 5px accent line on each leading edge, and close over the page carrying the
5
+ // destination's name in the display face with the preloader's hairline under it; then they carry on down and off,
6
+ // so the new page shows from the top, its title first, while its own entrance starts (whenUncovered). About 0.35s to
7
+ // close and 0.4s to leave. A slow route keeps the hairline moving under the name.
8
+ //
9
+ // Clicks: the route is fetched from the click on, under the closing cover, and RouteWatch holds the new page back
10
+ // until the cover is shut (a router navigation is a transition, and a transition that suspends keeps the old page).
11
+ // Back and forward: React commits a navigation started in a popstate at once, so no hold can stop it there. The
12
+ // popstate is caught first instead, the slices cover the page being left, and only then is the event handed to the
13
+ // router. Either way the reader sees: the page they leave is covered, the page they reach is uncovered.
14
+ //
15
+ // Mount it once, in app/layout.tsx, around {children}:
16
+ // <PageTransitionSlices name="Noord" labels={{ "/werk": "Werk", "/studio": "De studio" }}>{children}</PageTransitionSlices>
17
+ // Every same-origin link click is taken over (next/link or a plain <a>), so nothing else changes. A link opts out with
18
+ // data-no-transition; modified clicks, new tabs, downloads, other origins and links to a spot on the same page are left
19
+ // alone. For code that navigates: const go = useSliceNavigate(); go("/werk").
20
+ //
21
+ // The cover always names where you are going, never blank. In order: the link's data-transition-label, the `labels`
22
+ // map (by path), the name the page gave itself on an earlier visit (data-transition-title on any element, read on
23
+ // every arrival), the last part of the path in words ("/werk/de-spinnerij" reads "De spinnerij"), and for the home
24
+ // page `name`.
25
+ // whenUncovered (preloader-slices-uncovered.ts, shared with the preloader) times a page's entrance to the reveal.
26
+ //
27
+ // Scroll: the browser's own restoration is off while this is mounted, because it would jump the old page before the
28
+ // cover is down. A push lands at the top, or on its #hash; back and forward land where the reader left that page.
29
+ // Focus moves to the #hash target, else [data-transition-focus], else the h1, else main, without scrolling.
30
+ // Reduced motion and no JS: plain navigation, no cover.
31
+ import {
32
+ Suspense,
33
+ createContext,
34
+ use,
35
+ useCallback,
36
+ useContext,
37
+ useEffect,
38
+ useRef,
39
+ useState,
40
+ type CSSProperties,
41
+ } from "react";
42
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
43
+ import { gsap, useGSAP } from "../kit/gsap";
44
+ import { getLenis } from "../kit/SmoothScroll";
45
+ import { closeCurtain, liftCurtain, whenUncovered } from "../preloaders/preloader-slices-uncovered";
46
+
47
+ export { whenUncovered };
48
+
49
+ const T = {
50
+ cover: 0.26, // one slice coming in
51
+ leave: 0.3, // one slice leaving
52
+ stagger: 0.03,
53
+ settle: 0.06, // the longest the new page's first photos may hold the reveal
54
+ stall: 8, // the longest the cover waits for a route that never arrives
55
+ } as const;
56
+
57
+ const SLICES = [0, 1, 2, 3];
58
+
59
+ /** Where a slice rests off screen: a little past it, so its 5px accent edge is out of view too. */
60
+ const PARK = 102;
61
+
62
+ /** The cover's ground, exactly as in preloader-slices: always the dark of the palette. */
63
+ const COVER = {
64
+ "--slices-base": "oklch(from var(--surface-0) min(l + 0.07, 0.27) c h)",
65
+ "--slices-ink": "oklch(from var(--inverse) l c h / clamp(0, (0.5 - l) * 100, 1))",
66
+ } as CSSProperties;
67
+ // Written out in full, so Tailwind finds the classes.
68
+ const COVER_BG = "bg-inverse supports-[color:oklch(from_red_l_c_h)]:bg-[var(--slices-base)]";
69
+ const COVER_INK = "absolute inset-0 hidden bg-[var(--slices-ink)] supports-[color:oklch(from_red_l_c_h)]:block";
70
+
71
+ type Navigate = (href: string, label?: string) => void;
72
+ const SlicesContext = createContext<Navigate | null>(null);
73
+
74
+ /** router.push through the slices, optionally with the name the cover shows. Outside the provider: router.push. */
75
+ export function useSliceNavigate(): Navigate {
76
+ const navigate = useContext(SlicesContext);
77
+ const router = useRouter();
78
+ return navigate ?? ((href: string) => router.push(href));
79
+ }
80
+
81
+ type Phase = "idle" | "covering" | "covered" | "leaving";
82
+
83
+ /** A click's hold: the route on screen when it began, and the promise that settles when the cover is shut. */
84
+ type Gate = { from: string; shut: Promise<void> };
85
+
86
+ /** The history entry on screen: the Navigation API's key where there is one, else the URL. */
87
+ function entryKey(): string {
88
+ const nav = (window as Window & { navigation?: { currentEntry?: { key?: string } } }).navigation;
89
+ return nav?.currentEntry?.key ?? location.pathname + location.search;
90
+ }
91
+
92
+ /** Path and query, the query spelled the way useSearchParams spells it, so the two always compare equal. */
93
+ function routeOf(url: URL | Location): string {
94
+ const search = new URLSearchParams(url.search).toString();
95
+ return url.pathname + (search ? `?${search}` : "");
96
+ }
97
+
98
+ /** The last part of a path in words: "/werk/de-spinnerij" reads "De spinnerij". */
99
+ function wordsOf(pathname: string): string {
100
+ const last = decodeURIComponent(pathname.split("/").filter(Boolean).pop() ?? "").replace(/[-_]+/g, " ").trim();
101
+ return last ? last.charAt(0).toUpperCase() + last.slice(1) : "";
102
+ }
103
+
104
+ export function PageTransitionSlices({
105
+ children,
106
+ name = "Home",
107
+ labels,
108
+ }: {
109
+ children: React.ReactNode;
110
+ /** The site's name: the cover's word for the home page. */
111
+ name?: string;
112
+ /** The cover's word per path, for pages no link names (e.g. { "/werk": "Werk" }). */
113
+ labels?: Record<string, string>;
114
+ }) {
115
+ const root = useRef<HTMLDivElement>(null);
116
+ const router = useRouter();
117
+ const [gate, setGate] = useState<Gate | null>(null);
118
+ const api = useRef<{ navigate: Navigate; arrived: (route: string) => void } | null>(null);
119
+ // Read by the engine at click time; kept current without restarting it.
120
+ const words = useRef({ name, labels });
121
+ useEffect(() => {
122
+ words.current = { name, labels };
123
+ }, [name, labels]);
124
+
125
+ useGSAP(
126
+ (_, contextSafe) => {
127
+ const el = root.current;
128
+ if (!el || !contextSafe) return;
129
+ const q = gsap.utils.selector(el);
130
+ const live = (list: Element[]) => list.filter((n) => getComputedStyle(n).display !== "none");
131
+ const reduce = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
132
+
133
+ // The engine's state between events.
134
+ let phase: Phase = "idle";
135
+ let kind: "push" | "pop" | null = null;
136
+ let hash = "";
137
+ let shown = routeOf(location); // the route on screen
138
+ let shownKey = entryKey(); // its history entry
139
+ let early = false; // the route arrived before the cover was shut
140
+ let replaying = false;
141
+ let stall = 0;
142
+ let shut: Promise<void> | null = null;
143
+ let open = () => {};
144
+ const scrolls = new Map<string, number>();
145
+ const learnt = new Map<string, string>(); // names pages gave themselves, by route
146
+
147
+ /** Remember the name the page on screen gives itself, for the next time a cover leads back to it. */
148
+ const learn = () => {
149
+ const own = document.querySelector<HTMLElement>("[data-transition-title]")?.dataset.transitionTitle?.trim();
150
+ if (own) learnt.set(shown, own);
151
+ };
152
+ learn();
153
+
154
+ /** The cover's word for a destination: never blank. */
155
+ const wordFor = (url: URL, given?: string | null) => {
156
+ const { name: home, labels: map } = words.current;
157
+ return (
158
+ given?.trim() ||
159
+ map?.[url.pathname] ||
160
+ learnt.get(routeOf(url)) ||
161
+ wordsOf(url.pathname) ||
162
+ home
163
+ );
164
+ };
165
+
166
+ const restoration = history.scrollRestoration;
167
+ history.scrollRestoration = "manual";
168
+ gsap.set(q("[data-sheet]"), { yPercent: -PARK });
169
+
170
+ const scrollTo = (y: number) => {
171
+ const lenis = getLenis();
172
+ if (lenis) lenis.scrollTo(y, { immediate: true, force: true });
173
+ else window.scrollTo(0, y);
174
+ };
175
+
176
+ // One motion at a time: a click while the slices leave turns them back from wherever they are.
177
+ let motion: gsap.core.Timeline | null = null;
178
+ let creep: gsap.core.Tween | null = null;
179
+ const line = { p: 0 };
180
+ const paintLine = () => {
181
+ for (const f of q("[data-fill]")) f.style.transform = `scaleX(${line.p})`;
182
+ };
183
+
184
+ /** Close the cover over the page on screen; `then` runs once it is shut. */
185
+ const cover = contextSafe((word: string, then?: () => void) => {
186
+ for (const n of q("[data-label]")) n.textContent = word;
187
+ if (!shut) shut = new Promise<void>((resolve) => (open = resolve));
188
+ if (phase === "covered") {
189
+ then?.();
190
+ return;
191
+ }
192
+ afterShut = then ?? null;
193
+ if (phase === "covering") return; // the running cover takes the newest route
194
+ const wasLeaving = phase === "leaving";
195
+ phase = "covering";
196
+ el.dataset.active = "";
197
+ const sheets = live(q("[data-sheet]"));
198
+ motion?.kill();
199
+ creep?.kill();
200
+ // In from above. A cover that was still leaving turns back from where it is; anything else starts out of view.
201
+ if (!wasLeaving) gsap.set(sheets, { yPercent: -PARK });
202
+ line.p = 0;
203
+ paintLine();
204
+ motion = gsap
205
+ .timeline({
206
+ onComplete: () => {
207
+ phase = "covered";
208
+ closeCurtain();
209
+ open();
210
+ const next = afterShut;
211
+ afterShut = null;
212
+ next?.();
213
+ if (early) return leave();
214
+ // A slow route: the hairline keeps going under the name, so the closed cover is never a still slab.
215
+ creep = gsap.to(line, { p: 0.94, duration: 4, ease: "power2.out", onUpdate: paintLine });
216
+ window.clearTimeout(stall);
217
+ stall = window.setTimeout(() => leave(), T.stall * 1000);
218
+ },
219
+ })
220
+ .to(sheets, { yPercent: 0, duration: T.cover, ease: "power3.inOut", stagger: T.stagger }, 0)
221
+ .to(line, { p: 0.55, duration: T.cover + T.stagger * 3, ease: "power2.out", onUpdate: paintLine }, 0);
222
+ });
223
+ let afterShut: (() => void) | null = null;
224
+
225
+ const leave = contextSafe(() => {
226
+ window.clearTimeout(stall);
227
+ creep?.kill();
228
+ phase = "leaving";
229
+ const was = kind ?? "push";
230
+ const target = hash ? document.getElementById(decodeURIComponent(hash.slice(1))) : null;
231
+ kind = null;
232
+ hash = "";
233
+ shut = null;
234
+ early = false;
235
+ setGate(null);
236
+ shown = routeOf(location);
237
+ shownKey = entryKey();
238
+ learn();
239
+ if (was === "pop") scrollTo(scrolls.get(shownKey) ?? 0);
240
+ else scrollTo(target ? target.getBoundingClientRect().top + window.scrollY : 0);
241
+ focusPage(target);
242
+
243
+ const go = contextSafe(() => {
244
+ liftCurtain();
245
+ const sheets = live(q("[data-sheet]"));
246
+ motion?.kill();
247
+ motion = gsap
248
+ .timeline({
249
+ onComplete: () => {
250
+ phase = "idle";
251
+ delete el.dataset.active;
252
+ gsap.set(sheets, { yPercent: -PARK });
253
+ },
254
+ })
255
+ .to(line, { p: 1, duration: 0.18, ease: "power2.out", onUpdate: paintLine }, 0)
256
+ .to(sheets, { yPercent: PARK, duration: T.leave, ease: "power3.inOut", stagger: T.stagger }, 0);
257
+ });
258
+ // A breath for the new page's first photos, capped, then away.
259
+ const photos = Array.from(document.images).filter((img) => {
260
+ const r = img.getBoundingClientRect();
261
+ return !img.complete && r.bottom > 0 && r.top < window.innerHeight;
262
+ });
263
+ Promise.race([
264
+ Promise.all(photos.map((img) => img.decode().catch(() => undefined))),
265
+ new Promise((resolve) => window.setTimeout(resolve, T.settle * 1000)),
266
+ ]).then(() => requestAnimationFrame(() => go()));
267
+ });
268
+
269
+ const navigate: Navigate = (href, label) => {
270
+ const url = new URL(href, location.href);
271
+ if (url.origin !== location.origin) {
272
+ location.assign(url.href);
273
+ return;
274
+ }
275
+ if (reduce()) {
276
+ router.push(href);
277
+ return;
278
+ }
279
+ if (phase === "idle" || phase === "leaving") {
280
+ if (routeOf(url) === shown) return; // already here
281
+ scrolls.set(shownKey, window.scrollY);
282
+ }
283
+ kind = "push";
284
+ hash = url.hash;
285
+ cover(wordFor(url, label));
286
+ // The route is fetched from now on, under the closing cover; RouteWatch holds it back until the cover is shut.
287
+ setGate({ from: shown, shut: shut! });
288
+ router.push(url.pathname + url.search + url.hash, { scroll: false });
289
+ };
290
+
291
+ const arrived = (route: string) => {
292
+ if (route === shown) return;
293
+ if (phase === "covered" && kind) return leave();
294
+ if (phase === "covering" && kind) {
295
+ early = true;
296
+ return;
297
+ }
298
+ // A navigation the slices did not run (reduced motion, router.replace, a form): take it as it is, with the
299
+ // scroll a back or forward press should land on.
300
+ if (phase === "idle") {
301
+ const wasPop = kind === "pop";
302
+ kind = null;
303
+ shown = route;
304
+ shownKey = entryKey();
305
+ learn();
306
+ if (wasPop) scrollTo(scrolls.get(shownKey) ?? 0);
307
+ if (reduce()) focusPage(null);
308
+ }
309
+ };
310
+ api.current = { navigate, arrived };
311
+
312
+ // Every same-origin link, in the capture phase, before next/link sees the click. preventDefault is enough:
313
+ // next/link skips a click that is already handled, and the link's own onClick still runs.
314
+ const onClick = (e: MouseEvent) => {
315
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
316
+ const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
317
+ if (!a || a.hasAttribute("download") || a.hasAttribute("data-no-transition")) return;
318
+ if (a.target && a.target !== "_self") return;
319
+ const url = new URL(a.href, location.href);
320
+ if (url.origin !== location.origin || /\.[a-z0-9]{2,5}$/i.test(url.pathname)) return;
321
+ if (routeOf(url) === shown && (url.hash || a.getAttribute("href")?.startsWith("#"))) return;
322
+ if (reduce()) {
323
+ scrolls.set(shownKey, window.scrollY); // next/link navigates as usual; back still lands here
324
+ return;
325
+ }
326
+ e.preventDefault();
327
+ navigate(url.pathname + url.search + url.hash, a.dataset.transitionLabel);
328
+ };
329
+ const onIntent = (e: Event) => {
330
+ const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
331
+ if (!a || a.hasAttribute("data-no-transition")) return;
332
+ const url = new URL(a.href, location.href);
333
+ if (url.origin === location.origin && routeOf(url) !== shown) router.prefetch(url.pathname + url.search);
334
+ };
335
+
336
+ // Back and forward. A capture listener on window runs before the router's own, so the popstate is held here
337
+ // while the slices cover the page being left, and handed to the router only once they are shut. The URL is
338
+ // already the new one; the screen is still the old page.
339
+ const onPop = (e: PopStateEvent) => {
340
+ if (replaying) return;
341
+ const route = routeOf(location);
342
+ if (phase === "idle" || phase === "leaving") {
343
+ if (route === shown) return; // a spot on the same page
344
+ scrolls.set(shownKey, window.scrollY);
345
+ }
346
+ kind = "pop";
347
+ hash = "";
348
+ if (reduce()) return; // the router traverses as usual; arrived() restores the scroll
349
+ e.stopImmediatePropagation();
350
+ cover(wordFor(new URL(location.href)), () => {
351
+ if (routeOf(location) === shown) return leave(); // back and forth again: nothing to load
352
+ replaying = true;
353
+ window.dispatchEvent(new PopStateEvent("popstate", { state: history.state }));
354
+ replaying = false;
355
+ });
356
+ };
357
+
358
+ document.addEventListener("click", onClick, true);
359
+ document.addEventListener("pointerover", onIntent, { passive: true });
360
+ document.addEventListener("focusin", onIntent);
361
+ window.addEventListener("popstate", onPop, true);
362
+ return () => {
363
+ document.removeEventListener("click", onClick, true);
364
+ document.removeEventListener("pointerover", onIntent);
365
+ document.removeEventListener("focusin", onIntent);
366
+ window.removeEventListener("popstate", onPop, true);
367
+ window.clearTimeout(stall);
368
+ history.scrollRestoration = restoration;
369
+ // Never leave a held route or a closed curtain behind.
370
+ open();
371
+ setGate(null);
372
+ if (phase !== "idle") liftCurtain();
373
+ api.current = null;
374
+ };
375
+ },
376
+ { scope: root },
377
+ );
378
+
379
+ const navigate = useCallback<Navigate>(
380
+ (href, label) => {
381
+ if (api.current) api.current.navigate(href, label);
382
+ else router.push(href);
383
+ },
384
+ [router],
385
+ );
386
+ const arrived = useCallback((route: string) => api.current?.arrived(route), []);
387
+
388
+ return (
389
+ <SlicesContext.Provider value={navigate}>
390
+ {children}
391
+ <Suspense fallback={null}>
392
+ <RouteWatch gate={gate} onRoute={arrived} />
393
+ </Suspense>
394
+ <div
395
+ ref={root}
396
+ data-page-transition="slices"
397
+ aria-hidden="true"
398
+ className="@container pointer-events-none invisible fixed inset-0 z-[90] [--n:4] max-md:[--n:3] data-[active]:pointer-events-auto data-[active]:visible"
399
+ style={COVER}
400
+ >
401
+ {SLICES.map((i) => (
402
+ <div
403
+ key={i}
404
+ data-sheet=""
405
+ className={`absolute inset-y-0 text-on-photo ${COVER_BG} ${i === 3 ? "max-md:hidden" : ""}`}
406
+ style={slice(i)}
407
+ >
408
+ {/* Fixed 5px accent edges outside the sheet, seen only while it moves: the bottom one leads it in, the
409
+ top one trails it out. */}
410
+ <div className="absolute inset-x-0 top-full h-[5px] bg-accent" />
411
+ <div className="absolute inset-x-0 bottom-full h-[5px] bg-accent" />
412
+ <div className="absolute inset-0 overflow-hidden">
413
+ <div className={COVER_INK} />
414
+ <div className="absolute inset-y-0" style={{ left: `calc(${-i} * 100cqw / var(--n))`, width: "100cqw" }}>
415
+ <Face />
416
+ </div>
417
+ </div>
418
+ </div>
419
+ ))}
420
+ </div>
421
+ </SlicesContext.Provider>
422
+ );
423
+ }
424
+
425
+ /**
426
+ * Reports the route (path and query) after every change, and during a click's cover holds the new route back: the
427
+ * router's push is a transition, and a transition that suspends keeps the old page on screen until the cover is shut.
428
+ * Its own Suspense keeps useSearchParams off the page.
429
+ */
430
+ function RouteWatch({ gate, onRoute }: { gate: Gate | null; onRoute: (route: string) => void }) {
431
+ const pathname = usePathname();
432
+ const search = useSearchParams().toString();
433
+ const route = pathname + (search ? `?${search}` : "");
434
+ if (gate && route !== gate.from) use(gate.shut);
435
+ useEffect(() => {
436
+ onRoute(route);
437
+ }, [route, onRoute]);
438
+ return null;
439
+ }
440
+
441
+ /** One strip's place: an n-th of the width, a pixel wider so no seam of the page shows between two strips. */
442
+ function slice(i: number): CSSProperties {
443
+ return { left: `calc(${i} * 100cqw / var(--n))`, width: "calc(100cqw / var(--n) + 1px)" };
444
+ }
445
+
446
+ /** The closed cover: the destination's name where the preloader sets the client's, the hairline across the foot. */
447
+ function Face() {
448
+ return (
449
+ <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]">
450
+ <p data-label="" className="font-display max-w-[11ch] text-[18vw] leading-[0.86] tracking-[-0.04em] md:text-[9.5vw]" />
451
+ <div className="relative h-px w-full bg-on-photo/20">
452
+ <div data-fill="" className="absolute inset-0 origin-left bg-accent" style={{ transform: "scaleX(0)" }} />
453
+ </div>
454
+ </div>
455
+ );
456
+ }
457
+
458
+ /** Focus the new page without moving it, so a keyboard or screen reader starts at its top (or at its #hash). */
459
+ function focusPage(at: HTMLElement | null) {
460
+ const target =
461
+ at ??
462
+ document.querySelector<HTMLElement>("[data-transition-focus]") ??
463
+ document.querySelector<HTMLElement>("main h1") ??
464
+ document.querySelector<HTMLElement>("h1") ??
465
+ document.querySelector<HTMLElement>("main");
466
+ if (!target) return;
467
+ if (!target.hasAttribute("tabindex") && target.tabIndex < 0) Object.assign(target, { tabIndex: -1 }).style.outline = "none"; // a heading made focusable only for this: no ring
468
+ target.focus({ preventScroll: true });
469
+ }