@struq/skill 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +4 -2
  3. package/skills/struq/SKILL.md +139 -307
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
  19. package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
  20. package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
  21. package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
  22. package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
  23. package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
  24. package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
  25. package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
  26. package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/images.ts +17 -0
  29. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  30. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  31. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  32. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  33. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  34. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  35. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  36. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  37. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  38. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  39. package/skills/struq/blocks/sections/approach.tsx +661 -0
  40. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  41. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  42. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  43. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  44. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  45. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  46. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  47. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  48. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  49. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  50. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  51. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  52. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  53. package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
  54. package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
  55. package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
  56. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  57. package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
  58. package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
  59. package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
  60. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  61. package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
  62. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  63. package/skills/struq/canon/canon.json +627 -0
  64. package/skills/struq/cinematic/README.md +7 -18
  65. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  66. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  67. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  68. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  69. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  70. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  71. package/skills/struq/recipes/app/fonts.ts +12 -22
  72. package/skills/struq/recipes/app/globals.css +34 -177
  73. package/skills/struq/recipes/app/layout.tsx +13 -11
  74. package/skills/struq/references/craft.md +77 -0
  75. package/skills/struq/references/iconography.md +28 -230
  76. package/skills/struq/references/stack-targets.md +23 -74
  77. package/skills/struq/references/tells.md +52 -0
  78. package/skills/struq/references/triage.md +6 -7
  79. package/skills/struq/scripts/blocks.mjs +156 -0
  80. package/skills/struq/scripts/canon.mjs +106 -281
  81. package/skills/struq/scripts/check.mjs +92 -126
  82. package/skills/struq/scripts/images.mjs +2 -2
  83. package/skills/struq/scripts/log.mjs +6 -23
  84. package/skills/struq/scripts/tokens.mjs +222 -0
  85. package/skills/struq/cinematic/showpieces/README.md +0 -18
  86. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
  87. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
  88. package/skills/struq/references/anti-patterns.md +0 -330
  89. package/skills/struq/references/baseline-prompt.md +0 -122
  90. package/skills/struq/references/brand.md +0 -143
  91. package/skills/struq/references/brief-router.md +0 -218
  92. package/skills/struq/references/composition.md +0 -190
  93. package/skills/struq/references/depth-parallax.md +0 -235
  94. package/skills/struq/references/direction.md +0 -188
  95. package/skills/struq/references/feel-layer.md +0 -107
  96. package/skills/struq/references/gsap-api.md +0 -293
  97. package/skills/struq/references/hero-scenes.md +0 -107
  98. package/skills/struq/references/imagery.md +0 -125
  99. package/skills/struq/references/loaders.md +0 -287
  100. package/skills/struq/references/micro-interactions.md +0 -270
  101. package/skills/struq/references/mobile-composition.md +0 -70
  102. package/skills/struq/references/nav-header.md +0 -296
  103. package/skills/struq/references/niche-packets.md +0 -338
  104. package/skills/struq/references/page-architecture.md +0 -134
  105. package/skills/struq/references/page-entrances.md +0 -198
  106. package/skills/struq/references/performance.md +0 -235
  107. package/skills/struq/references/pinning-scrub.md +0 -340
  108. package/skills/struq/references/pitfalls.md +0 -290
  109. package/skills/struq/references/route-transitions.md +0 -281
  110. package/skills/struq/references/scaling.md +0 -151
  111. package/skills/struq/references/scroll-reveals.md +0 -279
  112. package/skills/struq/references/sections.md +0 -141
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
  123. package/skills/struq/scripts/font-names.mjs +0 -40
  124. package/skills/struq/scripts/recipes.mjs +0 -135
  125. /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
  126. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
  127. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
  128. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
  129. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
@@ -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.