@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,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
|
+
}
|