@struq/skill 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -36
- package/package.json +4 -2
- package/skills/struq/SKILL.md +139 -307
- 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/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -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/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- 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 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- 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 +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- 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/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- 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/niche-packets.md +0 -338
- 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 -340
- 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 -279
- package/skills/struq/references/sections.md +0 -141
- 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
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
# Route Transitions: the cover→swap→reveal overlay
|
|
2
|
-
|
|
3
|
-
One arm of the page-flow system, and the canonical way a site *navigates*. Siblings: [loaders.md](loaders.md) (first-load overlay, the same gesture starting closed) and [page-entrances.md](page-entrances.md) (the per-zone entrance of the revealed page).
|
|
4
|
-
|
|
5
|
-
This is the proven, studio-grade overlay approach. Use it for any narrative/marketing/portfolio site. For the easing vocabulary these timings draw on, see [transitions.md](transitions.md); for the Lenis/GSAP provider this builds on, see [setup.md](setup.md).
|
|
6
|
-
|
|
7
|
-
**The whole point:** a site should *navigate* like a film cuts. A deliberate surface covers the screen, the swap happens unseen, and the surface clears to reveal finished content. Never a hard route cut, never a whole-page cross-fade.
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## Philosophy: why an overlay, not `AnimatePresence`
|
|
12
|
-
|
|
13
|
-
The App Router **unmounts the old page on navigation**, so there is nothing left to animate *out*. The robust move is the opposite of a cross-fade:
|
|
14
|
-
|
|
15
|
-
1. **Cover**: a full-screen panel sweeps over the current page.
|
|
16
|
-
2. **Swap behind the cover**: `router.push()` runs while the screen is hidden; the old page unmounts, the new one mounts unseen.
|
|
17
|
-
3. **Reveal**: the panel clears, uncovering finished, already-painted content.
|
|
18
|
-
|
|
19
|
-
The user never sees a swap, a layout jump, or an empty frame. The first load is the same gesture with the cover *starting* closed. That's the loader ([loaders.md](loaders.md)).
|
|
20
|
-
|
|
21
|
-
**Cheap tells this replaces:** route changes that hard-cut; whole-page `opacity` cross-fades; the page visibly reflowing as fonts/images land.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## Architecture at a glance
|
|
26
|
-
|
|
27
|
-
```
|
|
28
|
-
app/layout.tsx (root)
|
|
29
|
-
└─ SmoothScrollProvider // Lenis on the GSAP ticker (setup.md §2)
|
|
30
|
-
└─ MotionConfig reducedMotion="user" // Motion respects reduced-motion globally
|
|
31
|
-
└─ PageTransitionProvider // owns the cover overlay + TransitionLink (this file)
|
|
32
|
-
└─ {children}
|
|
33
|
-
└─ app/(zone)/template.tsx // per-zone entrance (page-entrances.md)
|
|
34
|
-
── optional: <LoaderCounter id="intro" /> // first-load overlay (loaders.md)
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Provider **order matters**: SmoothScroll outermost (one scroll clock for everything), Motion config next, the transition provider innermost so its overlay sits above page content. Loaders mount inside `<body>`, above `children`.
|
|
38
|
-
|
|
39
|
-
Five things make it feel seamless, and all five are load-bearing:
|
|
40
|
-
|
|
41
|
-
1. **Cover → swap → reveal**, never animate a stale DOM.
|
|
42
|
-
2. **`TransitionLink` intercepts navigation** and prefetches on hover (next/link is bypassed, so the route must be warmed manually or the wipe finishes before the page exists).
|
|
43
|
-
3. **Loaders are session-gated**: they play once per session, never stacking on top of the route wipe on every click ([loaders.md](loaders.md)).
|
|
44
|
-
4. **Loaders are SSR-safe**: they sit *on top of* already-rendered HTML and reveal it ([loaders.md](loaders.md)).
|
|
45
|
-
5. **Zone `template.tsx` re-runs `ScrollTrigger.refresh()`** after the entrance and uses `clearProps:"all"`, so pinned/scrubbed sections measure correctly on the freshly-revealed page ([page-entrances.md](page-entrances.md)).
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## 1. The transition provider (cover → swap → reveal)
|
|
50
|
-
|
|
51
|
-
Drop-in. Wrap the app once; navigate with `<TransitionLink>` instead of `next/link`. Six variants (`wipe-up` default, `wipe-down/left/right`, `columns`, `iris`); reduced-motion collapses every variant to a 0.2s fade.
|
|
52
|
-
|
|
53
|
-
```tsx
|
|
54
|
-
// components/providers/PageTransition.tsx
|
|
55
|
-
"use client";
|
|
56
|
-
|
|
57
|
-
import {
|
|
58
|
-
createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode,
|
|
59
|
-
} from "react";
|
|
60
|
-
import { usePathname, useRouter } from "next/navigation";
|
|
61
|
-
import { gsap } from "@/lib/gsap";
|
|
62
|
-
|
|
63
|
-
export type TransitionVariant =
|
|
64
|
-
| "wipe-up" | "wipe-down" | "wipe-left" | "wipe-right" | "columns" | "iris";
|
|
65
|
-
|
|
66
|
-
type NavOpts = { variant?: TransitionVariant; fillClass?: string };
|
|
67
|
-
const TransitionCtx = createContext<{ navigate: (href: string, opts?: NavOpts) => void } | null>(null);
|
|
68
|
-
|
|
69
|
-
const COLS = 6;
|
|
70
|
-
const DEFAULT_FILL = "bg-ink"; // the overlay lives outside themed wrappers, so use a concrete token
|
|
71
|
-
|
|
72
|
-
// One timing set, inside the 900ms budget: cover + delay + reveal = 0.40 + 0.05 + 0.45.
|
|
73
|
-
// Curtains and panels take the baseline's quint-in-out (power4.inOut) on every variant.
|
|
74
|
-
const EASE = "power4.inOut";
|
|
75
|
-
const COVER = 0.4, REVEAL = 0.45, PAINT_DELAY = 0.05;
|
|
76
|
-
const BAR = 0.26, BAR_STAGGER = 0.03; // columns: 0.26 + 5 x 0.03 = 0.41 per phase
|
|
77
|
-
|
|
78
|
-
export function useTransitionRouter() {
|
|
79
|
-
const ctx = useContext(TransitionCtx);
|
|
80
|
-
if (!ctx) throw new Error("useTransitionRouter must be used within PageTransitionProvider");
|
|
81
|
-
return { push: ctx.navigate };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const prefersReduced = () =>
|
|
85
|
-
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
86
|
-
|
|
87
|
-
// Set the overlay's starting state for the chosen variant.
|
|
88
|
-
function prep(variant: TransitionVariant, panel: HTMLElement, colsWrap: HTMLElement, reduce: boolean) {
|
|
89
|
-
const useCols = !reduce && variant === "columns";
|
|
90
|
-
gsap.set(colsWrap, { autoAlpha: useCols ? 1 : 0 });
|
|
91
|
-
gsap.set(panel, {
|
|
92
|
-
autoAlpha: useCols ? 0 : 1,
|
|
93
|
-
clipPath: !reduce && variant === "iris" ? "circle(0% at 50% 50%)" : "none",
|
|
94
|
-
scaleX: 1, scaleY: 1,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// COVER: panel comes in, then fires onComplete (which triggers router.push).
|
|
99
|
-
function cover(variant: TransitionVariant, panel: HTMLElement, bars: HTMLElement[], reduce: boolean, onComplete: () => void) {
|
|
100
|
-
if (reduce) return gsap.fromTo(panel, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2, onComplete });
|
|
101
|
-
const d = COVER, ease = EASE;
|
|
102
|
-
switch (variant) {
|
|
103
|
-
case "wipe-down": return gsap.fromTo(panel, { scaleY: 0, transformOrigin: "top" }, { scaleY: 1, duration: d, ease, onComplete });
|
|
104
|
-
case "wipe-left": return gsap.fromTo(panel, { scaleX: 0, transformOrigin: "right" }, { scaleX: 1, duration: d, ease, onComplete });
|
|
105
|
-
case "wipe-right": return gsap.fromTo(panel, { scaleX: 0, transformOrigin: "left" }, { scaleX: 1, duration: d, ease, onComplete });
|
|
106
|
-
case "iris": return gsap.fromTo(panel, { clipPath: "circle(0% at 50% 50%)" }, { clipPath: "circle(150% at 50% 50%)", duration: d, ease, onComplete });
|
|
107
|
-
case "columns": return gsap.fromTo(bars, { scaleY: 0, transformOrigin: "bottom" }, { scaleY: 1, duration: BAR, ease, stagger: BAR_STAGGER, onComplete });
|
|
108
|
-
case "wipe-up": default:
|
|
109
|
-
return gsap.fromTo(panel, { scaleY: 0, transformOrigin: "bottom" }, { scaleY: 1, duration: d, ease, onComplete });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// REVEAL: cover goes out, uncovering the new page. `delay` lets it paint first.
|
|
114
|
-
function reveal(variant: TransitionVariant, panel: HTMLElement, bars: HTMLElement[], reduce: boolean, onComplete: () => void) {
|
|
115
|
-
if (reduce) return gsap.to(panel, { autoAlpha: 0, duration: 0.2, onComplete });
|
|
116
|
-
const d = REVEAL, ease = EASE, delay = PAINT_DELAY;
|
|
117
|
-
switch (variant) {
|
|
118
|
-
case "wipe-down": return gsap.to(panel, { scaleY: 0, transformOrigin: "bottom", duration: d, ease, delay, onComplete });
|
|
119
|
-
case "wipe-left": return gsap.to(panel, { scaleX: 0, transformOrigin: "left", duration: d, ease, delay, onComplete });
|
|
120
|
-
case "wipe-right": return gsap.to(panel, { scaleX: 0, transformOrigin: "right", duration: d, ease, delay, onComplete });
|
|
121
|
-
case "iris": return gsap.to(panel, { clipPath: "circle(0% at 50% 50%)", duration: d, ease, delay, onComplete });
|
|
122
|
-
case "columns": return gsap.to(bars, { scaleY: 0, transformOrigin: "top", duration: BAR, ease, stagger: BAR_STAGGER, delay, onComplete });
|
|
123
|
-
case "wipe-up": default:
|
|
124
|
-
return gsap.to(panel, { scaleY: 0, transformOrigin: "top", duration: d, ease, delay, onComplete });
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function PageTransitionProvider({ children }: { children: ReactNode }) {
|
|
129
|
-
const router = useRouter();
|
|
130
|
-
const pathname = usePathname();
|
|
131
|
-
const panel = useRef<HTMLDivElement>(null);
|
|
132
|
-
const cols = useRef<HTMLDivElement>(null);
|
|
133
|
-
const pending = useRef<string | null>(null);
|
|
134
|
-
const variantRef = useRef<TransitionVariant>("wipe-up");
|
|
135
|
-
const [fill, setFill] = useState(DEFAULT_FILL);
|
|
136
|
-
const [covering, setCovering] = useState(false);
|
|
137
|
-
|
|
138
|
-
// Reveal once the path actually changes while covered. The swap is now committed.
|
|
139
|
-
useEffect(() => {
|
|
140
|
-
if (!covering) return;
|
|
141
|
-
const p = panel.current;
|
|
142
|
-
if (!p) return;
|
|
143
|
-
const bars = cols.current ? gsap.utils.toArray<HTMLElement>(cols.current.children) : [];
|
|
144
|
-
const t = reveal(variantRef.current, p, bars, prefersReduced(), () => setCovering(false));
|
|
145
|
-
return () => { t.kill(); };
|
|
146
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
-
}, [pathname]);
|
|
148
|
-
|
|
149
|
-
const navigate = useCallback((href: string, opts?: NavOpts) => {
|
|
150
|
-
if (href === pathname) return;
|
|
151
|
-
const p = panel.current, c = cols.current;
|
|
152
|
-
if (!p || !c) { router.push(href); return; } // fallback if overlay isn't ready
|
|
153
|
-
const variant = opts?.variant ?? "wipe-up";
|
|
154
|
-
variantRef.current = variant;
|
|
155
|
-
setFill(opts?.fillClass ?? DEFAULT_FILL);
|
|
156
|
-
const reduce = prefersReduced();
|
|
157
|
-
const bars = gsap.utils.toArray<HTMLElement>(c.children);
|
|
158
|
-
prep(variant, p, c, reduce);
|
|
159
|
-
pending.current = href;
|
|
160
|
-
setCovering(true);
|
|
161
|
-
cover(variant, p, bars, reduce, () => {
|
|
162
|
-
if (pending.current) router.push(pending.current);
|
|
163
|
-
pending.current = null;
|
|
164
|
-
});
|
|
165
|
-
}, [pathname, router]);
|
|
166
|
-
|
|
167
|
-
return (
|
|
168
|
-
<TransitionCtx.Provider value={{ navigate }}>
|
|
169
|
-
{children}
|
|
170
|
-
<div aria-hidden className="pointer-events-none fixed inset-0 z-[10000]">
|
|
171
|
-
{/* Plain template strings: a fresh scaffold has no `cn` helper (setup.md §1). */}
|
|
172
|
-
<div ref={panel} style={{ transform: "scaleY(0)" }} className={`absolute inset-0 ${fill}`} />
|
|
173
|
-
<div ref={cols} style={{ visibility: "hidden" }} className="absolute inset-0 flex">
|
|
174
|
-
{Array.from({ length: COLS }).map((_, i) => (
|
|
175
|
-
<div key={i} style={{ transform: "scaleY(0)" }} className={`h-full flex-1 ${fill}`} />
|
|
176
|
-
))}
|
|
177
|
-
</div>
|
|
178
|
-
</div>
|
|
179
|
-
</TransitionCtx.Provider>
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
```tsx
|
|
185
|
-
// components/ui/TransitionLink.tsx: use everywhere you'd reach for next/link
|
|
186
|
-
"use client";
|
|
187
|
-
import { type ReactNode } from "react";
|
|
188
|
-
import { useRouter } from "next/navigation";
|
|
189
|
-
import { useTransitionRouter, type TransitionVariant } from "@/components/providers/PageTransition";
|
|
190
|
-
|
|
191
|
-
export function TransitionLink({
|
|
192
|
-
href, children, className, variant, fillClass, ...rest
|
|
193
|
-
}: {
|
|
194
|
-
href: string; children: ReactNode; className?: string;
|
|
195
|
-
variant?: TransitionVariant; fillClass?: string;
|
|
196
|
-
} & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "className" | "children">) {
|
|
197
|
-
const { push } = useTransitionRouter();
|
|
198
|
-
const router = useRouter();
|
|
199
|
-
return (
|
|
200
|
-
<a
|
|
201
|
-
href={href}
|
|
202
|
-
className={className}
|
|
203
|
-
// next/link is bypassed, so prefetch manually or the wipe finishes before the route exists.
|
|
204
|
-
onPointerEnter={() => router.prefetch(href)}
|
|
205
|
-
onFocus={() => router.prefetch(href)}
|
|
206
|
-
onClick={(e) => {
|
|
207
|
-
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
208
|
-
e.preventDefault();
|
|
209
|
-
push(href, { variant, fillClass });
|
|
210
|
-
}}
|
|
211
|
-
{...rest}
|
|
212
|
-
>
|
|
213
|
-
{children}
|
|
214
|
-
</a>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
**Craft notes**
|
|
220
|
-
- The reveal is *slightly longer* than the cover (0.45s vs 0.4s) and carries a 0.05s `delay`. The incoming page gets a beat to paint before it's uncovered, so the reveal never exposes an empty frame. The three numbers add up to exactly the 900ms budget below; change one and re-add them.
|
|
221
|
-
- `power4.inOut` (quint-in-out, the baseline's curtain and panel ease) is the right curve here: it's an A→B move with both endpoints visible (the curtain holds, then commits), which is exactly what in-out eases are for ([transitions.md](transitions.md) easing table). The menu panel ([nav-header.md](nav-header.md) §3) uses the same ease, so the site has one curtain voice.
|
|
222
|
-
- The overlay sits at the **root** layout, outside per-site theme wrappers, so its `fillClass` must be a concrete color token (e.g. `bg-ink`, or a project `.tx-fill-*` class), not a scoped one.
|
|
223
|
-
- `fillClass` is set explicitly on every nav so a per-link color never leaks into the next transition.
|
|
224
|
-
- Keep total cover+reveal **≤ 900ms**. Block longer than that on every click and the site feels slow, not cinematic.
|
|
225
|
-
- Scroll resets naturally on route change (Next scrolls to top); the transition doesn't need to touch Lenis.
|
|
226
|
-
|
|
227
|
-
---
|
|
228
|
-
|
|
229
|
-
## 2. Wiring it up (root layout)
|
|
230
|
-
|
|
231
|
-
```tsx
|
|
232
|
-
// app/layout.tsx
|
|
233
|
-
import { MotionConfig } from "motion/react";
|
|
234
|
-
import { SmoothScrollProvider } from "@/components/providers/smooth-scroll"; // setup.md §2
|
|
235
|
-
import { PageTransitionProvider } from "@/components/providers/PageTransition";
|
|
236
|
-
import { LoaderCounter } from "@/components/ui/LoaderCounter"; // optional, see loaders.md
|
|
237
|
-
|
|
238
|
-
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
239
|
-
return (
|
|
240
|
-
<html lang="en">
|
|
241
|
-
<body className="bg-surface-0 text-ink antialiased">
|
|
242
|
-
<SmoothScrollProvider>
|
|
243
|
-
<MotionConfig reducedMotion="user">
|
|
244
|
-
{/* Optional first-load overlay; omit on app/dashboard zones (loaders.md) */}
|
|
245
|
-
<LoaderCounter />
|
|
246
|
-
<PageTransitionProvider>{children}</PageTransitionProvider>
|
|
247
|
-
</MotionConfig>
|
|
248
|
-
</SmoothScrollProvider>
|
|
249
|
-
</body>
|
|
250
|
-
</html>
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
---
|
|
256
|
-
|
|
257
|
-
## Timing & easing reference
|
|
258
|
-
|
|
259
|
-
| Phase | Duration | Ease | Transform | Origin |
|
|
260
|
-
|---|---|---|---|---|
|
|
261
|
-
| Cover: wipe (default) | 0.4s | `power4.inOut` | `scaleY 0→1` | bottom |
|
|
262
|
-
| Cover: columns | 0.26s per bar (stagger 0.03), 0.41s total | `power4.inOut` | `scaleY 0→1` | bottom |
|
|
263
|
-
| Cover: iris | 0.4s | `power4.inOut` | `clipPath circle 0%→150%` | center |
|
|
264
|
-
| Reveal: wipe (default) | 0.45s (delay 0.05) | `power4.inOut` | `scaleY 1→0` | top |
|
|
265
|
-
| Reveal: columns | 0.26s per bar (stagger 0.03, delay 0.05), 0.46s total | `power4.inOut` | `scaleY 1→0` | top |
|
|
266
|
-
| Reveal: iris | 0.45s (delay 0.05) | `power4.inOut` | `clipPath circle 150%→0%` | center |
|
|
267
|
-
| **Total, any variant** | at most 0.9s | | | |
|
|
268
|
-
| **Reduced motion (all)** | 0.2s | none | opacity only | n/a |
|
|
269
|
-
|
|
270
|
-
---
|
|
271
|
-
|
|
272
|
-
## Anti-patterns (sweep before shipping)
|
|
273
|
-
|
|
274
|
-
- **Hard route cut**, or a whole-page `opacity` cross-fade → the cover→swap→reveal wipe.
|
|
275
|
-
- **Blocking > 900ms** per nav → tighten durations; cover+reveal ≤ 900ms.
|
|
276
|
-
- **Animating a stale page out** with `AnimatePresence` on route change → there's nothing to animate; cover instead.
|
|
277
|
-
- Reduced motion: every variant already collapses to a 0.2s fade; `MotionConfig reducedMotion="user"` covers Motion-driven UI globally, and GSAP code guards itself (the `prefersReduced()` checks above).
|
|
278
|
-
|
|
279
|
-
---
|
|
280
|
-
|
|
281
|
-
For in-page surface handoffs (section curtains), shared-element match-cuts (`layoutId`), and the full easing rationale, see [transitions.md](transitions.md). For the Lenis/GSAP provider this builds on, see [setup.md](setup.md).
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
# The Scaling System: fluid root-rem, one knob
|
|
2
|
-
|
|
3
|
-
The single most expensive recurring mistake is building a layout that looks right at
|
|
4
|
-
one width and then fixing every element by hand at every other width. That tax comes
|
|
5
|
-
from authoring in pixels and treating breakpoints as *resize* tools. This file kills it:
|
|
6
|
-
**one root-level knob scales everything proportionally**, and breakpoints are reserved
|
|
7
|
-
for genuine *layout changes* (column count, stack to row), never for resizing.
|
|
8
|
-
|
|
9
|
-
The mental model: design once at a reference width; the whole interface (type, padding,
|
|
10
|
-
gaps, component sizes) breathes in and out together as the viewport changes. No
|
|
11
|
-
per-element viewport patching.
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## 1. The knob: scale the root font-size with the viewport
|
|
16
|
-
|
|
17
|
-
Everything authored in `rem` is measured against `html`'s font-size. Scale that one
|
|
18
|
-
value with the viewport and *every rem in the build scales with it at once.*
|
|
19
|
-
|
|
20
|
-
```css
|
|
21
|
-
/* app/globals.css (Next) or src/index.css (Vite): the ONE knob */
|
|
22
|
-
html {
|
|
23
|
-
font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem);
|
|
24
|
-
/* 16px up to about 640w (so 16px at 390), about 17.6px at 1440, frozen at 18px from about 1640 */
|
|
25
|
-
}
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
### Why this exact form (the a11y-safe part, do not skip)
|
|
29
|
-
|
|
30
|
-
A pure-`vw` root (`font-size: 2.6vw`) scales beautifully **but breaks browser zoom and
|
|
31
|
-
the user's font-size preference**: `vw` ignores both, so zoom does nothing to text and
|
|
32
|
-
accessibility is gone. The fix is to **always keep a `rem` term in the expression**:
|
|
33
|
-
|
|
34
|
-
```
|
|
35
|
-
clamp( MIN_rem , REM_BASE_rem + SLOPE_vw , MAX_rem )
|
|
36
|
-
└── zoomable anchor ──┘ └ fluid part ┘
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
The `REM_BASE` term responds to zoom and OS font settings; the `vw` term adds the fluid
|
|
40
|
-
scaling on top. Result: proportional scaling **and** working zoom. This is the only
|
|
41
|
-
acceptable form. A clamp whose middle term is `vw`-only is the same bug as pure-`vw`.
|
|
42
|
-
|
|
43
|
-
> Deriving the middle term: pick two anchors, (minViewport, minRoot) and
|
|
44
|
-
> (maxViewport, maxRoot), both in px. `slope_vw = 100 * (maxRoot - minRoot) / (maxVp - minVp)`,
|
|
45
|
-
> `base_rem = (minRoot - slope_vw/100 * minVp) / 16`. Or just use a fluid-clamp
|
|
46
|
-
> calculator and paste. The two clamp ends are your readable floor and ceiling.
|
|
47
|
-
|
|
48
|
-
---
|
|
49
|
-
|
|
50
|
-
## 2. Keep the root band narrow, layer the drama on top
|
|
51
|
-
|
|
52
|
-
The root knob also scales **body text**, so a wide band makes body huge on 4K or tiny on
|
|
53
|
-
mobile. Keep the band **modest and readable** (16px to 18px, frozen from about 1640). The body
|
|
54
|
-
should feel basically the same size everywhere; what you want scaling dramatically is the
|
|
55
|
-
*display* type and *section rhythm*, not paragraphs.
|
|
56
|
-
|
|
57
|
-
So the system is two layers:
|
|
58
|
-
|
|
59
|
-
1. **Root knob (narrow band)** carries proportional scaling of all spacing, gaps,
|
|
60
|
-
component sizes, and body text. Set once, never touched again.
|
|
61
|
-
2. **Per-element `clamp()` on the extremes**: the hero/display type keeps its own
|
|
62
|
-
aggressive fluid clamp *on top* of the root:
|
|
63
|
-
```css
|
|
64
|
-
/* the text-display role token (typography.md §2.4): 46px at 390, about 120px at 1440 */
|
|
65
|
-
@theme { --text-display: clamp(2.875rem, 1.2rem + 6.86vw, 8rem); } /* drama lives here, not in the root */
|
|
66
|
-
```
|
|
67
|
-
These two compose cleanly: the root scales the rem baseline gently; the hero's own
|
|
68
|
-
clamp adds the big swing. The middle term keeps its `rem` part, so zoom still scales
|
|
69
|
-
the hero; a `vw`-only middle (`clamp(3rem, 8vw, 9rem)`) is the same bug as a pure-`vw`
|
|
70
|
-
root. Components use the token (`font-display text-display`), never a hand-written clamp.
|
|
71
|
-
|
|
72
|
-
Rule of thumb: **if it's body, labels, padding, gaps, radii, use plain rem and let the root
|
|
73
|
-
carry it. If it's a display headline or a showpiece measurement, give it its own
|
|
74
|
-
`clamp()`.** The full display-type scale (sizes, tracking, leading per tier) lives in
|
|
75
|
-
[typography.md](typography.md).
|
|
76
|
-
|
|
77
|
-
---
|
|
78
|
-
|
|
79
|
-
## 3. Author in rem: no px for anything that should scale
|
|
80
|
-
|
|
81
|
-
This is the discipline that makes the knob work:
|
|
82
|
-
|
|
83
|
-
- **Type:** rem (Tailwind `text-*` is already rem). Display type: its own `clamp()`.
|
|
84
|
-
- **Spacing / padding / gap / margin:** rem (Tailwind `p-*`, `gap-*`, `space-*`, `m-*`
|
|
85
|
-
are already rem). Section padding stays role-varied per the
|
|
86
|
-
[baseline](baseline-prompt.md), just expressed in rem so it scales.
|
|
87
|
-
- **Component sizing, max-widths, radii:** rem / `ch` / `%`. `max-w-[44ch]` scales with
|
|
88
|
-
type; `rounded-[0.5rem]` scales with the knob.
|
|
89
|
-
- **Hairlines & true 1px details:** `px` is correct. Borders, focus rings, and
|
|
90
|
-
decorative hairlines should stay crisp at 1px and must NOT scale. This is the one place
|
|
91
|
-
px is right.
|
|
92
|
-
- **Media:** width in `%`/`rem`/`vw`, height via aspect-ratio (also prevents CLS).
|
|
93
|
-
|
|
94
|
-
Tailwind (v4 or later) cooperates for free: its spacing scale and font sizes are rem-based, so once
|
|
95
|
-
the root is wired, every utility inherits the scaling. You mostly just *stop reaching for
|
|
96
|
-
arbitrary px values*.
|
|
97
|
-
|
|
98
|
-
---
|
|
99
|
-
|
|
100
|
-
## 4. Breakpoints change layout, never size
|
|
101
|
-
|
|
102
|
-
With the knob doing the resizing, breakpoints (`md:`, `lg:`) are freed for their real
|
|
103
|
-
job: **structural** changes that fluid scaling can't express.
|
|
104
|
-
|
|
105
|
-
- column count: `grid-cols-1 md:grid-cols-12`
|
|
106
|
-
- direction: `flex-col lg:flex-row`
|
|
107
|
-
- show/hide: a nav collapsing to a menu
|
|
108
|
-
- composition swaps: a 50/50 split becoming full-bleed
|
|
109
|
-
|
|
110
|
-
You should rarely write `text-2xl md:text-4xl lg:text-6xl` ladders again. That's the
|
|
111
|
-
manual-resize anti-pattern the root knob replaces. One fluid value, no ladder.
|
|
112
|
-
|
|
113
|
-
---
|
|
114
|
-
|
|
115
|
-
## 5. Bounds and the test matrix
|
|
116
|
-
|
|
117
|
-
- **Ceiling:** the upper `clamp()` arm freezes growth so 2560px+ monitors don't keep
|
|
118
|
-
inflating. Past ~1600 to 1920 the design should *stop scaling and start adding margin*,
|
|
119
|
-
not get bigger. (Cap the content container with a rem/`ch` max-width.)
|
|
120
|
-
- **Floor:** the lower arm holds the readable minimum; never let the knob drive body
|
|
121
|
-
below 16px effective or labels below the 12px floor ([anti-patterns.md](anti-patterns.md) §12).
|
|
122
|
-
- **Test at 360 / 768 / 1280 / 1920 / 2560.** If every element looks proportional at all
|
|
123
|
-
five *without per-width overrides*, the system is working. If you're adding `lg:`
|
|
124
|
-
size overrides to fix spacing, something was authored in px: find it and convert.
|
|
125
|
-
- **Zoom test:** Ctrl/Cmd-+ to 150% must enlarge text. If it doesn't, a `vw`-only value
|
|
126
|
-
leaked into the root or into a font-size. Fix it (see §1).
|
|
127
|
-
|
|
128
|
-
---
|
|
129
|
-
|
|
130
|
-
## 6. Wiring per stack
|
|
131
|
-
|
|
132
|
-
The knob is just CSS, so it's framework-agnostic. Only the file differs:
|
|
133
|
-
|
|
134
|
-
```css
|
|
135
|
-
/* Next App Router → app/globals.css | Vite → src/index.css (imported in main.tsx) */
|
|
136
|
-
@import "tailwindcss";
|
|
137
|
-
|
|
138
|
-
html { font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem); }
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Full stack-detection and the rest of the primitive translations are in
|
|
142
|
-
[stack-targets.md](stack-targets.md). Nothing else about scaling changes between Vite and
|
|
143
|
-
Next: it's one CSS rule in the global stylesheet either way.
|
|
144
|
-
|
|
145
|
-
---
|
|
146
|
-
|
|
147
|
-
**Cheap tell:** px-everything layout with `sm: md: lg: xl:` size ladders on type and
|
|
148
|
-
padding, hand-tuned per breakpoint, that still drifts at 1440 and 2560 and needs a fresh
|
|
149
|
-
override every time a new screen is tested.
|
|
150
|
-
**Instead:** One a11y-safe root clamp, everything in rem, breakpoints only for structural change,
|
|
151
|
-
display type on its own `clamp()`: proportional at every width with zero per-element fixes.
|