@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.
- package/README.md +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/imagery.md +0 -144
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -341
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -280
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// page-transition-gate (Struq page transition): the gate, on every navigation
|
|
3
|
+
// The preloader's leader carried into navigation. The click shuts the gate: two bars come in from
|
|
4
|
+
// top and bottom, each carrying one half of a slate (the dial's rings and the destination's name in
|
|
5
|
+
// the display face), while the page recedes behind them. The halves meet on the middle line and
|
|
6
|
+
// lock into one frame: the line lights in the accent and an accent arc runs once round the dial.
|
|
7
|
+
// The route changes under the locked frame. Then the gate opens on that line, the name and the
|
|
8
|
+
// rings split in two and ride away with the bars, and the new page settles forward through it while
|
|
9
|
+
// its own entrance (onGateReveal) starts in the gap.
|
|
10
|
+
//
|
|
11
|
+
// 0.00 click: the bars close (0.38s), the page behind recedes to 0.97
|
|
12
|
+
// 0.30 the line lights from the centre; the arc runs round the dial (0.26s)
|
|
13
|
+
// 0.38 locked: the navigation runs under the frame, which holds at least 0.16s
|
|
14
|
+
// 0.54 the gate opens (0.36s); the page settles from 1.035 and its entrance starts with it
|
|
15
|
+
// 0.90 open. A slower route holds the locked frame, never a blank page.
|
|
16
|
+
//
|
|
17
|
+
// Mount it once in app/layout.tsx and wrap only the page content in GateStage; keep a fixed header
|
|
18
|
+
// outside the stage, since the stage is scaled during a journey:
|
|
19
|
+
//
|
|
20
|
+
// <PageTransitionGate name="Noord" />
|
|
21
|
+
// <SiteHeader />
|
|
22
|
+
// <GateStage>{children}</GateStage>
|
|
23
|
+
// <GateLink href="/werk" label="Werk">Bekijk het werk</GateLink>
|
|
24
|
+
// const router = useGateRouter(); router.push("/contact", "Contact");
|
|
25
|
+
//
|
|
26
|
+
// A page's own entrance: set its start state only while transitionCovers() is true and play it in
|
|
27
|
+
// onGateReveal(), so it starts as the gate opens instead of half played under the shut frame.
|
|
28
|
+
//
|
|
29
|
+
// Back and forward get the same gate over the live page (canvases, video and a fixed header stay as
|
|
30
|
+
// they are). The browser's traversal is undone at once with history.go, the gate shuts, and the
|
|
31
|
+
// same traversal is made again for the router under the locked frame: public history calls only,
|
|
32
|
+
// nothing replayed into the router. A back press at any moment gives one coherent journey: before
|
|
33
|
+
// a push is sent it replaces the push; while a page is on its way it is made under the same shut
|
|
34
|
+
// frame once that page lands; while the gate opens it starts the next journey when it is open.
|
|
35
|
+
// This needs the Navigation API (every current engine); without it a traversal is the router's own,
|
|
36
|
+
// without the gate. Scroll is restored per history entry (a new page at the top or its #hash, a page
|
|
37
|
+
// you return to where you left it) and focus moves to the new page's [data-page-focus], its h1 or
|
|
38
|
+
// its main. Reduced motion: no gate and no scaling; scroll and focus are handled the same way.
|
|
39
|
+
import Link from "next/link";
|
|
40
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
41
|
+
import { Suspense, useCallback, useEffect, useRef, type ComponentProps, type ReactNode } from "react";
|
|
42
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
43
|
+
import { getLenis } from "../kit/SmoothScroll";
|
|
44
|
+
|
|
45
|
+
/** Fired on window when the gate starts to open on a new page. */
|
|
46
|
+
export const GATE_REVEAL_EVENT = "struq:gate-reveal";
|
|
47
|
+
|
|
48
|
+
const CLOSE = 0.38;
|
|
49
|
+
/** The least time the locked frame holds once the new page is in. */
|
|
50
|
+
const SHUT = 0.16;
|
|
51
|
+
const OPEN = 0.36;
|
|
52
|
+
/** Door to door; images in view may hold the frame only while this budget lasts. */
|
|
53
|
+
const BUDGET = 0.9;
|
|
54
|
+
/** A route that never arrives still opens the gate. */
|
|
55
|
+
const CEILING = 6;
|
|
56
|
+
|
|
57
|
+
type Phase = "idle" | "covering" | "covered" | "revealing";
|
|
58
|
+
type Gate = { cover: (from?: number) => void; reveal: () => void; label: (text: string) => void; reduce: boolean };
|
|
59
|
+
type Nav = {
|
|
60
|
+
currentEntry?: { key?: string; index?: number };
|
|
61
|
+
entries?: () => Array<{ key: string }>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One store for the document, outside React. A page that remounts on navigation (a route whose search params
|
|
66
|
+
* change remounts its page segment) takes its provider with it; the journey must survive that, so it lives here
|
|
67
|
+
* and the new provider picks it up mid-close or shut.
|
|
68
|
+
*/
|
|
69
|
+
const store = {
|
|
70
|
+
phase: "idle" as Phase,
|
|
71
|
+
kind: "push" as "push" | "pop",
|
|
72
|
+
/** The URL the gate shut on; the new page has landed when the rendered URL differs from it. */
|
|
73
|
+
from: "",
|
|
74
|
+
/** The URL the provider last rendered. */
|
|
75
|
+
rendered: "",
|
|
76
|
+
/** The history index of the page on screen. */
|
|
77
|
+
index: -1,
|
|
78
|
+
hash: "",
|
|
79
|
+
label: "",
|
|
80
|
+
name: "",
|
|
81
|
+
startedAt: 0,
|
|
82
|
+
/** The journey's request (the push, or the traversal) has gone to the router. */
|
|
83
|
+
sent: false,
|
|
84
|
+
/** A traversal waiting for the shut frame: the sum of the back and forward presses not yet made. */
|
|
85
|
+
go: 0,
|
|
86
|
+
/** A traversal pressed after the request was sent: made once the current page lands or the gate is open. */
|
|
87
|
+
after: 0,
|
|
88
|
+
/** Our own history.go calls: the undo of the browser's traversal, and the traversal made for the router. */
|
|
89
|
+
bouncing: false,
|
|
90
|
+
passing: false,
|
|
91
|
+
/** Without the Navigation API (or under reduced motion) a traversal is the router's own: no gate to open. */
|
|
92
|
+
instant: false,
|
|
93
|
+
landed: false,
|
|
94
|
+
arriving: false,
|
|
95
|
+
onCovered: null as (() => void) | null,
|
|
96
|
+
gate: null as Gate | null,
|
|
97
|
+
ceiling: null as gsap.core.Tween | null,
|
|
98
|
+
positions: new Map<string, number>(),
|
|
99
|
+
labels: new Map<string, string>(),
|
|
100
|
+
release: null as (() => void) | null,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function nav(): Nav | undefined {
|
|
104
|
+
return (window as Window & { navigation?: Nav }).navigation;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function navIndex() {
|
|
108
|
+
return nav()?.currentEntry?.index ?? -1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function urlKey(pathname = location.pathname, search = location.search) {
|
|
112
|
+
const params = new URLSearchParams(search).toString();
|
|
113
|
+
return params ? `${pathname}?${params}` : pathname;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The history entry's own key where the Navigation API exists, else its URL. */
|
|
117
|
+
function entryKey() {
|
|
118
|
+
return nav()?.currentEntry?.key ?? urlKey();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The slate's word for the entry `delta` steps from the page on screen. */
|
|
122
|
+
function labelAt(delta: number) {
|
|
123
|
+
const entry = nav()?.entries?.()[store.index + delta];
|
|
124
|
+
return (entry && store.labels.get(entry.key)) || store.name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function scrollTop(y: number) {
|
|
128
|
+
const lenis = getLenis();
|
|
129
|
+
if (lenis) lenis.scrollTo(y, { immediate: true, force: true });
|
|
130
|
+
window.scrollTo(0, y);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The element that recedes and settles: the live page content. */
|
|
134
|
+
function stage(): HTMLElement | null {
|
|
135
|
+
return document.querySelector<HTMLElement>("[data-gate-stage]");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const SCROLL_KEYS = new Set([" ", "PageDown", "PageUp", "ArrowDown", "ArrowUp", "Home", "End"]);
|
|
139
|
+
/** Holds wheel, touch and scroll keys while the gate is shut, so neither page moves under it. */
|
|
140
|
+
function holdScroll() {
|
|
141
|
+
if (store.release) return;
|
|
142
|
+
const stop = (e: Event) => {
|
|
143
|
+
if (e instanceof KeyboardEvent && !SCROLL_KEYS.has(e.key)) return;
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
};
|
|
147
|
+
const opts = { capture: true, passive: false } as const;
|
|
148
|
+
window.addEventListener("wheel", stop, opts);
|
|
149
|
+
window.addEventListener("touchmove", stop, opts);
|
|
150
|
+
window.addEventListener("keydown", stop, opts);
|
|
151
|
+
store.release = () => {
|
|
152
|
+
window.removeEventListener("wheel", stop, opts);
|
|
153
|
+
window.removeEventListener("touchmove", stop, opts);
|
|
154
|
+
window.removeEventListener("keydown", stop, opts);
|
|
155
|
+
store.release = null;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function armCeiling() {
|
|
160
|
+
store.ceiling?.kill();
|
|
161
|
+
store.ceiling = gsap.delayedCall(CEILING, () => {
|
|
162
|
+
if (store.phase === "covered") arrive();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Makes a traversal for the router (it sees this popstate as usual); waits for an undo still on its way. */
|
|
167
|
+
function traverse(delta: number) {
|
|
168
|
+
if (store.bouncing) {
|
|
169
|
+
gsap.delayedCall(0.03, () => traverse(delta));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
store.sent = true;
|
|
173
|
+
store.go = 0;
|
|
174
|
+
store.kind = "pop";
|
|
175
|
+
store.passing = true;
|
|
176
|
+
history.go(delta);
|
|
177
|
+
armCeiling();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Undoes the browser's traversal, so the router keeps the page on screen until the gate is shut. */
|
|
181
|
+
function undo(delta: number) {
|
|
182
|
+
store.bouncing = true;
|
|
183
|
+
history.go(-delta);
|
|
184
|
+
// A traversal that never reports (a history that changed under it) can never block the next one.
|
|
185
|
+
gsap.delayedCall(0.5, () => {
|
|
186
|
+
store.bouncing = false;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The cover reached the locked frame: run what waits on it, and open if the page has already landed. */
|
|
191
|
+
function coverDone() {
|
|
192
|
+
store.phase = "covered";
|
|
193
|
+
const next = store.onCovered;
|
|
194
|
+
store.onCovered = null;
|
|
195
|
+
next?.();
|
|
196
|
+
if (store.landed) scheduleArrive();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One tick later, so a provider that strict mode mounts twice has settled before the gate opens. */
|
|
200
|
+
function scheduleArrive() {
|
|
201
|
+
if (store.arriving) return;
|
|
202
|
+
store.arriving = true;
|
|
203
|
+
gsap.delayedCall(0, () => {
|
|
204
|
+
if (store.phase === "covered") arrive();
|
|
205
|
+
else store.arriving = false;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The new page is rendered under the locked frame: place the scroll, move focus, open. */
|
|
210
|
+
function arrive() {
|
|
211
|
+
store.ceiling?.kill();
|
|
212
|
+
store.ceiling = null;
|
|
213
|
+
store.landed = false;
|
|
214
|
+
store.passing = false;
|
|
215
|
+
store.rendered = urlKey();
|
|
216
|
+
store.index = navIndex();
|
|
217
|
+
if (store.kind === "push" && store.label) store.labels.set(entryKey(), store.label);
|
|
218
|
+
|
|
219
|
+
// A back or forward press made while this page was on its way: made now, under the same shut frame.
|
|
220
|
+
if (store.after !== 0 && !store.instant) {
|
|
221
|
+
const delta = store.after;
|
|
222
|
+
store.after = 0;
|
|
223
|
+
store.arriving = false;
|
|
224
|
+
store.from = store.rendered;
|
|
225
|
+
store.label = labelAt(delta);
|
|
226
|
+
store.gate?.label(store.label);
|
|
227
|
+
traverse(delta);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let y = store.kind === "pop" ? (store.positions.get(entryKey()) ?? 0) : 0;
|
|
232
|
+
if (store.kind === "push" && store.hash) {
|
|
233
|
+
const target = document.getElementById(decodeURIComponent(store.hash.slice(1)));
|
|
234
|
+
if (target) y = target.getBoundingClientRect().top + window.scrollY;
|
|
235
|
+
}
|
|
236
|
+
scrollTop(y);
|
|
237
|
+
|
|
238
|
+
const focus =
|
|
239
|
+
document.querySelector<HTMLElement>("[data-page-focus]") ??
|
|
240
|
+
document.querySelector<HTMLElement>("main h1, h1") ??
|
|
241
|
+
document.querySelector<HTMLElement>("main");
|
|
242
|
+
if (focus) {
|
|
243
|
+
if (!focus.hasAttribute("tabindex")) focus.setAttribute("tabindex", "-1");
|
|
244
|
+
focus.focus({ preventScroll: true });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const gate = store.gate;
|
|
248
|
+
if (!gate || gate.reduce || store.instant) {
|
|
249
|
+
store.arriving = false;
|
|
250
|
+
window.dispatchEvent(new Event(GATE_REVEAL_EVENT));
|
|
251
|
+
finish();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// The locked frame holds for its beat; images in view get what is left of the budget on top of that.
|
|
255
|
+
const elapsed = (performance.now() - store.startedAt) / 1000;
|
|
256
|
+
const hold = Math.max(0, CLOSE + SHUT - elapsed);
|
|
257
|
+
const left = Math.max(hold, BUDGET - OPEN - elapsed);
|
|
258
|
+
const pending = Array.from(document.images).filter((img) => {
|
|
259
|
+
if (img.complete || img.closest("[data-gate]")) return false;
|
|
260
|
+
const box = img.getBoundingClientRect();
|
|
261
|
+
return box.bottom > 0 && box.top < window.innerHeight;
|
|
262
|
+
});
|
|
263
|
+
let opened = false;
|
|
264
|
+
let held = false;
|
|
265
|
+
let loaded = pending.length === 0;
|
|
266
|
+
const open = () => {
|
|
267
|
+
if (opened) return;
|
|
268
|
+
opened = true;
|
|
269
|
+
soonest.kill();
|
|
270
|
+
latest.kill();
|
|
271
|
+
store.arriving = false;
|
|
272
|
+
store.gate?.reveal();
|
|
273
|
+
};
|
|
274
|
+
const soonest = gsap.delayedCall(hold, () => {
|
|
275
|
+
held = true;
|
|
276
|
+
if (loaded) open();
|
|
277
|
+
});
|
|
278
|
+
const latest = gsap.delayedCall(left, open);
|
|
279
|
+
if (!loaded) {
|
|
280
|
+
Promise.all(pending.map((img) => img.decode().catch(() => undefined))).then(() => {
|
|
281
|
+
loaded = true;
|
|
282
|
+
if (held) open();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The journey is over: back to idle, then a back or forward press made while the gate was opening. */
|
|
288
|
+
function finish() {
|
|
289
|
+
store.phase = "idle";
|
|
290
|
+
store.instant = false;
|
|
291
|
+
store.passing = false;
|
|
292
|
+
store.release?.();
|
|
293
|
+
getLenis()?.start();
|
|
294
|
+
if (store.after !== 0) {
|
|
295
|
+
const delta = store.after;
|
|
296
|
+
store.after = 0;
|
|
297
|
+
begin("pop", labelAt(delta), () => traverse(delta));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Starts a journey: the label goes on the slate, the gate shuts, `then` runs on the locked frame. */
|
|
302
|
+
function begin(kind: "push" | "pop", label: string, then: (() => void) | null) {
|
|
303
|
+
store.kind = kind;
|
|
304
|
+
store.from = store.rendered || urlKey();
|
|
305
|
+
store.label = label;
|
|
306
|
+
store.landed = false;
|
|
307
|
+
store.sent = false;
|
|
308
|
+
store.startedAt = performance.now();
|
|
309
|
+
store.onCovered = then;
|
|
310
|
+
store.phase = "covering";
|
|
311
|
+
holdScroll();
|
|
312
|
+
getLenis()?.stop();
|
|
313
|
+
const gate = store.gate;
|
|
314
|
+
if (!gate) {
|
|
315
|
+
coverDone();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
gate.label(label);
|
|
319
|
+
gate.cover();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Back or forward, at any moment. Our own history.go calls pass (the one for the router) or are swallowed (the
|
|
324
|
+
* undo). Anything else is the reader's: undone at once, then made part of the journey.
|
|
325
|
+
*/
|
|
326
|
+
function onPopState(e: PopStateEvent) {
|
|
327
|
+
if (store.passing) {
|
|
328
|
+
store.passing = false;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (store.bouncing) {
|
|
332
|
+
e.stopImmediatePropagation();
|
|
333
|
+
store.bouncing = false;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const index = navIndex();
|
|
337
|
+
const gate = store.gate;
|
|
338
|
+
// No Navigation API, no gate or reduced motion: the router's own traversal, with scroll and focus kept.
|
|
339
|
+
if (index < 0 || store.index < 0 || !gate || gate.reduce) {
|
|
340
|
+
if (store.phase === "idle" && urlKey() !== store.rendered) {
|
|
341
|
+
store.kind = "pop";
|
|
342
|
+
store.from = store.rendered;
|
|
343
|
+
store.phase = "covered";
|
|
344
|
+
store.sent = true;
|
|
345
|
+
store.instant = true;
|
|
346
|
+
armCeiling();
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const delta = index - store.index;
|
|
351
|
+
if (delta === 0) return;
|
|
352
|
+
e.stopImmediatePropagation();
|
|
353
|
+
undo(delta);
|
|
354
|
+
|
|
355
|
+
if (store.phase === "idle") {
|
|
356
|
+
store.hash = "";
|
|
357
|
+
begin("pop", labelAt(delta), () => traverse(delta));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!store.sent) {
|
|
361
|
+
// The gate is still closing on a push or a traversal not yet made: this press replaces it.
|
|
362
|
+
store.go = (store.kind === "pop" ? store.go : 0) + delta;
|
|
363
|
+
store.kind = "pop";
|
|
364
|
+
store.hash = "";
|
|
365
|
+
const go = store.go;
|
|
366
|
+
store.label = labelAt(go);
|
|
367
|
+
gate.label(store.label);
|
|
368
|
+
store.onCovered = go === 0 ? () => scheduleArrive() : () => traverse(go);
|
|
369
|
+
if (go === 0) {
|
|
370
|
+
// Back and forward cancelled out: the same page opens again.
|
|
371
|
+
store.landed = true;
|
|
372
|
+
store.from = "";
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// A page is on its way, or the gate is opening: the press waits for it, then runs.
|
|
377
|
+
store.after += delta;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
type Router = ReturnType<typeof useRouter>;
|
|
381
|
+
|
|
382
|
+
function navigate(router: Router, href: string, label?: string, replace = false) {
|
|
383
|
+
const url = new URL(href, location.href);
|
|
384
|
+
if (url.origin !== location.origin) {
|
|
385
|
+
location.assign(url.href);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const key = urlKey(url.pathname, url.search);
|
|
389
|
+
const target = url.pathname + url.search + url.hash;
|
|
390
|
+
// The same page (a hash link) or no gate mounted: the router's own way.
|
|
391
|
+
if (key === urlKey() || !store.gate) {
|
|
392
|
+
if (replace) router.replace(target);
|
|
393
|
+
else router.push(target);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// A click while the gate is already moving: the first journey wins.
|
|
397
|
+
if (store.phase !== "idle") return;
|
|
398
|
+
store.hash = url.hash;
|
|
399
|
+
store.positions.set(entryKey(), window.scrollY);
|
|
400
|
+
router.prefetch(target);
|
|
401
|
+
begin("push", label || store.name, () => {
|
|
402
|
+
store.sent = true;
|
|
403
|
+
if (replace) router.replace(target, { scroll: false });
|
|
404
|
+
else router.push(target, { scroll: false });
|
|
405
|
+
armCeiling();
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The router, with the gate: push and replace shut it first, back goes through the browser and the same gate. */
|
|
410
|
+
export function useGateRouter() {
|
|
411
|
+
const router = useRouter();
|
|
412
|
+
return {
|
|
413
|
+
push: useCallback((href: string, label?: string) => navigate(router, href, label), [router]),
|
|
414
|
+
replace: useCallback((href: string, label?: string) => navigate(router, href, label, true), [router]),
|
|
415
|
+
back: useCallback(() => window.history.back(), []),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The name the slate shows for a link: its `label`, else its text when short, else the site's name. */
|
|
420
|
+
function linkLabel(a: HTMLAnchorElement, label?: string) {
|
|
421
|
+
if (label) return label;
|
|
422
|
+
const text = a.textContent?.trim() ?? "";
|
|
423
|
+
return text.length > 0 && text.length <= 14 ? text : "";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** next/link through the gate. Modified clicks, new tabs and downloads stay the browser's own. */
|
|
427
|
+
export function GateLink({ onClick, replace, label, ...props }: ComponentProps<typeof Link> & { label?: string }) {
|
|
428
|
+
const router = useRouter();
|
|
429
|
+
return (
|
|
430
|
+
<Link
|
|
431
|
+
{...props}
|
|
432
|
+
replace={replace}
|
|
433
|
+
onClick={(e) => {
|
|
434
|
+
onClick?.(e);
|
|
435
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
436
|
+
const a = e.currentTarget;
|
|
437
|
+
if ((a.target && a.target !== "_self") || a.hasAttribute("download")) return;
|
|
438
|
+
e.preventDefault();
|
|
439
|
+
navigate(router, a.href, linkLabel(a, label), Boolean(replace));
|
|
440
|
+
}}
|
|
441
|
+
/>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** True while the gate is shut or shutting: a page mounting now is under it, and its entrance should wait. */
|
|
446
|
+
export function transitionCovers(): boolean {
|
|
447
|
+
return store.phase === "covering" || store.phase === "covered";
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Calls `ready` when the gate opens on this page, or at once when no journey is in progress (the first load).
|
|
452
|
+
* Returns the cancel. A page's entrance goes here, its start state only while transitionCovers() is true.
|
|
453
|
+
*/
|
|
454
|
+
export function onGateReveal(ready: () => void): () => void {
|
|
455
|
+
if (!transitionCovers()) {
|
|
456
|
+
ready();
|
|
457
|
+
return () => {};
|
|
458
|
+
}
|
|
459
|
+
let fired = false;
|
|
460
|
+
const fire = () => {
|
|
461
|
+
if (fired) return;
|
|
462
|
+
fired = true;
|
|
463
|
+
window.removeEventListener(GATE_REVEAL_EVENT, fire);
|
|
464
|
+
ceiling.kill();
|
|
465
|
+
ready();
|
|
466
|
+
};
|
|
467
|
+
window.addEventListener(GATE_REVEAL_EVENT, fire);
|
|
468
|
+
const ceiling = gsap.delayedCall(CEILING + 1, fire);
|
|
469
|
+
return () => {
|
|
470
|
+
fired = true;
|
|
471
|
+
window.removeEventListener(GATE_REVEAL_EVENT, fire);
|
|
472
|
+
ceiling.kill();
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The page content the gate moves. Keep position: fixed elements (a fixed header) outside it. */
|
|
477
|
+
export function GateStage({ children, className }: { children: ReactNode; className?: string }) {
|
|
478
|
+
return (
|
|
479
|
+
<div data-gate-stage="" className={className}>
|
|
480
|
+
{children}
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Reads the rendered URL. Its own Suspense boundary, so useSearchParams never pulls a static page into the client. */
|
|
486
|
+
function UrlWatch({ onUrl }: { onUrl: (key: string) => void }) {
|
|
487
|
+
const pathname = usePathname();
|
|
488
|
+
const search = useSearchParams();
|
|
489
|
+
const key = urlKey(pathname, search.toString());
|
|
490
|
+
useEffect(() => onUrl(key), [key, onUrl]);
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** One half of the slate: the full screen's leader, clipped by its bar, so the halves meet into one frame. */
|
|
495
|
+
function Slate({ side }: { side: "top" | "bottom" }) {
|
|
496
|
+
const ring = "color-mix(in oklch, var(--on-gate) 46%, transparent)";
|
|
497
|
+
const inner = "color-mix(in oklch, var(--on-gate) 30%, transparent)";
|
|
498
|
+
const cross = "color-mix(in oklch, var(--on-gate) 24%, transparent)";
|
|
499
|
+
return (
|
|
500
|
+
<div className={`absolute inset-x-0 h-[200%] ${side === "top" ? "top-0" : "bottom-0"}`}>
|
|
501
|
+
<div className="absolute left-1/2 top-1/2 aspect-square w-[min(64vw,22rem)] -translate-x-1/2 -translate-y-1/2">
|
|
502
|
+
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible" fill="none">
|
|
503
|
+
<circle cx="100" cy="100" r="92" style={{ stroke: ring, strokeWidth: 0.8 }} />
|
|
504
|
+
<circle cx="100" cy="100" r="78" style={{ stroke: inner, strokeWidth: 0.7 }} />
|
|
505
|
+
<line x1="100" y1="-14" x2="100" y2="214" style={{ stroke: cross, strokeWidth: 0.7 }} />
|
|
506
|
+
<circle
|
|
507
|
+
data-arc=""
|
|
508
|
+
cx="100"
|
|
509
|
+
cy="100"
|
|
510
|
+
r="78"
|
|
511
|
+
pathLength={1}
|
|
512
|
+
strokeDasharray="1 1"
|
|
513
|
+
strokeDashoffset="1"
|
|
514
|
+
transform="rotate(-90 100 100)"
|
|
515
|
+
className="stroke-accent"
|
|
516
|
+
style={{ strokeWidth: 1.4 }}
|
|
517
|
+
/>
|
|
518
|
+
</svg>
|
|
519
|
+
<div className="absolute inset-0 grid place-items-center">
|
|
520
|
+
<span
|
|
521
|
+
data-gate-label=""
|
|
522
|
+
className="whitespace-nowrap font-display text-[min(13vw,7.25rem)] leading-none tracking-[-0.03em] text-[var(--on-gate)]"
|
|
523
|
+
/>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function PageTransitionGate({
|
|
531
|
+
name = "",
|
|
532
|
+
tone = "deep",
|
|
533
|
+
children,
|
|
534
|
+
}: {
|
|
535
|
+
/** The site's name: the slate's word when a journey has no label of its own. */
|
|
536
|
+
name?: string;
|
|
537
|
+
tone?: "deep" | "inverse" | "field";
|
|
538
|
+
/** Rendered as is, never wrapped: put the page content in GateStage. */
|
|
539
|
+
children?: ReactNode;
|
|
540
|
+
}) {
|
|
541
|
+
const root = useRef<HTMLDivElement>(null);
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
store.name = name;
|
|
544
|
+
}, [name]);
|
|
545
|
+
|
|
546
|
+
useGSAP(
|
|
547
|
+
() => {
|
|
548
|
+
const el = root.current;
|
|
549
|
+
if (!el) return;
|
|
550
|
+
const q = gsap.utils.selector(el);
|
|
551
|
+
const [top, bottom] = q("[data-bar]");
|
|
552
|
+
const lights = q("[data-light]");
|
|
553
|
+
const arcs = q("[data-arc]");
|
|
554
|
+
const words = q("[data-gate-label]");
|
|
555
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
556
|
+
/** Scale about the middle of the screen, wherever the page is scrolled to. */
|
|
557
|
+
const origin = () => `50% ${window.scrollY + window.innerHeight / 2}px`;
|
|
558
|
+
|
|
559
|
+
let running: gsap.core.Timeline | null = null;
|
|
560
|
+
|
|
561
|
+
const locked = () => {
|
|
562
|
+
gsap.set(el, { autoAlpha: 1 });
|
|
563
|
+
gsap.set([top, bottom], { yPercent: 0 });
|
|
564
|
+
gsap.set(lights, { scaleX: 1 });
|
|
565
|
+
gsap.set(arcs, { strokeDashoffset: 0 });
|
|
566
|
+
};
|
|
567
|
+
const away = () => {
|
|
568
|
+
gsap.set(el, { autoAlpha: 0 });
|
|
569
|
+
gsap.set(top, { yPercent: -100 });
|
|
570
|
+
gsap.set(bottom, { yPercent: 100 });
|
|
571
|
+
gsap.set(lights, { scaleX: 0 });
|
|
572
|
+
gsap.set(arcs, { strokeDashoffset: 1 });
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const gate: Gate = {
|
|
576
|
+
reduce,
|
|
577
|
+
label: (text) => words.forEach((w) => (w.textContent = text)),
|
|
578
|
+
cover: (from = 0) => {
|
|
579
|
+
if (reduce) {
|
|
580
|
+
coverDone();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
running?.kill();
|
|
584
|
+
gsap.set(el, { autoAlpha: 1 });
|
|
585
|
+
const page = stage();
|
|
586
|
+
running = gsap
|
|
587
|
+
.timeline()
|
|
588
|
+
.fromTo(top, { yPercent: -100 }, { yPercent: 0, duration: CLOSE, ease: "power3.inOut" }, 0)
|
|
589
|
+
.fromTo(bottom, { yPercent: 100 }, { yPercent: 0, duration: CLOSE, ease: "power3.inOut" }, 0)
|
|
590
|
+
.fromTo(lights, { scaleX: 0 }, { scaleX: 1, duration: 0.3, ease: "expo.out" }, CLOSE - 0.08)
|
|
591
|
+
.fromTo(arcs, { strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: 0.26, ease: "power2.inOut" }, CLOSE - 0.08)
|
|
592
|
+
.call(coverDone, [], CLOSE);
|
|
593
|
+
if (page) {
|
|
594
|
+
running.fromTo(page, { scale: 1 }, { scale: 0.97, transformOrigin: origin(), duration: CLOSE, ease: "power2.in" }, 0);
|
|
595
|
+
}
|
|
596
|
+
if (from > 0) running.time(Math.min(from, CLOSE - 0.001), true);
|
|
597
|
+
},
|
|
598
|
+
reveal: () => {
|
|
599
|
+
store.phase = "revealing";
|
|
600
|
+
running?.kill();
|
|
601
|
+
locked();
|
|
602
|
+
running = gsap
|
|
603
|
+
.timeline({
|
|
604
|
+
onComplete: () => {
|
|
605
|
+
away();
|
|
606
|
+
finish();
|
|
607
|
+
},
|
|
608
|
+
})
|
|
609
|
+
.to(top, { yPercent: -100, duration: OPEN, ease: "power4.inOut" }, 0)
|
|
610
|
+
.to(bottom, { yPercent: 100, duration: OPEN, ease: "power4.inOut" }, 0)
|
|
611
|
+
.call(() => window.dispatchEvent(new Event(GATE_REVEAL_EVENT)), [], 0.04);
|
|
612
|
+
// The page comes forward through the opening gate; its own tween, so the gate is free once open.
|
|
613
|
+
const page = stage();
|
|
614
|
+
if (page) {
|
|
615
|
+
gsap.fromTo(
|
|
616
|
+
page,
|
|
617
|
+
{ scale: 1.035, transformOrigin: origin() },
|
|
618
|
+
{ scale: 1, duration: 0.9, ease: "expo.out", clearProps: "transform,transformOrigin" },
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
store.gate = gate;
|
|
624
|
+
if (store.label) gate.label(store.label);
|
|
625
|
+
if (store.index < 0) store.index = navIndex();
|
|
626
|
+
|
|
627
|
+
// Mounted mid-journey (the page remounted on the way): pick the gate up where the last one was.
|
|
628
|
+
if (store.phase === "covering") {
|
|
629
|
+
if (reduce) away();
|
|
630
|
+
else gate.cover((performance.now() - store.startedAt) / 1000);
|
|
631
|
+
} else if (store.phase === "covered") {
|
|
632
|
+
if (reduce) away();
|
|
633
|
+
else locked();
|
|
634
|
+
} else {
|
|
635
|
+
away();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
window.addEventListener("popstate", onPopState, true);
|
|
639
|
+
const previous = history.scrollRestoration;
|
|
640
|
+
history.scrollRestoration = "manual";
|
|
641
|
+
const record = () => {
|
|
642
|
+
if (store.phase === "idle") store.positions.set(entryKey(), window.scrollY);
|
|
643
|
+
};
|
|
644
|
+
window.addEventListener("scroll", record, { passive: true });
|
|
645
|
+
|
|
646
|
+
return () => {
|
|
647
|
+
running?.kill();
|
|
648
|
+
window.removeEventListener("popstate", onPopState, true);
|
|
649
|
+
window.removeEventListener("scroll", record);
|
|
650
|
+
history.scrollRestoration = previous;
|
|
651
|
+
if (store.gate === gate) store.gate = null;
|
|
652
|
+
};
|
|
653
|
+
},
|
|
654
|
+
{ scope: root },
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
const onUrl = useCallback((key: string) => {
|
|
658
|
+
if ((store.phase === "covering" || store.phase === "covered") && key !== store.from) {
|
|
659
|
+
store.landed = true;
|
|
660
|
+
if (store.phase === "covered") scheduleArrive();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (store.phase === "idle") {
|
|
664
|
+
store.rendered = key;
|
|
665
|
+
store.index = navIndex();
|
|
666
|
+
}
|
|
667
|
+
}, []);
|
|
668
|
+
|
|
669
|
+
return (
|
|
670
|
+
<>
|
|
671
|
+
{children}
|
|
672
|
+
<Suspense fallback={null}>
|
|
673
|
+
<UrlWatch onUrl={onUrl} />
|
|
674
|
+
</Suspense>
|
|
675
|
+
<div
|
|
676
|
+
ref={root}
|
|
677
|
+
data-page-transition-gate=""
|
|
678
|
+
data-gate=""
|
|
679
|
+
data-tone={tone}
|
|
680
|
+
aria-hidden="true"
|
|
681
|
+
className="invisible fixed inset-0 z-[90] select-none"
|
|
682
|
+
>
|
|
683
|
+
{(["top", "bottom"] as const).map((side) => (
|
|
684
|
+
<div
|
|
685
|
+
key={side}
|
|
686
|
+
data-bar={side}
|
|
687
|
+
className={`absolute inset-x-0 h-1/2 overflow-hidden bg-[var(--gate)] ${side === "top" ? "top-0" : "bottom-0 shadow-[0_-1px_0_0_var(--gate)]"}`}
|
|
688
|
+
>
|
|
689
|
+
<Slate side={side} />
|
|
690
|
+
{/* The leading edge carries a hairline, so the bar reads on a page as dark as itself; when the frame
|
|
691
|
+
locks, the accent runs along it from the centre. */}
|
|
692
|
+
<div
|
|
693
|
+
className={`absolute inset-x-0 h-px bg-[color-mix(in_oklch,var(--on-gate)_24%,transparent)] ${side === "top" ? "bottom-0" : "top-0"}`}
|
|
694
|
+
>
|
|
695
|
+
<div
|
|
696
|
+
data-light=""
|
|
697
|
+
className={`absolute inset-x-0 h-[2px] bg-accent ${side === "top" ? "bottom-0" : "top-0"}`}
|
|
698
|
+
style={{ transform: "scaleX(0)" }}
|
|
699
|
+
/>
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
))}
|
|
703
|
+
</div>
|
|
704
|
+
</>
|
|
705
|
+
);
|
|
706
|
+
}
|