@struq/skill 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/imagery.md +0 -144
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -341
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -280
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
# GSAP API: the 15% this skill actually uses
|
|
2
|
-
|
|
3
|
-
This is not a GSAP manual. It's the exact API surface the technique files rely on, so the skill needs no external reference. Everything imports from the `@/lib/gsap` registry ([setup.md](setup.md) §1); every animating component follows the `useGSAP` rules ([setup.md](setup.md) §3).
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## 1. Tweens & timelines
|
|
8
|
-
|
|
9
|
-
```ts
|
|
10
|
-
gsap.to(target, { x: 100, duration: 0.8, ease: "expo.out" }); // current → values
|
|
11
|
-
gsap.from(target, { y: 48, autoAlpha: 0 }); // values → current (SSR HTML is the final state; prefer this)
|
|
12
|
-
gsap.fromTo(target, { yPercent: -8 }, { yPercent: 8, ease: "none" }); // explicit both ends
|
|
13
|
-
gsap.set(target, { autoAlpha: 0 }); // instant, no tween
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
- `target` = element, ref `.current`, selector string (scoped inside `useGSAP`), or array.
|
|
17
|
-
- **`autoAlpha`** = opacity + `visibility`. Use it instead of `opacity` for entrances so hidden elements can't intercept clicks.
|
|
18
|
-
- **Stagger object:** `stagger: { each: 0.08, from: "start" | "center" | "edges" | "end" | "random", grid: [rows, cols] }`. Plain `stagger: 0.08` = shorthand for `each`.
|
|
19
|
-
- **`gsap.defaults({ ease, duration })`**: set once in the registry so every tween starts agency-grade.
|
|
20
|
-
|
|
21
|
-
**Timelines** sequence tweens; the *position parameter* (third argument) is the craft:
|
|
22
|
-
|
|
23
|
-
```ts
|
|
24
|
-
const tl = gsap.timeline({ defaults: { ease: "expo.out", duration: 1 } });
|
|
25
|
-
tl.from(a, {...}) // starts at 0
|
|
26
|
-
.from(b, {...}, "-=0.4") // overlaps: starts 0.4s before previous ends
|
|
27
|
-
.from(c, {...}, "<") // starts WITH the previous tween
|
|
28
|
-
.from(d, {...}, "<0.15") // 0.15s after the previous tween's START
|
|
29
|
-
.from(e, {...}, "+=0.2"); // gap: 0.2s after everything ends
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Overlapping (`-=` / `<`) reads as one composed gesture; strict sequencing reads as a PowerPoint build ([scroll-reveals.md](scroll-reveals.md)).
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
## 2. useGSAP (the React hook)
|
|
37
|
-
|
|
38
|
-
```tsx
|
|
39
|
-
const { contextSafe } = useGSAP(() => {
|
|
40
|
-
gsap.from(".card", { y: 40, autoAlpha: 0, stagger: 0.08 }); // ".card" scoped to container
|
|
41
|
-
}, { scope: container });
|
|
42
|
-
|
|
43
|
-
const onHover = contextSafe(() => gsap.to(".dot", { scale: 1.4 })); // event-handler tweens MUST be wrapped
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Everything created inside is auto-killed on unmount (no leaked ScrollTriggers after route changes). Full rules (`"use client"`, scope ref, animate-`from`, no `window` at module scope): [setup.md](setup.md) §3.
|
|
47
|
-
|
|
48
|
-
---
|
|
49
|
-
|
|
50
|
-
## 3. ScrollTrigger
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
gsap.to(target, {
|
|
54
|
-
...,
|
|
55
|
-
scrollTrigger: {
|
|
56
|
-
trigger: container.current,
|
|
57
|
-
start: "top 78%", // "[trigger-edge] [viewport-edge]"
|
|
58
|
-
end: "bottom top", // or "+=250%" (2.5 viewports of scroll-room), or a function
|
|
59
|
-
scrub: 1, // true = locked 1:1; number = seconds of catch-up (feel-layer.md §2)
|
|
60
|
-
pin: true, // freeze the trigger while the scroll distance plays out
|
|
61
|
-
once: true, // entrances fire once; scrub effects may repeat
|
|
62
|
-
snap: { snapTo: 1 / (n - 1), duration: 0.4, ease: "power2.inOut" }, // stepped content only
|
|
63
|
-
invalidateOnRefresh: true, // re-run function-based values on refresh/resize
|
|
64
|
-
onUpdate: (self) => { progress.current = self.progress; }, // 0 to 1; mutate refs, never setState
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
// Standalone (no tween): ScrollTrigger.create({ trigger, start, end, scrub, onUpdate })
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
**The pinning rules (each one is a real failure when skipped):**
|
|
71
|
-
|
|
72
|
-
1. **Pinning re-parents** the trigger into a generated pin-spacer, so measure and position accordingly; `pinSpacing: false` only when a following section should slide over the pinned one.
|
|
73
|
-
2. **Function-based distances** for anything measured: `x: () => -getDistance()` and `end: () => "+=" + getDistance()`, plus `invalidateOnRefresh: true`. Otherwise resize breaks the mapping.
|
|
74
|
-
3. **`ScrollTrigger.refresh()`** after layout settles (fonts/images loaded, entrance finished). Triggers measured against a mid-animation layout are wrong ([page-entrances.md](page-entrances.md)).
|
|
75
|
-
4. **Never `anticipatePin` with smooth scroll.** Lenis feeds it a smoothed velocity, the prediction overshoots, and the pin engages a frame early then corrects: a visible jerk at the handoff. Full diagnosis: [pinning-scrub.md](pinning-scrub.md) §2.
|
|
76
|
-
5. **One trigger per concern**, not per element: stagger ten cards from one trigger on the parent.
|
|
77
|
-
6. **Never pin on touch.** The matchMedia + scroll-snap fallback in [pinning-scrub.md](pinning-scrub.md) §4 is the law.
|
|
78
|
-
|
|
79
|
-
Pin symptoms (a jerk, a jump, a snap on first engage, a pin that lands in the wrong place), each with cause, fix and check: [pitfalls.md](pitfalls.md) §2.
|
|
80
|
-
|
|
81
|
-
---
|
|
82
|
-
|
|
83
|
-
## 4. gsap.matchMedia: responsive + reduced-motion, declaratively
|
|
84
|
-
|
|
85
|
-
```ts
|
|
86
|
-
const mm = gsap.matchMedia();
|
|
87
|
-
mm.add({
|
|
88
|
-
motionOK: "(prefers-reduced-motion: no-preference)",
|
|
89
|
-
isDesktop: "(min-width: 768px)",
|
|
90
|
-
}, (ctx) => {
|
|
91
|
-
const { motionOK, isDesktop } = ctx.conditions!;
|
|
92
|
-
if (!motionOK) return; // SSR markup is already the final state
|
|
93
|
-
if (isDesktop) { /* pin/scrub here */ }
|
|
94
|
-
});
|
|
95
|
-
// Or one combined query: mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {...});
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Everything created inside a branch **auto-reverts** when the condition stops matching (rotate, resize, OS setting change). No stale pins, nothing to clean up by hand. Never `if (window.innerWidth < 768)` at setup time: it doesn't revert.
|
|
99
|
-
|
|
100
|
-
---
|
|
101
|
-
|
|
102
|
-
## 5. quickTo: per-frame pointer effects
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
const xTo = gsap.quickTo(el, "x", { duration: 0.4, ease: "power3.out" });
|
|
106
|
-
el.addEventListener("mousemove", (e) => xTo(computeX(e))); // call the function, per frame, cheaply
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
Pre-compiles one reusable tween per property. For anything updating every frame (magnetic buttons, cursor followers, hide-on-scroll headers): never `setState` per mousemove (React render per frame) and never `gsap.to` per event (allocates a tween per pixel). This is also why those effects stay on GSAP's ticker, not a Motion spring loop: the one-clock rule (see the decision table in [SKILL.md](../SKILL.md)).
|
|
110
|
-
|
|
111
|
-
---
|
|
112
|
-
|
|
113
|
-
## 6. SplitText (GSAP 3.13 or later)
|
|
114
|
-
|
|
115
|
-
```ts
|
|
116
|
-
SplitText.create(el, {
|
|
117
|
-
type: "lines", // "lines" | "words" | "chars" (chars: short display words only)
|
|
118
|
-
mask: "lines", // wraps each line in an overflow-clipped element: the whole effect
|
|
119
|
-
autoSplit: true, // re-splits on font load / resize
|
|
120
|
-
onSplit: (self) => // animation MUST live here so re-splits re-run it
|
|
121
|
-
gsap.from(self.lines, { yPercent: 110, stagger: 0.09, ease: "expo.out", duration: 1.1 }),
|
|
122
|
-
});
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
Do **not** use the old `new SplitText()` + manual resize listeners. Accessibility is automatic (`aria-label` set on the parent). Craft details (leading, descenders, the scrub variant): [scroll-reveals.md](scroll-reveals.md) §3. SplitText typography choreography in depth: [typography.md](typography.md).
|
|
126
|
-
|
|
127
|
-
---
|
|
128
|
-
|
|
129
|
-
## 7. Flip: expand-to-detail / shared-element morphs
|
|
130
|
-
|
|
131
|
-
```ts
|
|
132
|
-
const state = Flip.getState(".card, .card img"); // 1. measure
|
|
133
|
-
container.classList.toggle("expanded"); // 2. change the DOM/layout however you like
|
|
134
|
-
Flip.from(state, { // 3. animate FROM the old positions
|
|
135
|
-
duration: 0.6, ease: "power4.inOut",
|
|
136
|
-
absolute: true, // see §7.1: REQUIRES a positioned + height-pinned container
|
|
137
|
-
nested: true, // when parents AND children both move
|
|
138
|
-
});
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Reach for Flip when the morph must coordinate with scroll or a timeline (card→modal, grid↔list, thumbnail→hero); a simple React-state list→detail swap is Motion `layoutId` territory ([transitions.md](transitions.md) §3, and the decision table in [SKILL.md](../SKILL.md)).
|
|
142
|
-
|
|
143
|
-
### 7.1 The four Flip failures: read before writing any Flip
|
|
144
|
-
|
|
145
|
-
Flip has a reputation for "layout shift that nothing fixes" and for fighting the
|
|
146
|
-
page. It is almost always one of these four, and they compound, so fixing one and
|
|
147
|
-
still seeing a jump does **not** mean you were wrong; it means there were two.
|
|
148
|
-
Work the list in order.
|
|
149
|
-
|
|
150
|
-
**1. `absolute: true` collapses the container.**
|
|
151
|
-
`absolute: true` sets every target to `position: absolute` for the tween's
|
|
152
|
-
duration. If the container derives its height from those in-flow children (any
|
|
153
|
-
CSS grid with `grid-auto-rows`, any auto-height flex/block wrapper), it now has
|
|
154
|
-
zero in-flow children and its height collapses to `0`, then snaps back. Everything
|
|
155
|
-
below lurches up and slams down.
|
|
156
|
-
|
|
157
|
-
> The older note that `absolute: true` "prevents reflow fighting" is only half
|
|
158
|
-
> true: it prevents *sibling* reflow, and causes *container* collapse. It is not
|
|
159
|
-
> a free win. It is a trade that you must pay for with the two fixes below.
|
|
160
|
-
|
|
161
|
-
**2. No positioned ancestor → items escape the container.**
|
|
162
|
-
Absolutely-positioned targets resolve offsets against the nearest *positioned*
|
|
163
|
-
ancestor. If the container is not `position: relative`, tiles fly out of its
|
|
164
|
-
coordinate space and render **on top of the next section**. This looks like a
|
|
165
|
-
z-index bug and is not one. Symptom: content overlapping the section below, not
|
|
166
|
-
just shifting.
|
|
167
|
-
|
|
168
|
-
**3. The height release races the stagger.**
|
|
169
|
-
With `stagger`, the last item lands at `duration + stagger.amount`, and stays
|
|
170
|
-
`position: absolute` until then. Pin the height for only `duration` and it
|
|
171
|
-
releases while items are still out of flow → the same collapse, but *only at the
|
|
172
|
-
very end* of the animation. Symptom: the tween looks perfect, then jumps on the
|
|
173
|
-
final beat.
|
|
174
|
-
|
|
175
|
-
**4. A CSS `transition` on a property Flip tweens.**
|
|
176
|
-
`Flip.getState(el, { props: 'filter' })` makes GSAP write `filter` inline every
|
|
177
|
-
frame. A CSS `transition: filter …` on the same element re-animates every one of
|
|
178
|
-
those writes. Two systems own one property; the result is mushy, laggy, or stuck.
|
|
179
|
-
Same for `transition: all`. **Never** transition a property you pass to `props`,
|
|
180
|
-
and never use `transition: all` on a Flip target.
|
|
181
|
-
|
|
182
|
-
### 7.2 The rock-solid pattern (React)
|
|
183
|
-
|
|
184
|
-
Covers all four. Measure the old height *before* the re-render, pin, tween the
|
|
185
|
-
container in lockstep with the staggered items, release only after the last one
|
|
186
|
-
lands.
|
|
187
|
-
|
|
188
|
-
```tsx
|
|
189
|
-
const DURATION = 0.8;
|
|
190
|
-
const STAGGER = 0.18; // shared, so the height tween can never drift out of sync
|
|
191
|
-
|
|
192
|
-
const gridRef = useRef<HTMLDivElement>(null);
|
|
193
|
-
const flipState = useRef<Flip.FlipState | null>(null);
|
|
194
|
-
const prevHeight = useRef(0);
|
|
195
|
-
|
|
196
|
-
// Capture in the EVENT HANDLER, before React re-renders.
|
|
197
|
-
const handleChange = (next: Id) => {
|
|
198
|
-
const grid = gridRef.current;
|
|
199
|
-
if (grid && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
200
|
-
prevHeight.current = grid.getBoundingClientRect().height; // old height
|
|
201
|
-
flipState.current = Flip.getState(grid.querySelectorAll('[data-flip]'));
|
|
202
|
-
}
|
|
203
|
-
setActive(next);
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
useLayoutEffect(() => {
|
|
207
|
-
const state = flipState.current;
|
|
208
|
-
flipState.current = null;
|
|
209
|
-
if (!state) return;
|
|
210
|
-
const grid = gridRef.current;
|
|
211
|
-
if (!grid) return;
|
|
212
|
-
|
|
213
|
-
const from = prevHeight.current;
|
|
214
|
-
const to = grid.getBoundingClientRect().height; // measure BEFORE pinning
|
|
215
|
-
gsap.set(grid, { height: from });
|
|
216
|
-
|
|
217
|
-
const flip = Flip.from(state, {
|
|
218
|
-
duration: DURATION, ease: 'power3.inOut', absolute: true,
|
|
219
|
-
stagger: { amount: STAGGER, from: 'start' },
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// Outlast the stagger (failure #3).
|
|
223
|
-
const height = gsap.to(grid, {
|
|
224
|
-
height: to,
|
|
225
|
-
duration: DURATION + STAGGER,
|
|
226
|
-
ease: 'power3.inOut',
|
|
227
|
-
onComplete: () => gsap.set(grid, { clearProps: 'height' }),
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
return () => { flip.kill(); height.kill(); gsap.set(grid, { clearProps: 'height' }); };
|
|
231
|
-
}, [active]);
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
```css
|
|
235
|
-
.grid {
|
|
236
|
-
position: relative; /* REQUIRED: failure #2 */
|
|
237
|
-
display: grid;
|
|
238
|
-
grid-auto-rows: …;
|
|
239
|
-
}
|
|
240
|
-
.grid > [data-flip] {
|
|
241
|
-
/* Do NOT transition any property passed to Flip's `props` (failure #4).
|
|
242
|
-
Opacity is safe here only because Flip never touches it. */
|
|
243
|
-
transition: opacity 0.6s var(--ease-out);
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
**Ordering rule:** all measurement happens before any `gsap.set` that mutates
|
|
248
|
-
layout. Never `set(height:'auto')` → measure → tween *after* `Flip.from` has
|
|
249
|
-
captured target geometry; that mutates layout mid-flight and silently corrupts
|
|
250
|
-
the capture.
|
|
251
|
-
|
|
252
|
-
### 7.3 `scale: true` is a trade, not a default
|
|
253
|
-
|
|
254
|
-
`scale: true` fakes resizing with `transform: scale`, which is cheaper, but it
|
|
255
|
-
**distorts children**: `object-fit: cover` images stretch, text warps, borders and
|
|
256
|
-
radii scale non-uniformly.
|
|
257
|
-
|
|
258
|
-
- Items that genuinely change dimensions (bento tile `span 1` → `span 2`, card →
|
|
259
|
-
modal): **omit `scale`.** Let Flip tween width/height. Correctness beats cheap.
|
|
260
|
-
- Uniform items that only *move* (reordering a same-size grid), or brief/small
|
|
261
|
-
morphs where distortion is imperceptible: `scale: true` is a fine win.
|
|
262
|
-
|
|
263
|
-
### 7.4 Verifying Flip
|
|
264
|
-
|
|
265
|
-
**Typecheck and lint cannot see any of these failures.** A fully broken Flip
|
|
266
|
-
passes `tsc --noEmit` and ESLint clean, which is exactly how a wrong fix survives
|
|
267
|
-
review and reaches the user. Flip is only verified by watching it: click the
|
|
268
|
-
control and confirm (a) nothing below the container moves, (b) no item overlaps
|
|
269
|
-
the next section, (c) the final beat lands without a jump. Check the last one
|
|
270
|
-
specifically; failure #3 hides until the very end.
|
|
271
|
-
|
|
272
|
-
---
|
|
273
|
-
|
|
274
|
-
## 8. The `ease: "none"` rule
|
|
275
|
-
|
|
276
|
-
Anything **scrubbed** tracks scroll linearly. Scroll itself is the easing; an ease on top feels broken:
|
|
277
|
-
|
|
278
|
-
- Scrub-tied values (parallax drift, marquee, the horizontal-scroll track, continuous dial): `ease: "none"`.
|
|
279
|
-
- **Every tween whose trigger uses `containerAnimation`** (triggers inside a moving horizontal track): `ease: "none"` on the track tween, or the trigger math breaks.
|
|
280
|
-
- The exception: *segment* eases (`power2.out`/`power2.in`) **inside** a scrubbed timeline for phase swaps. Each swap keeps shape while the timeline as a whole stays scroll-locked ([pinning-scrub.md](pinning-scrub.md) §1).
|
|
281
|
-
|
|
282
|
-
---
|
|
283
|
-
|
|
284
|
-
## 9. DrawSVG & MorphSVG (pointer)
|
|
285
|
-
|
|
286
|
-
Both plugins are free (no token) and register in the `@/lib/gsap` module alongside `Flip` ([setup.md](setup.md) §1). The two one-liners the other files rely on:
|
|
287
|
-
|
|
288
|
-
```ts
|
|
289
|
-
gsap.from(path, { drawSVG: 0 }); // self-drawing stroke
|
|
290
|
-
gsap.to("#shapeA", { morphSVG: "#shapeB" }); // shape-to-shape morph
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
Full DrawSVG and MorphSVG recipes live in [svg-motion.md](svg-motion.md).
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
# Imagery & Art Direction: one grade, declared ratios, treated assets
|
|
2
|
-
|
|
3
|
-
Imagery is now where generated builds fail hardest: the CSS can be flawless and the page still screams "AI" because the images are ungraded stock, glossy renders, or six unrelated looks. The fix is the same shape as the color system: **decide once, apply everywhere**. One grade, one ratio set, one sourcing standard.
|
|
4
|
-
|
|
5
|
-
Media *behavior* (window parallax, masked reveals, aspect-ratio for CLS) lives in [depth-parallax.md](depth-parallax.md) and [scroll-reveals.md](scroll-reveals.md); this file is about what the pixels *are*.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. One grade per page
|
|
10
|
-
|
|
11
|
-
Every image on a page passes through the **same treatment**, chosen to match the preset's temperature ([vibe-presets.md](vibe-presets.md)). Mixed grading (a warm lifestyle shot next to a cool product render next to a saturated stock photo) is the single most reliable imagery tell. It says the images were *collected*, not *directed*.
|
|
12
|
-
|
|
13
|
-
Pick one:
|
|
14
|
-
|
|
15
|
-
| Grade | Character | Pairs with |
|
|
16
|
-
|---|---|---|
|
|
17
|
-
| **Warm film** | lifted blacks, warm midtones, gentle contrast | Warm Editorial |
|
|
18
|
-
| **Desaturated cool** | -20 to -40% saturation, cool shift, deep blacks | Dark Cinematic |
|
|
19
|
-
| **High-contrast BW** | full monochrome, punchy | the Brutalist genre appendix only, when the brief declares the genre (color arrives only via the accent) |
|
|
20
|
-
| **Soft neutral** | low contrast, airy highlights, near-monochrome palette subjects | Soft Modern (cool-leaning), Luxury Minimal (warm-leaning) |
|
|
21
|
-
| **Duotone** | surface color + ink (or accent) mapped to shadows/highlights | any preset, as the *signature* look |
|
|
22
|
-
|
|
23
|
-
Apply the grade in one reusable layer, not per-image in an editor:
|
|
24
|
-
|
|
25
|
-
```tsx
|
|
26
|
-
// components/ui/GradedImage.tsx: every content image renders through this
|
|
27
|
-
export function GradedImage({ src, alt, ratio = "4/3", className = "" }: {
|
|
28
|
-
src: string; alt: string; ratio?: string; className?: string;
|
|
29
|
-
}) {
|
|
30
|
-
return (
|
|
31
|
-
<div className={`relative overflow-hidden ${className}`} style={{ aspectRatio: ratio }}>
|
|
32
|
-
<img src={src} alt={alt} className="h-full w-full scale-[1.18] object-cover
|
|
33
|
-
[filter:saturate(0.82)_contrast(1.05)]" />
|
|
34
|
-
{/* the tint: surface temperature washed over every image */}
|
|
35
|
-
<div aria-hidden className="absolute inset-0 bg-surface-0/15 mix-blend-color" />
|
|
36
|
-
</div>
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
- The `filter` + tint values are **per-project constants**: tune once against the preset, then never per-image. (Static CSS filters are fine; it's *scrubbed* filters that cost, see [setup.md](setup.md) §5.)
|
|
42
|
-
- Duotone variant: `[filter:grayscale(1)]` on the img + an accent-colored overlay with `mix-blend-screen` (dark pages) or `mix-blend-multiply` (light pages).
|
|
43
|
-
- `scale-[1.18]` is the parallax headroom from [depth-parallax.md](depth-parallax.md) §2, so grading and panning share the same frame component.
|
|
44
|
-
|
|
45
|
-
**Cheap tell:** every image at its source colors, six looks on one page. **Instead:** One `GradedImage` component; swap the page's grade by editing one filter line.
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## 2. The ratio set: declared like tokens
|
|
50
|
-
|
|
51
|
-
Pick **2 to 3 aspect ratios per project** and use nothing else. A page whose media is 4/3, 16/9, 3/2, 1/1, and 21/9 at random reads as unplaced; the same images in a disciplined ratio set read as a system. (Explicit ratios are also the CLS guard, see [setup.md](setup.md) §6.)
|
|
52
|
-
|
|
53
|
-
| Role | Default | Alternatives by genre |
|
|
54
|
-
|---|---|---|
|
|
55
|
-
| Standard content / work thumb | `4/3` | `3/2` editorial, `1/1` brutalist grid |
|
|
56
|
-
| Full-bleed statement (1 per ~3 viewports) | `21/9` | `2/1` |
|
|
57
|
-
| Portrait accent (people, product detail) | `3/4` | `4/5` |
|
|
58
|
-
|
|
59
|
-
Declare them where the tokens live and reference by role, never ad-hoc:
|
|
60
|
-
|
|
61
|
-
```css
|
|
62
|
-
@theme {
|
|
63
|
-
--ratio-standard: 4/3;
|
|
64
|
-
--ratio-wide: 21/9;
|
|
65
|
-
--ratio-portrait: 3/4;
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
---
|
|
70
|
-
|
|
71
|
-
## 3. Texture: grain, and when to stop
|
|
72
|
-
|
|
73
|
-
One **global grain overlay** unifies mixed sources better than any filter. It puts every image (and the surfaces) on the same "stock":
|
|
74
|
-
|
|
75
|
-
```tsx
|
|
76
|
-
// One fixed element at the end of <body>. z-index above content, below nav (composition.md z-ladder).
|
|
77
|
-
<div aria-hidden className="pointer-events-none fixed inset-0 z-40 opacity-[0.05] mix-blend-overlay"
|
|
78
|
-
style={{ backgroundImage: "url(/noise.png)", backgroundSize: "128px" }} />
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
- `noise.png`: a 128px tileable monochrome noise tile (or an inline SVG `feTurbulence` data-URI). Opacity 0.03 to 0.06: visible on a dark surface if you look, never legible as a pattern.
|
|
82
|
-
- Grain suits film/cinematic/editorial registers; **skip it** for Luxury Minimal (clean air is the brand) and product-UI screenshots (they must stay crisp).
|
|
83
|
-
- Budget: grain is the *only* always-on texture. Grain + vignettes + gradients + blobs = mud.
|
|
84
|
-
|
|
85
|
-
---
|
|
86
|
-
|
|
87
|
-
## 4. Sourcing ladder: where images come from
|
|
88
|
-
|
|
89
|
-
In order of preference:
|
|
90
|
-
|
|
91
|
-
1. **Real assets**: the client's work, product, people, places. Always first; treatment (§1) makes even average photography cohesive.
|
|
92
|
-
2. **Struq's beeldbank**: real, graded, CC0 sector packs from struq.nl, one pack per site so the grade stays single, downloaded into the project and credited. The first stop when the client has no photos, or not enough. How to pick, fetch and credit: [struq-imagery.md](struq-imagery.md).
|
|
93
|
-
3. **Generated on-brief**, with any capable image model (Flux/SDXL-class local checkpoints or a hosted generator). The standard when real assets don't exist and no beeldbank pack fits. Treat each prompt as an art-direction document, not a subject description, and generate the whole set in one session with the same model and settings so it reads as one shoot. Prompt for the *page's* grade, not per-image whims:
|
|
94
|
-
- State the grade in every prompt: light temperature ("warm late-afternoon window light" / "cool overcast studio light"), palette bias matching the preset's neutrals, film character ("35mm film grain, muted colors" vs "clean digital").
|
|
95
|
-
- Keep **one lighting world** across the set. Consistent light does more for coherence than consistent subjects.
|
|
96
|
-
- Compose for the crop: prompt the ratio you'll place (§2), and leave dead space where type will sit if it's a lockup image ([composition.md](composition.md) §3).
|
|
97
|
-
- Avoid the AI-render look: no glossy 3D blobs, no purple-glow gradients, no hyperdetailed-everything. The §1 grade pass over generated output hides most remaining tells.
|
|
98
|
-
4. **Treated stock**: last resort, and only *through* the grade layer. Untreated stock (beaming laptop people, handshake photos) is below the floor.
|
|
99
|
-
|
|
100
|
-
**Beeldbank, stock and generated images show context, never the client's product.** The city, the canal, a rider, a room, a material: yes. A stranger's e-bike, a generated bottle or a stock ceramic presented as the product: never, because the visitor reads it as the thing they would buy. Until the real product is shot, it is a placeholder frame (§4b), a declared 3D stand-in ([webgl-scenes.md](webgl-scenes.md) §4, "No model file yet"), or type.
|
|
101
|
-
|
|
102
|
-
**Never:** browser-chrome screenshot mockups tilted in perspective, generic "dashboard glow" renders, or watermark-adjacent placeholder imagery. If a section has no honest image, the strongest move is **no image**: type, space, and surface carry it (same logic as the no-icon rule, [iconography.md](iconography.md) §1).
|
|
103
|
-
|
|
104
|
-
---
|
|
105
|
-
|
|
106
|
-
## 4b. Placeholders: when the real image is not here yet
|
|
107
|
-
|
|
108
|
-
A slot that will hold a real image (planned in the shot list, not yet shot or generated) ships as a **graded tonal frame**, never a grey box and never random stock dropped in to fill it. It must look like part of the page's system, so the layout can be judged honestly before the photography exists.
|
|
109
|
-
|
|
110
|
-
- **Frame:** the declared ratio for that role (`--ratio-standard`, `--ratio-wide`, `--ratio-portrait`, §2), with the same radius and mask as the real `GradedImage` frame.
|
|
111
|
-
- **Tone:** a vertical gradient between two adjacent surface steps of the palette (for example `surface-2` to `surface-3`), with the page's grain on top. No icon, no "image" glyph, no diagonal cross.
|
|
112
|
-
- **Caption:** the shot-list line (subject and ratio) set in the eyebrow style, bottom left inside the frame's padding, in `ink-muted`. Example: `ROASTER MACHINE, 4/5`.
|
|
113
|
-
- **Markup:** `role="img"` with an `aria-label` that names the planned subject, so the placeholder is honest to screen readers too.
|
|
114
|
-
- **Never inside a showpiece.** A media-led showpiece is not staged on placeholders; it waits for the real assets ([showcase.md](showcase.md) Choosing).
|
|
115
|
-
|
|
116
|
-
```tsx
|
|
117
|
-
// components/ui/ImagePlaceholder.tsx: the stand-in for one shot-list line
|
|
118
|
-
export function ImagePlaceholder({ caption, ratio = "4/3", className = "" }: {
|
|
119
|
-
caption: string; ratio?: string; className?: string;
|
|
120
|
-
}) {
|
|
121
|
-
return (
|
|
122
|
-
<div role="img" aria-label={`Planned image: ${caption}`}
|
|
123
|
-
className={`relative overflow-hidden bg-linear-to-b from-surface-2 to-surface-3 ${className}`}
|
|
124
|
-
style={{ aspectRatio: ratio }}>
|
|
125
|
-
<span className="eyebrow text-eyebrow absolute bottom-4 left-4 text-ink-muted">
|
|
126
|
-
{caption}
|
|
127
|
-
</span>
|
|
128
|
-
</div>
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
---
|
|
134
|
-
|
|
135
|
-
## 5. Accessibility & weight
|
|
136
|
-
|
|
137
|
-
- **Content images:** real `alt` describing the subject. **Decorative/texture images** (ghost imagery, grain, backdrop media): `alt=""` or `aria-hidden`, so "image of background texture" never leaks to screen readers.
|
|
138
|
-
- Ship AVIF/WebP with sensible `sizes`; a full-bleed hero stays at or under 250KB at 1920w and a content image at or under 150KB at 1440w, the budgets in [performance.md](performance.md) §1. Never 4MB.
|
|
139
|
-
- Ambient/looping video obeys the motion doctrine: muted, `playsinline`, poster, paused under `prefers-reduced-motion`. It also counts as the page's one autonomous mover ([feel-layer.md](feel-layer.md) §5).
|
|
140
|
-
|
|
141
|
-
---
|
|
142
|
-
|
|
143
|
-
**Cheap tell:** mixed-grade stock + a tilted browser-chrome screenshot + a purple-glow 3D blob, all at random ratios, alt-less.
|
|
144
|
-
**Instead:** One grade constant, one grain layer, 2 to 3 declared ratios, assets real or generated on-brief, treated identically. The imagery reads as *photographed for this site*.
|