@struq/skill 0.0.0-stage → 0.2.2

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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -2
  3. package/cli/struq.mjs +132 -0
  4. package/package.json +41 -4
  5. package/skills/struq/SKILL.md +237 -0
  6. package/skills/struq/cinematic/README.md +409 -0
  7. package/skills/struq/cinematic/checklists/01-preproduction.md +15 -0
  8. package/skills/struq/cinematic/checklists/02-implementation.md +17 -0
  9. package/skills/struq/cinematic/checklists/03-polish.md +17 -0
  10. package/skills/struq/cinematic/examples/code/nextjs/README.md +13 -0
  11. package/skills/struq/cinematic/examples/code/nextjs/app/cinematic-experience.tsx +13 -0
  12. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +42 -0
  13. package/skills/struq/cinematic/examples/code/nextjs/app/page.tsx +6 -0
  14. package/skills/struq/cinematic/examples/code/shared/CameraRig.tsx +65 -0
  15. package/skills/struq/cinematic/examples/code/shared/ExperienceCanvas.tsx +20 -0
  16. package/skills/struq/cinematic/examples/code/shared/ExperienceDirector.tsx +57 -0
  17. package/skills/struq/cinematic/examples/code/shared/ExperienceOverlay.tsx +37 -0
  18. package/skills/struq/cinematic/examples/code/shared/ExperiencePage.tsx +52 -0
  19. package/skills/struq/cinematic/examples/code/shared/GsapFrameLoop.tsx +32 -0
  20. package/skills/struq/cinematic/examples/code/shared/InteriorRoot.tsx +30 -0
  21. package/skills/struq/cinematic/examples/code/shared/README.md +20 -0
  22. package/skills/struq/cinematic/examples/code/shared/SubjectRig.tsx +58 -0
  23. package/skills/struq/cinematic/examples/code/shared/build-experience-timeline.ts +237 -0
  24. package/skills/struq/cinematic/examples/code/shared/direction.ts +52 -0
  25. package/skills/struq/cinematic/examples/code/shared/gsap-client.ts +32 -0
  26. package/skills/struq/cinematic/examples/code/shared/math.ts +12 -0
  27. package/skills/struq/cinematic/examples/code/shared/registry.ts +58 -0
  28. package/skills/struq/cinematic/examples/code/shared/runtime.ts +52 -0
  29. package/skills/struq/cinematic/examples/code/shared/styles.css +127 -0
  30. package/skills/struq/cinematic/examples/code/shared/useExperienceInput.ts +42 -0
  31. package/skills/struq/cinematic/examples/code/vite/README.md +14 -0
  32. package/skills/struq/cinematic/examples/code/vite/src/App.tsx +11 -0
  33. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +37 -0
  34. package/skills/struq/cinematic/examples/code/vite/src/main.tsx +9 -0
  35. package/skills/struq/cinematic/examples/concise/01-chapter-spec-example.md +42 -0
  36. package/skills/struq/cinematic/examples/concise/02-handoff-example.md +25 -0
  37. package/skills/struq/cinematic/examples/concise/03-audit-finding-example.md +13 -0
  38. package/skills/struq/cinematic/examples/verbose/01-full-experience-direction.md +687 -0
  39. package/skills/struq/cinematic/examples/verbose/02-full-audit-example.md +362 -0
  40. package/skills/struq/cinematic/references/01-core-mental-model.md +221 -0
  41. package/skills/struq/cinematic/references/02-vocabulary.md +384 -0
  42. package/skills/struq/cinematic/references/03-experience-architecture.md +302 -0
  43. package/skills/struq/cinematic/references/04-timeline-choreography.md +264 -0
  44. package/skills/struq/cinematic/references/05-handoff-contracts.md +241 -0
  45. package/skills/struq/cinematic/references/06-three-rig-architecture.md +286 -0
  46. package/skills/struq/cinematic/references/07-stack-integration.md +228 -0
  47. package/skills/struq/cinematic/references/08-gsap-plugin-patterns.md +218 -0
  48. package/skills/struq/cinematic/references/09-tuning-harness.md +202 -0
  49. package/skills/struq/cinematic/references/10-quality-systems.md +206 -0
  50. package/skills/struq/cinematic/references/11-agent-workflows.md +246 -0
  51. package/skills/struq/cinematic/references/12-audit-rubric.md +176 -0
  52. package/skills/struq/cinematic/references/13-official-docs.md +53 -0
  53. package/skills/struq/cinematic/templates/01-experience-brief.md +105 -0
  54. package/skills/struq/cinematic/templates/02-experience-contract.md +80 -0
  55. package/skills/struq/cinematic/templates/03-chapter-spec.md +103 -0
  56. package/skills/struq/cinematic/templates/04-handoff-contract.md +102 -0
  57. package/skills/struq/cinematic/templates/05-ownership-matrix.md +29 -0
  58. package/skills/struq/cinematic/templates/06-shot-presets.md +59 -0
  59. package/skills/struq/cinematic/templates/07-implementation-slice.md +66 -0
  60. package/skills/struq/cinematic/templates/08-tuning-register.md +36 -0
  61. package/skills/struq/cinematic/templates/09-acceptance-tests.md +57 -0
  62. package/skills/struq/references/anti-patterns.md +265 -0
  63. package/skills/struq/references/baseline-prompt.md +118 -0
  64. package/skills/struq/references/brand.md +143 -0
  65. package/skills/struq/references/brief-router.md +219 -0
  66. package/skills/struq/references/composition.md +191 -0
  67. package/skills/struq/references/depth-parallax.md +235 -0
  68. package/skills/struq/references/direction.md +188 -0
  69. package/skills/struq/references/feel-layer.md +107 -0
  70. package/skills/struq/references/gsap-api.md +293 -0
  71. package/skills/struq/references/iconography.md +255 -0
  72. package/skills/struq/references/imagery.md +144 -0
  73. package/skills/struq/references/loaders.md +287 -0
  74. package/skills/struq/references/micro-interactions.md +270 -0
  75. package/skills/struq/references/mobile-composition.md +70 -0
  76. package/skills/struq/references/nav-header.md +296 -0
  77. package/skills/struq/references/page-architecture.md +134 -0
  78. package/skills/struq/references/page-entrances.md +198 -0
  79. package/skills/struq/references/performance.md +235 -0
  80. package/skills/struq/references/pinning-scrub.md +341 -0
  81. package/skills/struq/references/pitfalls.md +290 -0
  82. package/skills/struq/references/route-transitions.md +281 -0
  83. package/skills/struq/references/scaling.md +151 -0
  84. package/skills/struq/references/scroll-reveals.md +280 -0
  85. package/skills/struq/references/seo.md +219 -0
  86. package/skills/struq/references/setup.md +397 -0
  87. package/skills/struq/references/showcase.md +87 -0
  88. package/skills/struq/references/stack-targets.md +78 -0
  89. package/skills/struq/references/struq-imagery.md +107 -0
  90. package/skills/struq/references/svg-motion.md +390 -0
  91. package/skills/struq/references/transitions.md +160 -0
  92. package/skills/struq/references/triage.md +71 -0
  93. package/skills/struq/references/typography.md +720 -0
  94. package/skills/struq/references/vibe-presets.md +287 -0
  95. package/skills/struq/references/webgl-r3f.md +274 -0
  96. package/skills/struq/references/webgl-scenes.md +593 -0
@@ -0,0 +1,290 @@
1
+ # Pitfalls: the bugs that recur on every GSAP build
2
+
3
+ Symptom first. Find what you see in the index, read the cause, apply the fix, run the check. Every entry here has shipped in a real build at least once, usually because the recipe looked right in a static screenshot. Most of them are invisible to a typecheck, a green build and a reduced-motion screenshot; they only show with motion on, at a real scroll speed, at both 390 and 1440.
4
+
5
+ Read this file before the first tween (workflow step 6) and again at verify (step 8). The rules behind it live in [setup.md](setup.md) §5; this file is how they fail.
6
+
7
+ | What you see | Go to |
8
+ |---|---|
9
+ | The tail of g, y, p, j, q or a comma is cut off; an italic last letter loses its right edge | §1 |
10
+ | A pinned section jerks, jumps, snaps or lands in the wrong place | §2 |
11
+ | A section is an empty band with motion on, but fine under reduced motion | §3.1 |
12
+ | A Tailwind offset (`translate-y-16`, `scale-95`) is gone on an animated element | §3.2 |
13
+ | A timed entrance (match cut, hold, intro) fires over an empty frame or behind the loader | §3.3 |
14
+ | The hero flashes unsplit, then jumps into lines | §3.4 |
15
+ | Two animations fight over one element (a menu that half-closes, a jittering object) | §3.5 |
16
+ | A footer, CTA, frame or row is blank in a reduced-motion, no-JS or settled capture | §3.6 |
17
+ | Bars, lines or display type run past their frame at 390 or 1920 | §3.7 |
18
+
19
+ ---
20
+
21
+ ## 1. Descender clipping
22
+
23
+ **The 10-second check.** Temporarily set the text of every masked component (split headlines, the hero, the menu links, a clip-line hero, any reveal wrapper) to **`Gyp, jq.`** at its real display size, plus one line that ends in the pairing's italic accent. Screenshot at 390 and 1440, mid-animation and at rest. Every tail and the comma must be whole, and the italic's last stroke must not be sliced vertically. Then put the copy back.
24
+
25
+ ### 1.1 SplitText line masks at leading under 1
26
+
27
+ - **Symptom:** descenders of a split headline are cut flat during and after the rise; at rest the line looks underlined by nothing.
28
+ - **Cause:** `mask: "lines"` wraps each line in an `overflow: clip` box exactly one line-height tall. Display leading is 0.92 to 1.0, so the glyphs are taller than the box.
29
+ - **Fix:** pad the mask and pull the margin back, so the clip box grows and the layout does not move:
30
+
31
+ ```css
32
+ /* globals.css: class = linesClass + "-mask" */
33
+ .split-line-mask { padding-block: 0.1em; margin-block: -0.1em; }
34
+ ```
35
+
36
+ - **Verify:** the 10-second check on every `SplitText` with `mask: "lines"` ([scroll-reveals.md](scroll-reveals.md) §3, [typography.md](typography.md) "Fonts and SplitText").
37
+
38
+ ### 1.2 `overflow: hidden` or `clip` on a parent, or a reveal wrapper sized to the line
39
+
40
+ - **Symptom:** same clipping, but on text that is not split: a menu link, a card title inside an image frame, a text-swap button label.
41
+ - **Cause:** a hand-built mask (`overflow-hidden` on an `li`, a `h-[1em]` wrapper, a fixed-height button) clips at the line box, or a section-level `overflow-hidden` sits flush with the last line.
42
+ - **Fix:** every hand-built mask carries the same padding pulled back with a negative margin: `className="-my-[0.1em] overflow-hidden py-[0.1em]"` ([nav-header.md](nav-header.md) §3). Never size a mask with a fixed height in `em` or `px`; let padding and leading size it. A section that needs `overflow-hidden` for a bleed keeps at least `0.2em` between its edge and the last line of display type.
43
+ - **Verify:** the 10-second check on each hand-built mask, including the menu open state.
44
+
45
+ ### 1.3 Clip-path insets
46
+
47
+ - **Symptom:** a `clip-path` wipe (`clip-line-hero`, a curtain over a headline) slices descenders while open, or leaves them cut when finished.
48
+ - **Cause:** `inset()` clips to the element's border box, and a heading's box at leading 0.92 is shorter than its ink.
49
+ - **Fix:** padding pulled back on the clipped element (`-my-[0.1em] py-[0.1em]`) so its box contains the ink, and `clearProps: "clipPath"` when the wipe ends so nothing clips the glyphs at rest ([page-entrances.md](page-entrances.md), `clip-line-hero`).
50
+ - **Verify:** the 10-second check, mid-wipe and after.
51
+
52
+ ### 1.4 Italic overhang on the right
53
+
54
+ - **Symptom:** the last letter of an italic accent word (Fraunces italic, Cormorant italic, Newsreader italic) loses a sliver of its right side, most visible on f, y and d.
55
+ - **Cause:** italic glyphs lean past their advance width. Any horizontal clip (a line mask, `overflow-clip` on the heading, a clip-path inset at 0) cuts the overhang.
56
+ - **Fix:** inline padding pulled back, on the mask and on any clipped heading that can end in italic:
57
+
58
+ ```css
59
+ .split-line-mask { padding-inline: 0.08em; margin-inline: -0.08em; } /* 0.12em for Cormorant */
60
+ ```
61
+
62
+ - **Verify:** the italic line of the 10-second check, ending the line on the italic word.
63
+
64
+ ### 1.5 Tight display leading inside overflow containers
65
+
66
+ - **Symptom:** an oversized line (`scrub-headline`, a cropped display, ghost type) loses its descenders at the section's bottom edge or its accents at the top.
67
+ - **Cause:** the section is `overflow-clip` for the run-off, and the line's ink extends past its leading box into the clip.
68
+ - **Fix:** vertical padding on the section at least `0.25em` of the display size above and below the line (`py-[14rem]` in [typography.md](typography.md) `scrub-headline`), and the crop only on the axis the design crops (`overflow-x-clip`, not `overflow-clip`, when only the horizontal run-off is wanted).
69
+ - **Verify:** screenshot the line at the scrub's start, middle and end.
70
+
71
+ ### 1.6 The wordmark viewBox
72
+
73
+ - **Symptom:** the logo's descender (a g, a y, a p) is cut, or the logo sits too high next to the nav links.
74
+ - **Cause:** a hand-written or exported `viewBox` from the baseline to the cap height instead of the real ink bounds.
75
+ - **Fix:** generate the outline with the script in [brand.md](brand.md) §3, whose `viewBox` hugs the ink including descenders; never edit the viewBox by hand to "fix" alignment. Align the logo by its box in CSS.
76
+ - **Verify:** the logo at nav size and at its largest use, on both surfaces.
77
+
78
+ ### 1.7 Text inside pinned or transformed containers
79
+
80
+ - **Symptom:** text that was whole before the pin is clipped while pinned, or a scaled card clips its own title.
81
+ - **Cause:** the pin-spacer and a scaled or transformed parent with `overflow-hidden` both create a new clip box; a `scale: 0.92` recede ([depth-parallax.md](depth-parallax.md) §3) shrinks the box while the text keeps its leading.
82
+ - **Fix:** keep the clip on the media frame, not on the text's container; put text outside the scaled element or give the container the padding from 1.2.
83
+ - **Verify:** the 10-second check with the section pinned and mid-recede.
84
+
85
+ ---
86
+
87
+ ## 2. Jarring pins
88
+
89
+ **The check for every pinned section:** with motion on, scroll into it slowly, then again with one fast flick, then scroll back up through it. The pin must engage without a jump, hold without drifting, release without a jump, and the content after it must start where it should. At 390 and on touch there must be no pin at all, and the content must still read. Then reload mid-page and repeat.
90
+
91
+ ### 2.1 A jerk at the moment the pin engages
92
+
93
+ - **Cause:** `anticipatePin` with Lenis. It predicts from a smoothed velocity, overshoots, and pins a frame early, then corrects.
94
+ - **Fix:** delete `anticipatePin` on every smooth-scrolled page. It exists to hide native-scroll latency that Lenis already removes ([pinning-scrub.md](pinning-scrub.md) §2).
95
+ - **Verify:** the fast flick into the pin.
96
+
97
+ ### 2.2 Scrub feels laggy, mushy or jittery
98
+
99
+ - **Cause:** scrub smoothing stacked on Lenis smoothing (`scrub: 2` or more on lerp 0.1 reads as lag), or `scrub: true` on a jumpy native scroll.
100
+ - **Fix:** pair them: Lenis lerp 0.09 to 0.11 with `scrub: 1` for showpieces, `scrub: true` for departure scrubs that must feel attached to the hand ([feel-layer.md](feel-layer.md) §2). Never raise `scrub` to hide a jump; find the jump's cause below.
101
+ - **Verify:** stop scrolling mid-pin: the timeline should settle within about a second and then stand still.
102
+
103
+ ### 2.3 The pin starts or ends in the wrong place, or jumps after something loads
104
+
105
+ - **Cause:** stale measurements. Triggers were measured before webfonts swapped, images got their height, an entrance finished, a route changed or a split re-ran. Horizontal tracks measured once overscroll after any resize.
106
+ - **Fix:** refresh after the layout settles, and make every measured distance a function:
107
+
108
+ ```ts
109
+ // after fonts, and once more after the first-load entrance or route reveal
110
+ document.fonts.ready.then(() => ScrollTrigger.refresh());
111
+
112
+ gsap.to(track, {
113
+ x: () => -(track.scrollWidth - window.innerWidth), // function: recomputed on refresh
114
+ ease: "none",
115
+ scrollTrigger: {
116
+ trigger: section, start: "top top", pin: true, scrub: 1,
117
+ end: () => `+=${track.scrollWidth - window.innerWidth}`,
118
+ invalidateOnRefresh: true, // re-run the functions on refresh
119
+ },
120
+ });
121
+ ```
122
+
123
+ Give every image an aspect ratio (width and height, or `fill` in a sized frame) so late loads cannot move a trigger. The providers already refresh once after mount ([setup.md](setup.md) §2) and the zone template refreshes after its entrance ([page-entrances.md](page-entrances.md)); a component that changes layout after that (an accordion, a late split) calls `ScrollTrigger.refresh()` itself.
124
+ - **Verify:** hard reload, scroll straight to the pin; then navigate to the page from another route and do it again. Both must engage at the same spot.
125
+
126
+ ### 2.4 The pin jumps on mobile when the URL bar shows or hides
127
+
128
+ - **Cause:** `100vh` changes with the browser chrome, and ScrollTrigger refreshes on that resize mid-scroll.
129
+ - **Fix:** size one-screen sections with `svh` (`h-[100svh]`, `min-h-[100svh]`), never `h-screen` or `100vh`, and set `ScrollTrigger.config({ ignoreMobileResize: true })` once in the scroll provider ([setup.md](setup.md) §2). Pins are off on touch anyway (2.12); this protects the sections that remain one screen tall.
130
+ - **Verify:** at 390 in a device emulator, scroll down and back up so the URL bar collapses and returns.
131
+
132
+ ### 2.5 The pinned element shifts sideways, scrolls away, or will not stick
133
+
134
+ - **Cause:** an ancestor with a `transform`, `filter`, `perspective`, `contain: paint` or `will-change: transform` becomes the containing block for `position: fixed`, so the pinned element is fixed to that ancestor instead of the viewport. The usual culprit is a page wrapper left with an inline transform by an entrance, or a smooth-scroll wrapper.
135
+ - **Fix:** clear the ancestor's transform after its entrance (`clearProps: "all"`, [page-entrances.md](page-entrances.md)) and never leave `will-change` on a wrapper. If the ancestor must keep its transform, restructure so the pinned section is not inside it, or set `pinReparent: true` on the trigger (it moves the element to `<body>` while pinned; styles scoped by the old parent then stop applying, so check it).
136
+ - **Verify:** in devtools, walk up from the pinned element: no ancestor may have a computed `transform` other than `none` while pinned.
137
+
138
+ ### 2.6 A pin inside a flex or grid parent collapses or misaligns
139
+
140
+ - **Cause:** the pin-spacer replaces a flex or grid item; while pinned the element is `position: fixed` and loses the width the track gave it, or the spacer takes a different track size.
141
+ - **Fix:** pin a block-level element that is a direct child of the page flow (the section, or a full-width wrapper inside it), and put the flex or grid layout *inside* the pinned element. In [webgl-scenes.md](webgl-scenes.md) §0 the `outer` section holds the spacer and the inner `wrap` is pinned for the same reason.
142
+ - **Verify:** compare a screenshot just before the pin engages with one while pinned: nothing may change width.
143
+
144
+ ### 2.7 The next section slides under or over the pin unexpectedly
145
+
146
+ - **Cause:** `pinSpacing: false`, which removes the spacer so the following content scrolls up under the pinned element.
147
+ - **Fix:** leave `pinSpacing` at its default. Use `false` only when the overlap is the design (a sticky stack, a section that should slide over the pinned one), and then give the following section a surface and a z-index on purpose.
148
+ - **Verify:** the release: the next section must start below the pin's end, or overlap it exactly as designed.
149
+
150
+ ### 2.8 Nested pins
151
+
152
+ - **Cause:** a pinned section inside another pinned section, or a pin inside a horizontal track. Each pin re-parents and measures against a layout the other is changing.
153
+ - **Fix:** one pin per section tree. Inside a pinned horizontal track, use `containerAnimation` for per-panel triggers ([pinning-scrub.md](pinning-scrub.md) §2), never another pin.
154
+ - **Verify:** search the code for `pin:` and confirm no two pinned elements are ancestors of each other.
155
+
156
+ ### 2.9 A pin directly under the hero
157
+
158
+ - **Cause:** the pin starts while the hero is still leaving, so the departure scrub and the pin compete for the same scroll distance, and the visitor meets a locked scroll before anything has earned it.
159
+ - **Fix:** a breather between the hero and any showpiece ([page-architecture.md](page-architecture.md) §3, [showcase.md](showcase.md) Never).
160
+ - **Verify:** the arc in STRUQ.md: no showpiece in the row after the hero, unless it is the hero.
161
+
162
+ ### 2.10 A snap on first engage after a fast scroll
163
+
164
+ - **Cause:** `snap` fires as soon as scrolling stops; after a fast flick into a stepped pin, the stop lands near a phase boundary and the snap yanks the timeline to the next phase. Stale pin positions (2.3) make it worse.
165
+ - **Fix:** snap only stepped content whose phases are at least about 80% of a viewport apart, and give it a beat and no momentum:
166
+
167
+ ```ts
168
+ snap: { snapTo: 1 / (phases - 1), duration: { min: 0.2, max: 0.5 }, delay: 0.15, ease: "power2.inOut", inertia: false },
169
+ ```
170
+
171
+ If it still yanks, remove `snap`: a continuous scrub never needs it.
172
+ - **Verify:** the fast flick into the pin, then stop: the phase that settles is the one in view, with no second move.
173
+
174
+ ### 2.11 The 3D scene freezes halfway through a pinned shot
175
+
176
+ - **Cause:** the offscreen-pause trigger sits on the pinned element, measured against its unpinned box, so it toggles inactive mid-pin.
177
+ - **Fix:** put the pause trigger on the never-pinned outer section with `refreshPriority: -1` ([webgl-scenes.md](webgl-scenes.md) §0).
178
+ - **Verify:** scroll through the whole pinned shot: the scene keeps rendering to the end.
179
+
180
+ ### 2.12 Reduced motion, touch and no-JS: the content must still read
181
+
182
+ - **Cause:** pinned layouts authored as the default markup (phases `absolute` and hidden with inline `opacity: 0`), with motion added on top.
183
+ - **Fix:** the server markup is the readable fallback (a stacked list, a native scroll-snap row). The pin, the stage layout and the hidden phases exist only inside one `gsap.matchMedia()` gate, set with `gsap.set` and a data attribute that matchMedia reverts ([pinning-scrub.md](pinning-scrub.md) §1 and §4). One `GATE` string decides the pin, the layout and any canvas.
184
+ - **Verify:** emulate `prefers-reduced-motion: reduce`, then 390 with touch, then JavaScript disabled: every phase's text is visible in all three.
185
+
186
+ ---
187
+
188
+ ## 3. Animation owners collide
189
+
190
+ ### 3.1 Content stuck at opacity 0: a CSS transition and GSAP on the same property
191
+
192
+ - **Symptom:** a revealed list or grid is an empty band with motion on. Devtools shows one tween at progress 1, `y` back at 0, but inline `opacity: 0; visibility: hidden` forever. Reduced-motion screenshots look perfect, because no tween runs.
193
+ - **Cause:** the element GSAP animates also carries a CSS `transition-*` on a property GSAP writes. The usual source is a sibling-dim hover (`transition-opacity duration-500`) on the same `li` that `[data-reveal]` targets. The browser transitions every inline value GSAP writes, so the `from` tween reads a half-transitioned style and resolves its end value as 0.
194
+ - **Fix:** one owner per property per element. Reveal the wrapper, transition the child:
195
+
196
+ ```tsx
197
+ // The li is the GSAP target: no transition, no opacity class.
198
+ <li data-reveal className="group">
199
+ {/* The hover dim lives one level in. */}
200
+ <a href={href} className="block transition-opacity duration-500
201
+ group-hover/list:opacity-40 group-hover/list:hover:opacity-100">
202
+ {children}
203
+ </a>
204
+ </li>
205
+ ```
206
+
207
+ The same applies to `transition-transform` and `transition-all` on anything GSAP moves ([micro-interactions.md](micro-interactions.md) §3b, [scroll-reveals.md](scroll-reveals.md) §1).
208
+ - **Verify:** search the code: no element with `data-reveal` (or any GSAP target class) has a `transition-` class. Then scroll the page with motion on and check every revealed group ends visible.
209
+
210
+ ### 3.2 A Tailwind offset vanished: `translate: none`
211
+
212
+ - **Symptom:** a staggered column offset (`lg:translate-y-16`), a `scale-95` rest state or a `rotate-2` tilt is gone on an element that animates, while the code still claims it.
213
+ - **Cause:** Tailwind (v4 or later) writes `translate-*`, `scale-*` and `rotate-*` to the individual CSS `translate`, `scale` and `rotate` properties. GSAP sets those properties to `none` inline on every element it transforms and puts its own values in `transform`, so the utility is erased.
214
+ - **Fix:** offset with layout, or on a wrapper, or inside the tween:
215
+
216
+ ```tsx
217
+ // padding or margin, never translate, on a GSAP target
218
+ <li data-reveal className="lg:even:mt-16">...</li>
219
+
220
+ // or the offset on a static wrapper, the reveal on the child
221
+ <div className="lg:translate-y-16"><article data-reveal>...</article></div>
222
+ ```
223
+
224
+ For an element that must rest transformed, express the rest state in GSAP (`gsap.set(el, { yPercent: -50 })`) rather than in a utility.
225
+ - **Verify:** compare the motion-on screenshot with the reduced-motion one: the offsets must match.
226
+
227
+ ### 3.3 A timed entrance fires before the image paints, or behind the loader
228
+
229
+ - **Symptom:** a match-cut hero shows an empty dark frame, then the type; the cut never reads. Or an intro plays behind the loader curtain and is over when it lifts. Static screenshots never show it.
230
+ - **Cause:** the hold starts on mount (`gsap.timeline({ delay: 1 })`) while the photo is still downloading, or the entrance starts before the thing it waits for. A delay set later with `tl.delay(1)` is ignored by `tl.play()`.
231
+ - **Fix:** start the hold from the event it depends on, with a `delayedCall`, and a fallback:
232
+
233
+ ```ts
234
+ const tl = gsap.timeline({ paused: true }); // the cut itself
235
+ let started = false;
236
+ const start = (hold: number) => {
237
+ if (started) return;
238
+ started = true;
239
+ gsap.delayedCall(hold, () => { tl.play(); });
240
+ };
241
+ img.addEventListener("load", () => start(1), { once: true });
242
+ if (img.complete && img.naturalWidth > 0) start(1); // cached
243
+ gsap.delayedCall(2.5, () => start(0)); // never arrived: go straight to the type
244
+ ```
245
+
246
+ With a loader, start it from the loader's exit ([loaders.md](loaders.md)). The full recipe, including the LCP cost of holding: [page-entrances.md](page-entrances.md) `match-cut-hero`.
247
+ - **Verify:** the frame trace in [performance.md](performance.md) §8: the hold frame must be the photo, at 1440 and at 390, on a throttled network.
248
+
249
+ ### 3.4 The hero flashes unsplit, then jumps
250
+
251
+ - **Cause:** the split runs after first paint and nothing hides the h1 in between.
252
+ - **Fix:** the pre-split guard in [typography.md](typography.md) `line-rise-hero` (a CSS rule that hides the hero only while motion is allowed, lifted by a `data-ready` attribute in the same tick as the split), plus the `<noscript>` override.
253
+ - **Verify:** the first frames of the trace: the hero is either hidden or split, never plain then split.
254
+
255
+ ### 3.5 Two animations write the same property
256
+
257
+ - **Symptom:** a menu that half-closes when Escape is pressed during its entrance; a 3D object that jitters between two rotations; a header that stutters between hidden and shown.
258
+ - **Cause:** two timelines, or GSAP and Motion, or scroll and pointer, writing one property on one element.
259
+ - **Fix:** kill the running timeline before starting its opposite (keep it in a ref, [nav-header.md](nav-header.md) §3b); nest groups so scroll and pointer each own one ([webgl-scenes.md](webgl-scenes.md) §4, product-rig); use `quickTo` for anything that reverses mid-gesture; never GSAP and Motion on the same property ([../SKILL.md](../SKILL.md), GSAP vs Motion).
260
+ - **Verify:** trigger the reverse mid-animation (Escape during the menu entrance, a direction flip mid-scroll): the motion reverses cleanly.
261
+
262
+ ### 3.6 A section renders empty: a blank footer, an empty frame, a CTA with nothing in it
263
+
264
+ This is the most common way an ambitious build loses craft points to a plain one: the plain build has no reveals, so it never shows an empty state.
265
+
266
+ - **Symptom:** in a full-page capture, a reduced-motion capture or mid-scroll, a whole region is blank: the footer, a CTA panel, a media frame, a list row. The reveal never ran, or ran and was reverted.
267
+ - **Cause:** the element starts hidden in CSS or from a `gsap.set` outside `matchMedia`, so the reduced-motion and no-JS branches never show it; a ScrollTrigger whose start is never reached (a footer-reveal or the last section of the page, where `top 80%` cannot happen); a reveal keyed to an element that a pin or a footer-reveal moved; a drawn SVG or canvas that waits for an event that never fires.
268
+ - **Fix:** final state is the default and hidden state is set only inside the motion branch (`gsap.from` inside `mm.add("(prefers-reduced-motion: no-preference)")`, [setup.md](setup.md) §4); triggers near the end of the page use `start: "top bottom"` or fire on load when the section is already in view (`ScrollTrigger.isInViewport`); anything drawn or loaded gets a visible fallback until it is ready.
269
+ - **Verify:** three captures, each read region by region for anything blank that should hold content: a full page with `reducedMotion: 'reduce'`, a full page with JavaScript disabled, and the motion-on scroll sequence after each step settles (wait for the longest reveal). An empty region in any of them fails verify.
270
+
271
+ ### 3.7 Something overruns its frame
272
+
273
+ - **Symptom:** data bars run past their axis, a drawn line leaves its container, a wordmark or display line pushes past the viewport at 1920.
274
+ - **Cause:** values scaled to a fixed width while the frame is fluid; an axis that does not cover the data's range; display clamps without a max.
275
+ - **Fix:** scale from the measured container (or `%` of it), make the axis cover the data's max, and keep display type inside its clamp ceiling ([typography.md](typography.md)).
276
+ - **Verify:** look at the edges of every frame at 390 and 1920.
277
+
278
+ ---
279
+
280
+ ## 4. The pitfall pass at verify
281
+
282
+ Run it on the production server ([performance.md](performance.md) §8), motion on, at 390 and 1440:
283
+
284
+ 1. The descender test from §1 on every masked component, including the menu and the italic accent.
285
+ 2. Every pinned section through the check at the top of §2: slow, fast flick, back up, reload mid-page, then reduced motion, touch and no-JS.
286
+ 3. A search for `transition-` and `translate-`, `scale-`, `rotate-` utilities on GSAP targets (§3.1, §3.2).
287
+ 4. The frame trace of the first 3 seconds, read frame by frame (§3.3, §3.4).
288
+ 5. The empty-region check: reduced-motion full page, no-JS full page, and the settled scroll sequence, each with no blank region that should hold content (§3.6); then the frame edges at 390 and 1920 (§3.7).
289
+
290
+ Write the result on the `Motion:` line under VERIFY in STRUQ.md.
@@ -0,0 +1,281 @@
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).