@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,340 +0,0 @@
1
- # Pinning & Scrub-Tied Sequences
2
-
3
- The showpiece category. A pinned, scrubbed section is where a site stops being a page and becomes a sequence, but it's also the easiest place to wreck UX. Budget: **1 to 2 pinned sequences per site.** Three or more and scrolling feels hijacked. For named, ready-made showpiece recipes with ids, see [showcase.md](showcase.md).
4
-
5
- Core concept first, because everything here is one idea: **`scrub` ties timeline progress to scroll position** (scroll = the playhead), and **`pin` freezes an element while the scroll distance plays out**. Triggered tweens fire once and play on their own clock; scrubbed timelines move when the user moves and stop when they stop. That hand-connection is the feel difference between an award site and a slideshow.
6
-
7
- All snippets assume [setup.md](setup.md). `scrub: true` vs `scrub: 1` is a feel decision; see [feel-layer.md](feel-layer.md).
8
-
9
- **A pin that jerks, jumps, snaps or lands in the wrong place** is one of a short list of causes (`anticipatePin` with Lenis, stale measurements, a transformed ancestor, `vh` on mobile, a pin in a flex or grid parent, nested pins). Work through [pitfalls.md](pitfalls.md) §2 before tuning `scrub`.
10
-
11
- ---
12
-
13
- ## 1. Pinned scrub timeline (the workhorse)
14
-
15
- **Term:** Pinned sequence / scroll-told story.
16
- **Mechanic:** A one-screen (`100svh`) section pins; the user scrolls "through" an invisible distance (`end: "+=250%"` → 2.5 viewport-heights of scroll) while a timeline scrubs across phases: text swaps, media transforms, layers move. Scroll distance, not time, is the duration unit.
17
-
18
- **When:** Product walkthroughs, process steps, one cinematic brand moment.
19
- **Overkill when:** the content is just three paragraphs. Pinning 3 viewports of scroll to read 40 words is hostage-taking.
20
- **Cost:** Pinning re-parents into a pin-spacer and recalculates on resize; transforms inside are cheap. The real cost is UX: always check the section is skippable in <3 swipes on mobile, and skip pinning entirely under `prefers-reduced-motion`.
21
-
22
- ```tsx
23
- "use client";
24
-
25
- import { useRef } from "react";
26
- import { gsap, useGSAP } from "@/lib/gsap";
27
-
28
- const PHASES = [
29
- { kicker: "01", title: "Research", body: "We start in the field, not in Figma." },
30
- { kicker: "02", title: "Systems", body: "Design tokens before screens." },
31
- { kicker: "03", title: "Craft", body: "Motion and microcopy get design reviews too." },
32
- ];
33
-
34
- // ONE gate for the layout switch and the pin. Touch, small screens, reduced motion and
35
- // no-JS all get the stacked list the server rendered.
36
- const GATE = "(min-width: 768px) and (prefers-reduced-motion: no-preference)";
37
-
38
- export function ProcessSequence() {
39
- const container = useRef<HTMLElement>(null);
40
-
41
- useGSAP(
42
- () => {
43
- const mm = gsap.matchMedia();
44
-
45
- mm.add(GATE, () => {
46
- const section = container.current!;
47
- const phases = gsap.utils.toArray<HTMLElement>("[data-phase]");
48
-
49
- // Switch the stacked list into a stage, then hide the later phases with GSAP.
50
- // matchMedia reverts the gsap.set and runs the returned cleanup when the gate
51
- // stops matching, so the list comes back by itself.
52
- section.dataset.mode = "rig";
53
- gsap.set(phases.slice(1), { yPercent: 30, autoAlpha: 0 });
54
-
55
- const tl = gsap.timeline({
56
- scrollTrigger: {
57
- trigger: section,
58
- start: "top top",
59
- end: "+=250%", // 2.5 viewports of scroll-room: ~0.8 per phase
60
- pin: true,
61
- scrub: 1, // 1s catch-up smoothing, see feel-layer.md
62
- // snap: phases feel like steps? snap to them. Continuous story? omit.
63
- snap: { snapTo: 1 / (phases.length - 1), duration: 0.4, ease: "power2.inOut" },
64
- },
65
- });
66
-
67
- phases.forEach((phase, i) => {
68
- if (i === 0) return; // phase 0 is the resting state
69
- tl.to(phases[i - 1], { yPercent: -30, autoAlpha: 0, ease: "power2.in", duration: 0.4 })
70
- .to(phase,
71
- { yPercent: 0, autoAlpha: 1, ease: "power2.out", duration: 0.4 },
72
- "<0.15" // overlap: new phase starts before old one is fully gone
73
- );
74
- });
75
-
76
- // A continuous element that evolves across ALL phases sells the sequence
77
- tl.fromTo("[data-dial]", { rotate: 0 }, { rotate: 270, ease: "none", duration: tl.duration() }, 0);
78
-
79
- return () => { delete section.dataset.mode; };
80
- });
81
- },
82
- { scope: container }
83
- );
84
-
85
- // Server markup = a readable stacked list. data-mode="rig" (motion branch only) turns it
86
- // into the pinned stage: the phases stack absolutely and the section takes one screen.
87
- return (
88
- <section
89
- ref={container}
90
- className="group/seq relative bg-surface-1 py-[8rem]
91
- data-[mode=rig]:h-[100svh] data-[mode=rig]:overflow-hidden data-[mode=rig]:py-0"
92
- >
93
- <div className="grid grid-cols-12 items-center gap-y-16 px-6 group-data-[mode=rig]/seq:h-full">
94
- <ol className="relative col-span-12 space-y-16 md:col-span-5 md:col-start-2
95
- group-data-[mode=rig]/seq:h-full group-data-[mode=rig]/seq:space-y-0">
96
- {PHASES.map((p) => (
97
- <li key={p.kicker} data-phase
98
- className="flex flex-col justify-center group-data-[mode=rig]/seq:absolute group-data-[mode=rig]/seq:inset-0">
99
- <h3 className="font-display text-h2">{p.title}</h3>
100
- <p className="text-body mt-6 max-w-[36ch] text-ink-muted">{p.body}</p>
101
- </li>
102
- ))}
103
- </ol>
104
- <div data-dial aria-hidden
105
- className="col-span-4 col-start-8 aspect-square rounded-full border border-line max-md:hidden" />
106
- </div>
107
- </section>
108
- );
109
- }
110
- ```
111
-
112
- Craft notes:
113
- - **`ease: "none"` for anything that should track scroll linearly** (the dial); eases *inside* a scrubbed timeline still apply within their segment, so entrance/exit phases keep `power2` and each swap has shape.
114
- - **One continuous element** (`data-dial`) spanning the whole timeline is what makes it feel like a sequence rather than a slide deck.
115
- - **`"<0.15"` overlap** between exit and entrance: gapless storytelling.
116
- - The `01/02/03` kickers here are fine. Ordinal numbering *inside one stepped component* is real content, distinct from the page-wide numbered-everything tell ([anti-patterns.md](anti-patterns.md) §16). Don't let this pattern leak numerals onto every other section.
117
- - Snap is for stepped content; omit for continuous narratives. Never snap with distances under ~80% per phase (fights the user's thumb).
118
- - **Server markup is the final, readable state.** Nothing is hidden with an inline `style={{ opacity: 0 }}` or a CSS class: the later phases are hidden by `gsap.set` inside the motion branch, so reduced motion, touch, a failed bundle and no-JS all read the three phases as a list. The layout switch is a data attribute set in the same branch, so the list and the pin can never disagree.
119
- - **One `GATE` string** for every decision about this section (the layout switch, the pin, and a canvas if one joins it, [webgl-r3f.md](webgl-r3f.md) §1). Two queries drift: a fine-pointer tablet ends up with a pin and no stage, or a stage and no pin.
120
-
121
- **Cheap tell:** content swaps via `opacity` crossfade fired at thresholds (`toggleActions`), no pin: a slideshow that happens near scroll. Or: pinned for 6 viewports with one sentence. **Instead:** Pinned, scrubbed, phases overlapping, one element evolving continuously, ≤2.5 viewports.
122
-
123
- ---
124
-
125
- ## 2. Horizontal scroll section
126
-
127
- **Term:** Horizontal scroll-jack (the respectable kind).
128
- **Mechanic:** A pinned viewport-height section translates a wide flex row on `x` as the user scrolls vertically. Vertical input, horizontal travel. The pin distance equals the hidden width, so scroll speed maps 1:1 to lateral movement. This 1:1 mapping is why it feels natural where `scroll-snap` carousels feel like a different widget.
129
-
130
- **When:** Galleries, project indexes, timeline content that is *genuinely sequential*.
131
- **Overkill when:** the content would work as a grid. Horizontal scroll must be earned by content with an axis. Never put critical info (pricing, CTA) inside one.
132
- **Cost:** Transforms only, so cheap. But images off-screen still load; lazy-load panels 3+. On touch, 1:1 vertical→horizontal is fine; *nested* horizontal sections are not: one per page.
133
-
134
- ```tsx
135
- "use client";
136
-
137
- import { useRef } from "react";
138
- import { gsap, useGSAP } from "@/lib/gsap";
139
-
140
- export function WorkGallery({ projects }: { projects: { title: string; meta: string; img: string }[] }) {
141
- const container = useRef<HTMLElement>(null);
142
- const track = useRef<HTMLDivElement>(null);
143
-
144
- useGSAP(
145
- () => {
146
- const getDistance = () => track.current!.scrollWidth - window.innerWidth;
147
-
148
- const scrollTween = gsap.to(track.current, {
149
- x: () => -getDistance(), // function-based: recalcs on refresh/resize
150
- ease: "none", // scroll IS the ease; easing here feels broken
151
- scrollTrigger: {
152
- trigger: container.current,
153
- start: "top top",
154
- end: () => `+=${getDistance()}`, // pin distance = hidden width → 1:1 feel
155
- pin: true,
156
- scrub: 1,
157
- invalidateOnRefresh: true,
158
- // Do NOT add anticipatePin here when Lenis (or any smooth scroll) is on.
159
- // See craft note below: it jumps the frame at the pin handoff.
160
- },
161
- });
162
-
163
- // Per-panel parallax inside the horizontal world: containerAnimation
164
- gsap.utils.toArray<HTMLElement>("[data-panel] img").forEach((img) => {
165
- gsap.fromTo(img, { xPercent: -8 }, {
166
- xPercent: 8,
167
- ease: "none",
168
- scrollTrigger: {
169
- trigger: img.parentElement,
170
- containerAnimation: scrollTween, // triggers measured inside the moving track
171
- start: "left right",
172
- end: "right left",
173
- scrub: true,
174
- },
175
- });
176
- });
177
- },
178
- { scope: container }
179
- );
180
-
181
- return (
182
- <section ref={container} className="overflow-hidden bg-surface-0">
183
- <div ref={track} className="flex h-[100svh] w-max items-center gap-[6vw] px-[8vw]">
184
- <div className="w-[28vw] shrink-0">
185
- <h2 className="font-display text-h2">Selected<br />work</h2>
186
- </div>
187
- {projects.map((p, i) => (
188
- <article key={p.title} data-panel className="w-[42vw] shrink-0">
189
- <div className="overflow-hidden">
190
- <img src={p.img} alt="" loading={i > 1 ? "lazy" : undefined}
191
- className="aspect-[4/3] w-full scale-110 object-cover" />
192
- </div>
193
- <p className="text-small mt-4 flex items-baseline justify-between text-ink-muted">
194
- <span>{p.title}</span><span className="tabular-nums">{p.meta}</span>
195
- </p>
196
- </article>
197
- ))}
198
- </div>
199
- </section>
200
- );
201
- }
202
- ```
203
-
204
- Craft notes:
205
- - **Function-based `x` and `end` + `invalidateOnRefresh`**: without these, resize breaks the mapping and the section over/under-scrolls.
206
- - **`containerAnimation`** is the GSAP-only feature that makes triggers work *inside* the moving track (per-panel parallax, reveals as panels enter). This is why Motion isn't the tool here.
207
- - Varying panel widths (`28vw` intro, `42vw` projects) beats uniform cards, the same asymmetry rule as vertical layout.
208
- - **Never `anticipatePin` with smooth scroll.** `anticipatePin` pins *early* by predicting scroll velocity, but Lenis feeds it a **smoothed** velocity, so the prediction overshoots and the pin engages a frame too early, then corrects. The result is a visible jump at the exact vertical→horizontal handoff that reads as "jagged, not eased in," worst on a fast scroll-in. It exists to hide native-scroll pin latency; smooth scroll already removes that latency, so it only adds jitter. The fix for a jagged handoff is almost always **remove `anticipatePin`** (not tuning `scrub`). If the *first* scroll-in specifically snaps, the cause is stale pin positions: add the delayed initial `lenis.resize()` + `ScrollTrigger.refresh()` from [setup.md](setup.md) §2, not `anticipatePin`.
209
- - Mobile fallback: **never pin on touch**. The exact matchMedia pattern is §4 below. Pinned horizontal on small phones is a common award-site sin.
210
-
211
- **Cheap tell:** `overflow-x-auto` with `scroll-snap` and visible scrollbar presented as the showpiece; or a horizontal section where panels are identical-width cards 1 to 12. **Instead:** Pinned 1:1 mapping, mixed panel widths, parallax inside panels, native fallback on mobile.
212
-
213
- ---
214
-
215
- ## 3. Scrub-tied hero (no pin)
216
-
217
- **Term:** Departure scrub.
218
- **Mechanic:** The hero reacts to the first hundreds of scroll pixels: headline drifts up faster than scroll, media scales and dims, the lead line fades. No pinning; the page just *leaves* cinematically instead of sliding away as a rigid block.
219
-
220
- **When:** Every hero. This is the cheapest scrub win on the page. Do it even when you do nothing else from this file.
221
- **Cost:** Trivial.
222
-
223
- ```tsx
224
- useGSAP(
225
- () => {
226
- const tl = gsap.timeline({
227
- scrollTrigger: {
228
- trigger: container.current,
229
- start: "top top",
230
- end: "bottom top", // while the hero leaves the viewport
231
- scrub: true, // direct tie; departure should feel attached to the hand
232
- },
233
- });
234
-
235
- tl.to("[data-hero-title]", { yPercent: -40, ease: "none" }, 0) // rises faster than page
236
- .to("[data-hero-media]", { scale: 1.12, ease: "none" }, 0) // grows as it leaves
237
- .to("[data-hero-veil]", { autoAlpha: 0.55, ease: "none" }, 0) // dims under a veil div
238
- .to("[data-hero-meta]", { autoAlpha: 0, yPercent: -100, ease: "none" }, 0);
239
- },
240
- { scope: container }
241
- );
242
- ```
243
-
244
- Different elements at different rates = instant depth (full multi-layer treatment in [depth-parallax.md](depth-parallax.md)). Dim with a `data-hero-veil` overlay div, not `filter: brightness()`: opacity composites cheaply, scrubbed filters don't.
245
-
246
- **Cheap tell:** hero scrolls away as one static raster. **Instead:** Title, media, veil, and meta leave at four different speeds, tied to the hand.
247
-
248
- ---
249
-
250
- ## 4. The touch fallback: pin on desktop, swipe on touch (the exact pattern)
251
-
252
- Pinning fights native touch scroll; every pinned/horizontal section MUST branch.
253
- The contract: **one markup tree**, authored so the touch experience is pure
254
- CSS (scroll-snap), with the pin added *only* inside a desktop
255
- `gsap.matchMedia()` branch. matchMedia reverts the ScrollTriggers automatically
256
- when the viewport crosses the breakpoint: no stale pins, nothing to clean up
257
- by hand.
258
-
259
- ```tsx
260
- "use client";
261
-
262
- import { useRef } from "react";
263
- import { gsap, useGSAP } from "@/lib/gsap";
264
-
265
- export function Showcase({ items }: { items: { id: string; title: string }[] }) {
266
- const container = useRef<HTMLElement>(null);
267
- const track = useRef<HTMLDivElement>(null);
268
-
269
- useGSAP(
270
- () => {
271
- const mm = gsap.matchMedia();
272
-
273
- // Desktop + motion-OK ONLY: the pinned 1:1 horizontal scrub (§2).
274
- mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
275
- const getDistance = () => track.current!.scrollWidth - window.innerWidth;
276
- gsap.to(track.current, {
277
- x: () => -getDistance(),
278
- ease: "none",
279
- scrollTrigger: {
280
- trigger: container.current,
281
- start: "top top",
282
- end: () => `+=${getDistance()}`,
283
- pin: true,
284
- scrub: 1,
285
- invalidateOnRefresh: true,
286
- },
287
- });
288
- });
289
-
290
- // Touch/small/reduced: NO branch needed. The markup below is already a
291
- // native scroll-snap carousel via the max-md: utilities. No JS, nothing
292
- // fighting the user's thumb.
293
- },
294
- { scope: container }
295
- );
296
-
297
- return (
298
- <section ref={container} className="overflow-hidden">
299
- {/* One tree serves both modes: the max-md: snap utilities are inert on
300
- desktop (where the pin translates the track), and the md: width is
301
- inert on mobile (where the track scrolls natively). */}
302
- <div
303
- ref={track}
304
- className="flex items-center gap-[5vw] px-[8vw]
305
- max-md:snap-x max-md:snap-mandatory max-md:overflow-x-auto
306
- md:h-[100svh] md:w-max"
307
- >
308
- {items.map((item) => (
309
- <article key={item.id} className="w-[82vw] shrink-0 snap-center md:w-[42vw]">
310
- {/* … */}
311
- </article>
312
- ))}
313
- </div>
314
- </section>
315
- );
316
- }
317
- ```
318
-
319
- What makes this the *correct* shape (each is a common failure when improvised):
320
- - **The touch path is CSS-only.** If the fallback also needs JS, it can race the
321
- matchMedia revert. Scroll-snap markup that's simply *inert* under the desktop
322
- pin can't break.
323
- - **Both conditions in one query** (`min-width` AND `no-preference`), so
324
- reduced-motion desktop users get the native carousel too, not the pin.
325
- - **Function-based `x`/`end` + `invalidateOnRefresh`** so crossing the
326
- breakpoint (rotate, window resize) re-measures instead of over/under-scrolling.
327
- - For *vertically stacked* pinned sequences (§1-style phase swaps), the touch
328
- fallback is a plain stacked list: keep the phases `relative` in normal flow on
329
- mobile and only position them `absolute` inside the desktop branch. Same
330
- principle: the mobile layout must work with zero JS.
331
-
332
- ---
333
-
334
- ## When *not* to pin: the self-check
335
-
336
- 1. Would the content lose meaning as a normal section? If no → don't pin.
337
- 2. Is the pin distance ≤ 2.5 viewports? If no → cut phases.
338
- 3. Is there already a pinned section on this page? If yes → strong justification or cut.
339
- 4. Mobile + reduced-motion fallbacks written (§4)? Not optional.
340
- 5. Does anything *continuous* evolve across the pin? If it's only discrete swaps → maybe it's just a stepped section with reveals, not a pin.
@@ -1,290 +0,0 @@
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.