@struq/skill 0.2.2 → 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 (122) hide show
  1. package/README.md +23 -35
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +148 -212
  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/blocks/kit/Cursor.tsx +117 -0
  19. package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
  20. package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
  21. package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
  22. package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
  23. package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
  24. package/skills/struq/blocks/kit/Reveal.tsx +64 -0
  25. package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
  26. package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/gsap.ts +21 -0
  29. package/skills/struq/blocks/kit/images.ts +17 -0
  30. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  31. package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
  32. package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
  33. package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
  34. package/skills/struq/blocks/kit/scenes/types.ts +114 -0
  35. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  36. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  37. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  38. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  39. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  40. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  41. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  42. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  43. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  44. package/skills/struq/blocks/sections/approach.tsx +661 -0
  45. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  46. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  47. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  48. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  49. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  50. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  51. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  52. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  53. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  54. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  55. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  56. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  57. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  58. package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
  59. package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
  60. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
  61. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  62. package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
  63. package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
  64. package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
  65. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  66. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
  67. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  68. package/skills/struq/canon/canon.json +627 -0
  69. package/skills/struq/cinematic/README.md +7 -18
  70. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  71. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  72. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  73. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  74. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  75. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  76. package/skills/struq/recipes/app/fonts.ts +12 -0
  77. package/skills/struq/recipes/app/globals.css +42 -0
  78. package/skills/struq/recipes/app/layout.tsx +30 -0
  79. package/skills/struq/references/craft.md +77 -0
  80. package/skills/struq/references/iconography.md +28 -230
  81. package/skills/struq/references/stack-targets.md +23 -74
  82. package/skills/struq/references/tells.md +52 -0
  83. package/skills/struq/references/triage.md +6 -7
  84. package/skills/struq/scripts/blocks.mjs +156 -0
  85. package/skills/struq/scripts/canon.mjs +134 -0
  86. package/skills/struq/scripts/check.mjs +513 -0
  87. package/skills/struq/scripts/images.mjs +360 -0
  88. package/skills/struq/scripts/log.mjs +75 -0
  89. package/skills/struq/scripts/packs.mjs +156 -0
  90. package/skills/struq/scripts/tokens.mjs +222 -0
  91. package/skills/struq/references/anti-patterns.md +0 -265
  92. package/skills/struq/references/baseline-prompt.md +0 -118
  93. package/skills/struq/references/brand.md +0 -143
  94. package/skills/struq/references/brief-router.md +0 -219
  95. package/skills/struq/references/composition.md +0 -191
  96. package/skills/struq/references/depth-parallax.md +0 -235
  97. package/skills/struq/references/direction.md +0 -188
  98. package/skills/struq/references/feel-layer.md +0 -107
  99. package/skills/struq/references/gsap-api.md +0 -293
  100. package/skills/struq/references/imagery.md +0 -144
  101. package/skills/struq/references/loaders.md +0 -287
  102. package/skills/struq/references/micro-interactions.md +0 -270
  103. package/skills/struq/references/mobile-composition.md +0 -70
  104. package/skills/struq/references/nav-header.md +0 -296
  105. package/skills/struq/references/page-architecture.md +0 -134
  106. package/skills/struq/references/page-entrances.md +0 -198
  107. package/skills/struq/references/performance.md +0 -235
  108. package/skills/struq/references/pinning-scrub.md +0 -341
  109. package/skills/struq/references/pitfalls.md +0 -290
  110. package/skills/struq/references/route-transitions.md +0 -281
  111. package/skills/struq/references/scaling.md +0 -151
  112. package/skills/struq/references/scroll-reveals.md +0 -280
  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
@@ -0,0 +1,583 @@
1
+ "use client";
2
+ // page-transition-stack (Struq page-transition recipe)
3
+ // The stack language on every navigation, in the voice of preloader-stack. On a click a small portrait print of the
4
+ // destination's own hero photo appears where the pointer is (centred for a keyboard press, back and forward) and grows
5
+ // until it fills the screen; the route loads under it; on the new page the print is already that page's hero photo,
6
+ // and it settles into the hero's exact place and crop, so arriving is the same seamless landing the preloader ends on.
7
+ // About 0.35s to fill the screen and 0.38s to land: door to door under 0.9 seconds once the route is in.
8
+ //
9
+ // The destination photo: a link names it with data-transition-image (the same src its page's hero renders). It is
10
+ // decoded ahead (links in view on idle, any link on hover or focus); a print is never shown half drawn. When it is not
11
+ // ready, or when the new page has no matching [data-stack-target] in view, the print carries the destination's name on
12
+ // the page ground instead, and the cover lifts away at the end rather than landing.
13
+ //
14
+ // Mount it once, in app/layout.tsx, around {children}:
15
+ // <PageTransitionStack name="Noord">{children}</PageTransitionStack>
16
+ // <Link href="/studio" data-transition-image={studioHero.src} data-transition-label="De studio">Studio</Link>
17
+ // The landing matches the page exactly when the page's hero is <Image fill sizes="100vw" className="object-cover">
18
+ // inside its [data-stack-target] box, as preloader-stack asks. Every same-origin link is taken over; a link opts out
19
+ // with data-no-transition. For code that navigates: const go = useStackNavigate(); go("/werk").
20
+ //
21
+ // Clicks: the route is fetched from the click on, under the growing print, and RouteWatch holds the new page back
22
+ // until the screen is covered (a router navigation is a transition, and a transition that suspends keeps the old
23
+ // page). Back and forward: React commits a navigation started in a popstate at once, so the popstate is caught first,
24
+ // the print covers the page being left, and only then is the event handed to the router. A route that never arrives
25
+ // lifts the cover after 6 seconds. Scroll: a push lands at the top or on its #hash, back and forward where the reader
26
+ // left that page. Focus moves to the #hash target, else [data-transition-focus], else the h1, else main.
27
+ // whenUncovered (preloaders/preloader-slices-uncovered.ts, shared with the preloaders) times a page's entrance.
28
+ // Reduced motion and no JS: plain navigation, no cover.
29
+ import {
30
+ Suspense,
31
+ createContext,
32
+ use,
33
+ useCallback,
34
+ useContext,
35
+ useEffect,
36
+ useRef,
37
+ useState,
38
+ } from "react";
39
+ import { getImageProps } from "next/image";
40
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
41
+ import { gsap, useGSAP } from "../kit/gsap";
42
+ import { getLenis } from "../kit/SmoothScroll";
43
+ import { closeCurtain, liftCurtain, whenUncovered } from "../preloaders/preloader-slices-uncovered";
44
+
45
+ export { whenUncovered };
46
+
47
+ const T = {
48
+ cover: 0.35, // the print growing to the full screen
49
+ land: 0.38, // the full screen settling into the new hero
50
+ lift: 0.42, // the cover leaving when there is nothing to land on
51
+ wait: 0.06, // the longest a click waits for its print to decode before it shows the name instead
52
+ settle: 2, // the longest a landed print waits over the page's own hero for that one to be drawn
53
+ stall: 6, // the longest the cover waits for a route that never arrives
54
+ } as const;
55
+
56
+ const TARGET = "[data-stack-target]";
57
+ /** The most prints kept decoded at once; past it the oldest goes. */
58
+ const POOL = 12;
59
+
60
+ type Box = { x: number; y: number; w: number; h: number };
61
+ type Source = { src: string; srcSet?: string; sizes?: string };
62
+
63
+ type Navigate = (href: string, label?: string, image?: string) => void;
64
+ const StackContext = createContext<Navigate | null>(null);
65
+
66
+ /** router.push through the stack, optionally with the cover's name and photo. Outside the provider: router.push. */
67
+ export function useStackNavigate(): Navigate {
68
+ const navigate = useContext(StackContext);
69
+ const router = useRouter();
70
+ return navigate ?? ((href: string) => router.push(href));
71
+ }
72
+
73
+ type Phase = "idle" | "covering" | "covered" | "landing";
74
+
75
+ /** A click's hold: the route on screen when it began, and the promise that settles when the screen is covered. */
76
+ type Gate = { from: string; shut: Promise<void> };
77
+
78
+ function entryKey(): string {
79
+ const nav = (window as Window & { navigation?: { currentEntry?: { key?: string } } }).navigation;
80
+ return nav?.currentEntry?.key ?? location.pathname + location.search;
81
+ }
82
+
83
+ function routeOf(url: URL | Location): string {
84
+ const search = new URLSearchParams(url.search).toString();
85
+ return url.pathname + (search ? `?${search}` : "");
86
+ }
87
+
88
+ function wordsOf(pathname: string): string {
89
+ const last = decodeURIComponent(pathname.split("/").filter(Boolean).pop() ?? "").replace(/[-_]+/g, " ").trim();
90
+ return last ? last.charAt(0).toUpperCase() + last.slice(1) : "";
91
+ }
92
+
93
+ /** The image a link names, as next/image renders it at sizes="100vw": the same file the page's hero will pick. */
94
+ function sourceOf(src: string): Source {
95
+ const { props } = getImageProps({ src, alt: "", fill: true, sizes: "100vw" });
96
+ return { src: props.src, srcSet: props.srcSet, sizes: props.sizes };
97
+ }
98
+
99
+ const rectOf = (r: DOMRect): Box => ({ x: r.left, y: r.top, w: r.width, h: r.height });
100
+
101
+ export function PageTransitionStack({
102
+ children,
103
+ name = "Home",
104
+ labels,
105
+ }: {
106
+ children: React.ReactNode;
107
+ /** The site's name: the cover's word for the home page. */
108
+ name?: string;
109
+ /** The cover's word per path, for pages no link names (e.g. { "/werk": "Werk" }). */
110
+ labels?: Record<string, string>;
111
+ }) {
112
+ const root = useRef<HTMLDivElement>(null);
113
+ const router = useRouter();
114
+ const [gate, setGate] = useState<Gate | null>(null);
115
+ const api = useRef<{ navigate: Navigate; arrived: (route: string) => void } | null>(null);
116
+ const words = useRef({ name, labels });
117
+ useEffect(() => {
118
+ words.current = { name, labels };
119
+ }, [name, labels]);
120
+
121
+ useGSAP(
122
+ (_, contextSafe) => {
123
+ const el = root.current;
124
+ if (!el || !contextSafe) return;
125
+ const layer = el.querySelector("[data-layer]") as HTMLElement;
126
+ const box = el.querySelector("[data-box]") as HTMLElement;
127
+ const label = el.querySelector("[data-label]") as HTMLElement;
128
+ const reduce = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
129
+
130
+ let phase: Phase = "idle";
131
+ let kind: "push" | "pop" | null = null;
132
+ let hash = "";
133
+ let shown = routeOf(location);
134
+ let shownKey = entryKey();
135
+ let early = false;
136
+ let replaying = false;
137
+ let stall = 0;
138
+ let shut: Promise<void> | null = null;
139
+ let open = () => {};
140
+ let afterShut: (() => void) | null = null;
141
+ let motion: gsap.core.Animation | null = null;
142
+ const scrolls = new Map<string, number>();
143
+ const learnt = new Map<string, string>(); // names pages gave themselves, by route
144
+ const heroes = new Map<string, Source>(); // each visited page's own hero photo, by route
145
+ const pool = new Map<string, { img: HTMLImageElement; ready: boolean }>();
146
+ let disposed = false; // strict mode runs this twice: a late idle callback of the first run must do nothing
147
+
148
+ /** The decoded print for a source, created and decoded once, kept in the cover. */
149
+ const print = (s: Source) => {
150
+ if (disposed) return { img: document.createElement("img"), ready: false };
151
+ let entry = pool.get(s.src);
152
+ if (!entry) {
153
+ const img = document.createElement("img");
154
+ img.alt = "";
155
+ img.decoding = "async";
156
+ img.hidden = true;
157
+ img.className = "absolute inset-0 h-full w-full object-cover";
158
+ if (s.sizes) img.sizes = s.sizes;
159
+ if (s.srcSet) img.srcset = s.srcSet;
160
+ img.src = s.src;
161
+ box.appendChild(img);
162
+ const made = { img, ready: false };
163
+ pool.set(s.src, made);
164
+ // A bounded pool: past POOL prints the oldest goes, unless it is on screen.
165
+ for (const [key, old] of pool) {
166
+ if (pool.size <= POOL) break;
167
+ if (!old.img.hidden) continue;
168
+ old.img.remove();
169
+ pool.delete(key);
170
+ }
171
+ img.decode().then(() => (made.ready = true), () => undefined);
172
+ entry = made;
173
+ }
174
+ return entry;
175
+ };
176
+ /** The print, if it decodes within `ms`; otherwise null and the cover carries the name. */
177
+ const printWithin = (s: Source | undefined, ms: number): Promise<HTMLImageElement | null> => {
178
+ if (!s) return Promise.resolve(null);
179
+ const entry = print(s);
180
+ if (entry.ready) return Promise.resolve(entry.img);
181
+ return Promise.race([
182
+ entry.img.decode().then(() => entry.img, () => null),
183
+ new Promise<null>((resolve) => window.setTimeout(() => resolve(null), ms)),
184
+ ]);
185
+ };
186
+
187
+ /** Remember what the page on screen is: its name, and its hero photo for a later back or forward. */
188
+ const learn = () => {
189
+ const own = document.querySelector<HTMLElement>("[data-transition-title]")?.dataset.transitionTitle?.trim();
190
+ if (own) learnt.set(shown, own);
191
+ const img = document.querySelector<HTMLImageElement>(`${TARGET} img`);
192
+ const src = img?.getAttribute("src");
193
+ if (img && src) {
194
+ const s = { src, srcSet: img.getAttribute("srcset") ?? undefined, sizes: img.getAttribute("sizes") ?? undefined };
195
+ heroes.set(shown, s);
196
+ print(s);
197
+ }
198
+ };
199
+ /** Decode the prints of the links in view, when the page is idle. */
200
+ const warm = () => {
201
+ const idle = (window as Window & { requestIdleCallback?: (cb: () => void) => number }).requestIdleCallback;
202
+ (idle ?? ((cb: () => void) => window.setTimeout(cb, 200)))(() => {
203
+ for (const a of document.querySelectorAll<HTMLAnchorElement>("a[data-transition-image]")) {
204
+ const r = a.getBoundingClientRect();
205
+ if (r.bottom > 0 && r.top < window.innerHeight) print(sourceOf(a.dataset.transitionImage!));
206
+ }
207
+ });
208
+ };
209
+ learn();
210
+ warm();
211
+
212
+ const wordFor = (url: URL, given?: string | null) => {
213
+ const { name: home, labels: map } = words.current;
214
+ return given?.trim() || map?.[url.pathname] || learnt.get(routeOf(url)) || wordsOf(url.pathname) || home;
215
+ };
216
+
217
+ const restoration = history.scrollRestoration;
218
+ history.scrollRestoration = "manual";
219
+
220
+ const scrollTo = (y: number) => {
221
+ const lenis = getLenis();
222
+ if (lenis) lenis.scrollTo(y, { immediate: true, force: true });
223
+ else window.scrollTo(0, y);
224
+ };
225
+
226
+ // The print's geometry, all from plain numbers. The photo sits at its own aspect, scaled to cover a reference
227
+ // box (the screen while covering, the new hero while landing) and centred on it; each frame a transform scales
228
+ // and moves it to cover the frame F, and a clip shows only F.
229
+ let photo: HTMLImageElement | null = null;
230
+ let iw = 1;
231
+ let ih = 1;
232
+ let s1 = 1;
233
+ let ref: Box = { x: 0, y: 0, w: 1, h: 1 };
234
+ const F: Box = { x: 0, y: 0, w: 0, h: 0 };
235
+ const screen = (): Box => ({ x: 0, y: 0, w: el.clientWidth, h: el.clientHeight });
236
+ const layout = (r: Box) => {
237
+ ref = r;
238
+ s1 = Math.max(r.w / iw, r.h / ih);
239
+ Object.assign(box.style, {
240
+ left: `${r.x + r.w / 2 - (iw * s1) / 2}px`,
241
+ top: `${r.y + r.h / 2 - (ih * s1) / 2}px`,
242
+ width: `${iw * s1}px`,
243
+ height: `${ih * s1}px`,
244
+ });
245
+ };
246
+ // Landing on a hero whose window shows a larger registered photo (data-stack-photo inside the target): the
247
+ // print is laid as that photo and stays put while the frame closes onto the window.
248
+ let still = false;
249
+ const paint = () => {
250
+ const V = screen();
251
+ layer.style.clipPath = `inset(${F.y}px ${V.w - F.x - F.w}px ${V.h - F.y - F.h}px ${F.x}px)`;
252
+ if (!photo) return;
253
+ if (still) {
254
+ box.style.transform = "translate(0px, 0px) scale(1)";
255
+ return;
256
+ }
257
+ const k = Math.max(F.w / iw, F.h / ih) / s1;
258
+ const dx = F.x + F.w / 2 - (ref.x + ref.w / 2);
259
+ const dy = F.y + F.h / 2 - (ref.y + ref.h / 2);
260
+ box.style.transform = `translate(${dx}px, ${dy}px) scale(${k})`;
261
+ };
262
+
263
+ /** The print's first place: a small portrait where the pointer is, kept on screen; centred without a pointer. */
264
+ const startAt = (point: { x: number; y: number } | null): Box => {
265
+ const V = screen();
266
+ const w = Math.max(110, V.w < 768 ? V.w * 0.36 : V.w * 0.14);
267
+ const h = (w * 4) / 3;
268
+ const cx = point ? gsap.utils.clamp(w / 2 + 16, V.w - w / 2 - 16, point.x) : V.w / 2;
269
+ const cy = point ? gsap.utils.clamp(h / 2 + 16, V.h - h / 2 - 16, point.y) : V.h / 2;
270
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
271
+ };
272
+
273
+ // A landed print waits over the page's hero; this counter makes a stale wait unable to tear down a newer
274
+ // transition, and stopWait drops the scroll watch that ends the wait early.
275
+ let settleId = 0;
276
+ let stopWait = () => {};
277
+ const reset = () => {
278
+ still = false;
279
+ settleId += 1;
280
+ stopWait();
281
+ stopWait = () => {};
282
+ el.style.pointerEvents = "";
283
+ phase = "idle";
284
+ delete el.dataset.active;
285
+ layer.style.clipPath = "";
286
+ gsap.set(layer, { yPercent: 0 });
287
+ for (const { img } of pool.values()) img.hidden = true;
288
+ photo = null;
289
+ };
290
+
291
+ /** Cover the page on screen: the print appears at `from` and grows to the screen; `then` runs once covered. */
292
+ const cover = contextSafe((from: Box, word: string, img: HTMLImageElement | null, then?: () => void) => {
293
+ settleId += 1;
294
+ if (!shut) shut = new Promise<void>((resolve) => (open = resolve));
295
+ if (phase === "covered") {
296
+ then?.();
297
+ return;
298
+ }
299
+ afterShut = then ?? null;
300
+ if (phase === "covering") return; // the running cover takes the newest route
301
+ motion?.kill();
302
+ if (phase === "landing") reset();
303
+ phase = "covering";
304
+ label.textContent = word;
305
+ for (const { img: other } of pool.values()) other.hidden = other !== img;
306
+ photo = img;
307
+ box.hidden = !img;
308
+ if (img) {
309
+ iw = img.naturalWidth || 1;
310
+ ih = img.naturalHeight || 1;
311
+ layout(screen());
312
+ }
313
+ Object.assign(F, from);
314
+ paint();
315
+ el.dataset.active = "";
316
+ const V = screen();
317
+ motion = gsap.to(F, {
318
+ ...V,
319
+ duration: T.cover,
320
+ ease: "power3.inOut",
321
+ onUpdate: paint,
322
+ onComplete: () => {
323
+ phase = "covered";
324
+ closeCurtain();
325
+ open();
326
+ const next = afterShut;
327
+ afterShut = null;
328
+ next?.();
329
+ if (early) return land();
330
+ window.clearTimeout(stall);
331
+ stall = window.setTimeout(() => land(), T.stall * 1000);
332
+ },
333
+ });
334
+ });
335
+
336
+ /** The new page is in: land on its hero when the print is its photo and it is in view, else lift away. */
337
+ const land = contextSafe(() => {
338
+ window.clearTimeout(stall);
339
+ phase = "landing";
340
+ const was = kind ?? "push";
341
+ const target = hash ? document.getElementById(decodeURIComponent(hash.slice(1))) : null;
342
+ kind = null;
343
+ hash = "";
344
+ shut = null;
345
+ early = false;
346
+ setGate(null);
347
+ shown = routeOf(location);
348
+ shownKey = entryKey();
349
+ learn();
350
+ if (was === "pop") scrollTo(scrolls.get(shownKey) ?? 0);
351
+ else scrollTo(target ? target.getBoundingClientRect().top + window.scrollY : 0);
352
+ focusPage(target);
353
+
354
+ const V = screen();
355
+ const hero = document.querySelector(TARGET);
356
+ const heroImg = hero?.querySelector("img");
357
+ const R = hero ? rectOf(hero.getBoundingClientRect()) : null;
358
+ const matches = !!(photo && heroImg && heroImg.getAttribute("src") === photo.getAttribute("src"));
359
+ const inView = !!(R && R.w > 0 && R.h > 0 && R.y < V.h && R.y + R.h > 0);
360
+
361
+ requestAnimationFrame(() => {
362
+ liftCurtain();
363
+ motion?.kill();
364
+ if (matches && inView && R) {
365
+ // Re-seat the photo on the hero box (same picture on screen), then settle the frame into it.
366
+ const photoEl = hero?.querySelector<HTMLElement>("[data-stack-photo]");
367
+ if (photoEl) {
368
+ layout(rectOf(photoEl.getBoundingClientRect()));
369
+ still = true;
370
+ } else layout(R);
371
+ paint();
372
+ motion = gsap.to(F, {
373
+ ...R,
374
+ duration: T.land,
375
+ ease: "power2.inOut",
376
+ onUpdate: paint,
377
+ onComplete: () => {
378
+ // Landed: the print now sits exactly over the page's own photo. It stays (the same picture, letting
379
+ // every click through) until that photo is drawn, so a slow hero never shows as an empty box.
380
+ // Only this wait may end itself: a click or back press in the meantime has moved settleId on.
381
+ // A scroll ends it at once, so the print never hangs where the hero was while the page moves.
382
+ el.style.pointerEvents = "none";
383
+ const id = ++settleId;
384
+ const done = () => {
385
+ if (id === settleId && phase === "landing") reset();
386
+ };
387
+ window.addEventListener("scroll", done, { once: true, passive: true });
388
+ stopWait = () => window.removeEventListener("scroll", done);
389
+ Promise.race([
390
+ heroImg!.decode().catch(() => undefined),
391
+ new Promise((resolve) => window.setTimeout(resolve, T.settle * 1000)),
392
+ ]).then(() => requestAnimationFrame(done));
393
+ },
394
+ });
395
+ } else {
396
+ motion = gsap.to(layer, { yPercent: -100, duration: T.lift, ease: "power3.inOut", onComplete: reset });
397
+ }
398
+ warm();
399
+ });
400
+ });
401
+
402
+ const navigate = (href: string, given?: string, image?: string, point?: { x: number; y: number } | null) => {
403
+ const url = new URL(href, location.href);
404
+ if (url.origin !== location.origin) {
405
+ location.assign(url.href);
406
+ return;
407
+ }
408
+ if (reduce()) {
409
+ router.push(href);
410
+ return;
411
+ }
412
+ if (phase === "idle" || phase === "landing") {
413
+ if (routeOf(url) === shown) return; // already here
414
+ scrolls.set(shownKey, window.scrollY);
415
+ }
416
+ kind = "push";
417
+ hash = url.hash;
418
+ const word = wordFor(url, given);
419
+ if (given?.trim()) learnt.set(routeOf(url), given.trim());
420
+ // The route is fetched from now on; RouteWatch holds it back until the screen is covered.
421
+ if (!shut) shut = new Promise<void>((resolve) => (open = resolve));
422
+ setGate({ from: shown, shut });
423
+ router.push(url.pathname + url.search + url.hash, { scroll: false });
424
+ const source = image ? sourceOf(image) : heroes.get(routeOf(url));
425
+ printWithin(source, T.wait * 1000).then((img) => cover(startAt(point ?? null), word, img));
426
+ };
427
+
428
+ const arrived = (route: string) => {
429
+ if (route === shown) return;
430
+ if (phase === "covered" && kind) return land();
431
+ if (phase === "covering" && kind) {
432
+ early = true;
433
+ return;
434
+ }
435
+ if (phase === "idle") {
436
+ const wasPop = kind === "pop";
437
+ kind = null;
438
+ shown = route;
439
+ shownKey = entryKey();
440
+ learn();
441
+ if (wasPop) scrollTo(scrolls.get(shownKey) ?? 0);
442
+ if (reduce()) focusPage(null);
443
+ warm();
444
+ }
445
+ };
446
+ api.current = {
447
+ navigate: (href, label, image) => navigate(href, label, image, null),
448
+ arrived,
449
+ };
450
+
451
+ const linkOf = (e: Event) => (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
452
+
453
+ const onClick = (e: MouseEvent) => {
454
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
455
+ const a = linkOf(e);
456
+ if (!a || a.hasAttribute("download") || a.hasAttribute("data-no-transition")) return;
457
+ if (a.target && a.target !== "_self") return;
458
+ const url = new URL(a.href, location.href);
459
+ if (url.origin !== location.origin || /\.[a-z0-9]{2,5}$/i.test(url.pathname)) return;
460
+ if (routeOf(url) === shown && (url.hash || a.getAttribute("href")?.startsWith("#"))) return;
461
+ if (reduce()) {
462
+ scrolls.set(shownKey, window.scrollY);
463
+ return;
464
+ }
465
+ e.preventDefault();
466
+ // A keyboard press reports no pointer (detail 0): the print starts in the middle.
467
+ const point = e.detail > 0 ? { x: e.clientX, y: e.clientY } : null;
468
+ navigate(url.pathname + url.search + url.hash, a.dataset.transitionLabel, a.dataset.transitionImage, point);
469
+ };
470
+ const onIntent = (e: Event) => {
471
+ const a = linkOf(e);
472
+ if (!a || a.hasAttribute("data-no-transition")) return;
473
+ const url = new URL(a.href, location.href);
474
+ if (url.origin !== location.origin || routeOf(url) === shown) return;
475
+ router.prefetch(url.pathname + url.search);
476
+ if (a.dataset.transitionImage) print(sourceOf(a.dataset.transitionImage));
477
+ };
478
+
479
+ // Back and forward: held in the capture phase while the print covers the page being left, then handed on.
480
+ const onPop = (e: PopStateEvent) => {
481
+ if (replaying) return;
482
+ const route = routeOf(location);
483
+ if (phase === "idle" || phase === "landing") {
484
+ if (route === shown) return;
485
+ scrolls.set(shownKey, window.scrollY);
486
+ }
487
+ kind = "pop";
488
+ hash = "";
489
+ if (reduce()) return;
490
+ e.stopImmediatePropagation();
491
+ const word = wordFor(new URL(location.href));
492
+ const replay = () => {
493
+ if (routeOf(location) === shown) return land();
494
+ replaying = true;
495
+ window.dispatchEvent(new PopStateEvent("popstate", { state: history.state }));
496
+ replaying = false;
497
+ };
498
+ printWithin(heroes.get(route), T.wait * 1000).then((img) => cover(startAt(null), word, img, replay));
499
+ };
500
+
501
+ document.addEventListener("click", onClick, true);
502
+ document.addEventListener("pointerover", onIntent, { passive: true });
503
+ document.addEventListener("focusin", onIntent);
504
+ window.addEventListener("popstate", onPop, true);
505
+ return () => {
506
+ document.removeEventListener("click", onClick, true);
507
+ document.removeEventListener("pointerover", onIntent);
508
+ document.removeEventListener("focusin", onIntent);
509
+ window.removeEventListener("popstate", onPop, true);
510
+ window.clearTimeout(stall);
511
+ history.scrollRestoration = restoration;
512
+ open();
513
+ setGate(null);
514
+ if (phase !== "idle") liftCurtain();
515
+ disposed = true;
516
+ stopWait();
517
+ for (const { img } of pool.values()) img.remove();
518
+ api.current = null;
519
+ };
520
+ },
521
+ { scope: root },
522
+ );
523
+
524
+ const navigate = useCallback<Navigate>(
525
+ (href, label, image) => {
526
+ if (api.current) api.current.navigate(href, label, image);
527
+ else router.push(href);
528
+ },
529
+ [router],
530
+ );
531
+ const arrived = useCallback((route: string) => api.current?.arrived(route), []);
532
+
533
+ return (
534
+ <StackContext.Provider value={navigate}>
535
+ {children}
536
+ <Suspense fallback={null}>
537
+ <RouteWatch gate={gate} onRoute={arrived} />
538
+ </Suspense>
539
+ <div
540
+ ref={root}
541
+ data-page-transition="stack"
542
+ aria-hidden="true"
543
+ className="pointer-events-none invisible fixed inset-0 z-[90] data-[active]:pointer-events-auto data-[active]:visible"
544
+ >
545
+ {/* The print: the page ground with the destination's name, and over it the destination's photo when it is
546
+ decoded. Only the frame's clip of it is ever seen. */}
547
+ <div data-layer="" className="absolute inset-0">
548
+ <div className="absolute inset-0 grid place-items-center bg-surface-0 px-6 text-ink">
549
+ <p data-label="" className="font-display max-w-[14ch] text-center text-[12vw] leading-[0.9] tracking-[-0.04em] md:text-[6vw]" />
550
+ </div>
551
+ <div data-box="" className="absolute origin-center" />
552
+ </div>
553
+ </div>
554
+ </StackContext.Provider>
555
+ );
556
+ }
557
+
558
+ /**
559
+ * Reports the route after every change, and during a click's cover holds the new route back: the router's push is a
560
+ * transition, and a transition that suspends keeps the old page on screen until the screen is covered.
561
+ */
562
+ function RouteWatch({ gate, onRoute }: { gate: Gate | null; onRoute: (route: string) => void }) {
563
+ const pathname = usePathname();
564
+ const search = useSearchParams().toString();
565
+ const route = pathname + (search ? `?${search}` : "");
566
+ if (gate && route !== gate.from) use(gate.shut);
567
+ useEffect(() => {
568
+ onRoute(route);
569
+ }, [route, onRoute]);
570
+ return null;
571
+ }
572
+
573
+ function focusPage(at: HTMLElement | null) {
574
+ const target =
575
+ at ??
576
+ document.querySelector<HTMLElement>("[data-transition-focus]") ??
577
+ document.querySelector<HTMLElement>("main h1") ??
578
+ document.querySelector<HTMLElement>("h1") ??
579
+ document.querySelector<HTMLElement>("main");
580
+ if (!target) return;
581
+ if (!target.hasAttribute("tabindex") && target.tabIndex < 0) Object.assign(target, { tabIndex: -1 }).style.outline = "none"; // a heading made focusable only for this: no ring
582
+ target.focus({ preventScroll: true });
583
+ }