@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.
Files changed (122) hide show
  1. package/README.md +23 -35
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +148 -212
  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/blocks/kit/Cursor.tsx +117 -0
  19. package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
  20. package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
  21. package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
  22. package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
  23. package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
  24. package/skills/struq/blocks/kit/Reveal.tsx +64 -0
  25. package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
  26. package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/gsap.ts +21 -0
  29. package/skills/struq/blocks/kit/images.ts +17 -0
  30. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  31. package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
  32. package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
  33. package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
  34. package/skills/struq/blocks/kit/scenes/types.ts +114 -0
  35. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  36. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  37. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  38. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  39. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  40. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  41. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  42. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  43. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  44. package/skills/struq/blocks/sections/approach.tsx +661 -0
  45. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  46. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  47. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  48. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  49. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  50. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  51. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  52. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  53. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  54. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  55. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  56. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  57. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  58. package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
  59. package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
  60. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
  61. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  62. package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
  63. package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
  64. package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
  65. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  66. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
  67. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  68. package/skills/struq/canon/canon.json +627 -0
  69. package/skills/struq/cinematic/README.md +7 -18
  70. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  71. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  72. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  73. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  74. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  75. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  76. package/skills/struq/recipes/app/fonts.ts +12 -0
  77. package/skills/struq/recipes/app/globals.css +42 -0
  78. package/skills/struq/recipes/app/layout.tsx +30 -0
  79. package/skills/struq/references/craft.md +77 -0
  80. package/skills/struq/references/iconography.md +28 -230
  81. package/skills/struq/references/stack-targets.md +23 -74
  82. package/skills/struq/references/tells.md +52 -0
  83. package/skills/struq/references/triage.md +6 -7
  84. package/skills/struq/scripts/blocks.mjs +156 -0
  85. package/skills/struq/scripts/canon.mjs +134 -0
  86. package/skills/struq/scripts/check.mjs +513 -0
  87. package/skills/struq/scripts/images.mjs +360 -0
  88. package/skills/struq/scripts/log.mjs +75 -0
  89. package/skills/struq/scripts/packs.mjs +156 -0
  90. package/skills/struq/scripts/tokens.mjs +222 -0
  91. package/skills/struq/references/anti-patterns.md +0 -265
  92. package/skills/struq/references/baseline-prompt.md +0 -118
  93. package/skills/struq/references/brand.md +0 -143
  94. package/skills/struq/references/brief-router.md +0 -219
  95. package/skills/struq/references/composition.md +0 -191
  96. package/skills/struq/references/depth-parallax.md +0 -235
  97. package/skills/struq/references/direction.md +0 -188
  98. package/skills/struq/references/feel-layer.md +0 -107
  99. package/skills/struq/references/gsap-api.md +0 -293
  100. package/skills/struq/references/imagery.md +0 -144
  101. package/skills/struq/references/loaders.md +0 -287
  102. package/skills/struq/references/micro-interactions.md +0 -270
  103. package/skills/struq/references/mobile-composition.md +0 -70
  104. package/skills/struq/references/nav-header.md +0 -296
  105. package/skills/struq/references/page-architecture.md +0 -134
  106. package/skills/struq/references/page-entrances.md +0 -198
  107. package/skills/struq/references/performance.md +0 -235
  108. package/skills/struq/references/pinning-scrub.md +0 -341
  109. package/skills/struq/references/pitfalls.md +0 -290
  110. package/skills/struq/references/route-transitions.md +0 -281
  111. package/skills/struq/references/scaling.md +0 -151
  112. package/skills/struq/references/scroll-reveals.md +0 -280
  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
@@ -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*.