@struq/skill 0.0.0-stage → 0.2.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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/bin/struq.mjs +132 -0
  4. package/package.json +41 -4
  5. package/skills/struq/SKILL.md +237 -0
  6. package/skills/struq/cinematic/README.md +409 -0
  7. package/skills/struq/cinematic/checklists/01-preproduction.md +15 -0
  8. package/skills/struq/cinematic/checklists/02-implementation.md +17 -0
  9. package/skills/struq/cinematic/checklists/03-polish.md +17 -0
  10. package/skills/struq/cinematic/examples/code/nextjs/README.md +13 -0
  11. package/skills/struq/cinematic/examples/code/nextjs/app/cinematic-experience.tsx +13 -0
  12. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +42 -0
  13. package/skills/struq/cinematic/examples/code/nextjs/app/page.tsx +6 -0
  14. package/skills/struq/cinematic/examples/code/shared/CameraRig.tsx +65 -0
  15. package/skills/struq/cinematic/examples/code/shared/ExperienceCanvas.tsx +20 -0
  16. package/skills/struq/cinematic/examples/code/shared/ExperienceDirector.tsx +57 -0
  17. package/skills/struq/cinematic/examples/code/shared/ExperienceOverlay.tsx +37 -0
  18. package/skills/struq/cinematic/examples/code/shared/ExperiencePage.tsx +52 -0
  19. package/skills/struq/cinematic/examples/code/shared/GsapFrameLoop.tsx +32 -0
  20. package/skills/struq/cinematic/examples/code/shared/InteriorRoot.tsx +30 -0
  21. package/skills/struq/cinematic/examples/code/shared/README.md +20 -0
  22. package/skills/struq/cinematic/examples/code/shared/SubjectRig.tsx +58 -0
  23. package/skills/struq/cinematic/examples/code/shared/build-experience-timeline.ts +237 -0
  24. package/skills/struq/cinematic/examples/code/shared/direction.ts +52 -0
  25. package/skills/struq/cinematic/examples/code/shared/gsap-client.ts +32 -0
  26. package/skills/struq/cinematic/examples/code/shared/math.ts +12 -0
  27. package/skills/struq/cinematic/examples/code/shared/registry.ts +58 -0
  28. package/skills/struq/cinematic/examples/code/shared/runtime.ts +52 -0
  29. package/skills/struq/cinematic/examples/code/shared/styles.css +127 -0
  30. package/skills/struq/cinematic/examples/code/shared/useExperienceInput.ts +42 -0
  31. package/skills/struq/cinematic/examples/code/vite/README.md +14 -0
  32. package/skills/struq/cinematic/examples/code/vite/src/App.tsx +11 -0
  33. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +37 -0
  34. package/skills/struq/cinematic/examples/code/vite/src/main.tsx +9 -0
  35. package/skills/struq/cinematic/examples/concise/01-chapter-spec-example.md +42 -0
  36. package/skills/struq/cinematic/examples/concise/02-handoff-example.md +25 -0
  37. package/skills/struq/cinematic/examples/concise/03-audit-finding-example.md +13 -0
  38. package/skills/struq/cinematic/examples/verbose/01-full-experience-direction.md +687 -0
  39. package/skills/struq/cinematic/examples/verbose/02-full-audit-example.md +362 -0
  40. package/skills/struq/cinematic/references/01-core-mental-model.md +221 -0
  41. package/skills/struq/cinematic/references/02-vocabulary.md +384 -0
  42. package/skills/struq/cinematic/references/03-experience-architecture.md +302 -0
  43. package/skills/struq/cinematic/references/04-timeline-choreography.md +264 -0
  44. package/skills/struq/cinematic/references/05-handoff-contracts.md +241 -0
  45. package/skills/struq/cinematic/references/06-three-rig-architecture.md +286 -0
  46. package/skills/struq/cinematic/references/07-stack-integration.md +228 -0
  47. package/skills/struq/cinematic/references/08-gsap-plugin-patterns.md +218 -0
  48. package/skills/struq/cinematic/references/09-tuning-harness.md +202 -0
  49. package/skills/struq/cinematic/references/10-quality-systems.md +206 -0
  50. package/skills/struq/cinematic/references/11-agent-workflows.md +246 -0
  51. package/skills/struq/cinematic/references/12-audit-rubric.md +176 -0
  52. package/skills/struq/cinematic/references/13-official-docs.md +53 -0
  53. package/skills/struq/cinematic/templates/01-experience-brief.md +105 -0
  54. package/skills/struq/cinematic/templates/02-experience-contract.md +80 -0
  55. package/skills/struq/cinematic/templates/03-chapter-spec.md +103 -0
  56. package/skills/struq/cinematic/templates/04-handoff-contract.md +102 -0
  57. package/skills/struq/cinematic/templates/05-ownership-matrix.md +29 -0
  58. package/skills/struq/cinematic/templates/06-shot-presets.md +59 -0
  59. package/skills/struq/cinematic/templates/07-implementation-slice.md +66 -0
  60. package/skills/struq/cinematic/templates/08-tuning-register.md +36 -0
  61. package/skills/struq/cinematic/templates/09-acceptance-tests.md +57 -0
  62. package/skills/struq/references/anti-patterns.md +265 -0
  63. package/skills/struq/references/baseline-prompt.md +118 -0
  64. package/skills/struq/references/brand.md +143 -0
  65. package/skills/struq/references/brief-router.md +219 -0
  66. package/skills/struq/references/composition.md +191 -0
  67. package/skills/struq/references/depth-parallax.md +235 -0
  68. package/skills/struq/references/direction.md +188 -0
  69. package/skills/struq/references/feel-layer.md +107 -0
  70. package/skills/struq/references/gsap-api.md +293 -0
  71. package/skills/struq/references/iconography.md +255 -0
  72. package/skills/struq/references/imagery.md +144 -0
  73. package/skills/struq/references/loaders.md +287 -0
  74. package/skills/struq/references/micro-interactions.md +270 -0
  75. package/skills/struq/references/mobile-composition.md +70 -0
  76. package/skills/struq/references/nav-header.md +296 -0
  77. package/skills/struq/references/page-architecture.md +134 -0
  78. package/skills/struq/references/page-entrances.md +198 -0
  79. package/skills/struq/references/performance.md +235 -0
  80. package/skills/struq/references/pinning-scrub.md +341 -0
  81. package/skills/struq/references/pitfalls.md +290 -0
  82. package/skills/struq/references/route-transitions.md +281 -0
  83. package/skills/struq/references/scaling.md +151 -0
  84. package/skills/struq/references/scroll-reveals.md +280 -0
  85. package/skills/struq/references/seo.md +219 -0
  86. package/skills/struq/references/setup.md +397 -0
  87. package/skills/struq/references/showcase.md +87 -0
  88. package/skills/struq/references/stack-targets.md +78 -0
  89. package/skills/struq/references/struq-imagery.md +107 -0
  90. package/skills/struq/references/svg-motion.md +390 -0
  91. package/skills/struq/references/transitions.md +160 -0
  92. package/skills/struq/references/triage.md +71 -0
  93. package/skills/struq/references/typography.md +720 -0
  94. package/skills/struq/references/vibe-presets.md +287 -0
  95. package/skills/struq/references/webgl-r3f.md +274 -0
  96. package/skills/struq/references/webgl-scenes.md +593 -0
@@ -0,0 +1,287 @@
1
+ # First-Load Loaders: session-gated, SSR-safe
2
+
3
+ One arm of the page-flow system, the site's *arrival*. A loader is the route transition's cover phase with no swap ([route-transitions.md](route-transitions.md)): it starts closed and clears once assets are ready. Sibling: [page-entrances.md](page-entrances.md) for what the revealed page does next.
4
+
5
+ **It is optional.** It is warranted on narrative/portfolio/launch sites and skipped on app/dashboard zones. Keep it short, tie it to real load progress, and play it **once per session**. Never a spinner: the overlay sits on top of finished content and reveals it.
6
+
7
+ ---
8
+
9
+ ## 0. The lifecycle invariant: why flash-free loaders work
10
+
11
+ A first-load loader has **two different state machines**. Never collapse them into one boolean:
12
+
13
+ | State | Owner | What it may do |
14
+ |---|---|---|
15
+ | First paint: play or skip | synchronous script in `<head>` + CSS | Hide SSR loader markup on a revisit before the browser paints |
16
+ | Live phase: loading → revealing → done | the mounted loader component | Keep the first-visit curtain mounted until its transform/opacity exit completes |
17
+
18
+ The classic regression is using one `data-loader-seen` attribute for both jobs, then running `markLoaderSeen()` when the intro starts or when the exit starts. If CSS contains `html[data-loader-seen] [data-first-loader] { display:none }`, that write removes the live curtain immediately. The reveal timeline technically starts against `display:none`, so the user sees a flash/cut instead of a curtain.
19
+
20
+ **Non-negotiable order:**
21
+
22
+ 1. SSR emits an already-opaque curtain. Any animated word/chars are also emitted in their hidden start state; do not wait for hydration to apply a GSAP `.from()` state.
23
+ 2. A synchronous `<head>` script stamps the skip attribute only for a session revisit or excluded app route. CSS suppresses that SSR overlay before first paint.
24
+ 3. On a genuine first visit, await real readiness plus a minimum floor, capped by a safety ceiling. The curtain remains static and opaque while waiting.
25
+ 4. Start one compositor-only exit (`transform` and/or `opacity`). Do not read layout or change overflow at release time.
26
+ 5. In the exit's `onComplete`, and only there: persist the session marker, stamp the skip attribute, release scroll, and unmount.
27
+
28
+ This is why the system stays clean at both ends: the SSR gate prevents revisit flashes, while delayed persistence lets the live curtain finish.
29
+
30
+ ---
31
+
32
+ ## 1. The session gate (shared by every loader)
33
+
34
+ ```ts
35
+ // components/ui/loaders/useFirstVisit.ts
36
+ const PREFIX = "loader:";
37
+ export const LOADER_SEEN_ATTR = "data-loader-seen";
38
+
39
+ // Inline this synchronously in <head>, before loader markup. It is the only code
40
+ // allowed to activate the CSS skip gate before the first paint.
41
+ export const PRE_PAINT_LOADER_SCRIPT = `try{if(sessionStorage.getItem('${PREFIX}intro')==='1')document.documentElement.setAttribute('${LOADER_SEEN_ATTR}','1')}catch(e){}`;
42
+
43
+ export function loaderSeen(id?: string): boolean {
44
+ if (typeof window === "undefined" || !id) return false;
45
+ if (document.documentElement.hasAttribute(LOADER_SEEN_ATTR)) return true;
46
+ try { return sessionStorage.getItem(PREFIX + id) === "1"; } catch { return false; }
47
+ }
48
+ export function markLoaderSeen(id?: string): void {
49
+ if (typeof window === "undefined" || !id) return;
50
+ // CALL ONLY FROM THE CURTAIN EXIT'S onComplete.
51
+ document.documentElement.setAttribute(LOADER_SEEN_ATTR, "1");
52
+ try { sessionStorage.setItem(PREFIX + id, "1"); } catch { /* private mode → plays every time */ }
53
+ }
54
+ ```
55
+
56
+ ```css
57
+ /* SSR revisit gate. This selector is permanent suppression, not animation state. */
58
+ html[data-loader-seen] [data-first-loader] {
59
+ display: none !important;
60
+ }
61
+ ```
62
+
63
+ Mount the loader in a persistent root/marketing layout, never inside a page template that remounts on every client navigation. A small same-document module guard may protect Strict Mode/HMR remounts while the first run is active; session persistence still happens only after the exit completes.
64
+
65
+ ---
66
+
67
+ ## 2. Asset-aware counter (the default loader)
68
+
69
+ Tie release to **real** readiness (`document.fonts.ready` + critical image decode/load) and a minimum on-screen floor, raced against a hard ceiling. The floor makes a warm-cache run legible; the ceiling means one stalled request can never pin the site.
70
+
71
+ The key lifecycle, not the visual styling, is:
72
+
73
+ ```tsx
74
+ "use client";
75
+ import { useRef, useState } from "react";
76
+ import { gsap, useGSAP } from "@/lib/gsap";
77
+ import { loaderSeen, markLoaderSeen } from "./useFirstVisit";
78
+
79
+ const MIN_HOLD_MS = 1450;
80
+ const SAFETY_CEILING_MS = 4000;
81
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
82
+
83
+ function decodeCriticalHeroImage(src = "/hero-poster.webp"): Promise<void> {
84
+ return new Promise((resolve) => {
85
+ const image = new Image();
86
+ image.src = src;
87
+ image.decode().then(resolve).catch(resolve);
88
+ });
89
+ }
90
+
91
+ export function LoaderCounter({ id = "intro" }: { id?: string }) {
92
+ const root = useRef<HTMLDivElement>(null);
93
+ const [done, setDone] = useState(false);
94
+
95
+ useGSAP(() => {
96
+ if (loaderSeen(id)) { setDone(true); return; }
97
+ const layer = root.current;
98
+ if (!layer) return;
99
+
100
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
101
+ let cancelled = false;
102
+ document.documentElement.classList.add("loader-scroll-lock");
103
+
104
+ const assetChecks = Promise.all([
105
+ (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve(),
106
+ decodeCriticalHeroImage(), // resolve on decode OR error
107
+ wait(MIN_HOLD_MS),
108
+ ]);
109
+
110
+ Promise.race([assetChecks, wait(SAFETY_CEILING_MS)]).then(() => {
111
+ if (cancelled) return;
112
+ layer.dataset.loaderPhase = "revealing";
113
+ gsap.to(layer, {
114
+ yPercent: reduce ? 0 : -100,
115
+ autoAlpha: reduce ? 0 : 1,
116
+ duration: reduce ? 0.25 : 0.9,
117
+ ease: reduce ? "power2.out" : "power4.inOut",
118
+ onComplete: () => {
119
+ // Persist LAST. Earlier writes activate the CSS display:none gate and
120
+ // cut this live reveal off.
121
+ markLoaderSeen(id);
122
+ document.documentElement.classList.remove("loader-scroll-lock");
123
+ setDone(true);
124
+ },
125
+ });
126
+ });
127
+
128
+ return () => {
129
+ cancelled = true;
130
+ document.documentElement.classList.remove("loader-scroll-lock");
131
+ };
132
+ }, { scope: root });
133
+
134
+ if (done) return null;
135
+ return (
136
+ <div ref={root} data-first-loader data-loader-phase="loading"
137
+ className="fixed inset-0 z-[10001] bg-surface-0">
138
+ {/* Encode hidden start states in SSR markup/CSS. If SplitText must create
139
+ them after hydration, keep the whole word visibility:hidden until its
140
+ chars have been positioned, then reveal the wrapper. */}
141
+ <span className="loader-word" style={{ visibility: "hidden" }}>Brand</span>
142
+ </div>
143
+ );
144
+ }
145
+ ```
146
+
147
+ The readiness callback must only flip state/start a transform. Never run `getBoundingClientRect()`, `ScrollTrigger.refresh()`, image decoding, or synchronous asset work in the release frame.
148
+
149
+ ### Flash-free animated start states
150
+
151
+ The curtain background itself must be present in SSR HTML. Any content that winds up (masked chars, mark, line, dot) must also start hidden before React runs:
152
+
153
+ ```css
154
+ .loader-char { transform: translateY(118%); }
155
+ .loader-dot { opacity: 0; transform: scale(0); }
156
+ ```
157
+
158
+ For SplitText, SSR cannot emit the generated char wrappers. Set `visibility:hidden` on the unsplit word in JSX, create the split, position every char, then set the wrapper visible. A GSAP `.from()` without that SSR guard is too late: the browser can paint the completed word for one frame before hydration, producing the initial flash.
159
+
160
+ ### Scroll lock without scrollbar shift
161
+
162
+ `scrollbar-gutter: stable` alone is not enough if the lock changes `<html>` to `overflow:hidden`; Chromium may collapse the gutter because the root is no longer scrollable. Keep the root scrollable and lock the body/content instead:
163
+
164
+ ```css
165
+ html { scrollbar-gutter: stable; }
166
+ html.loader-scroll-lock {
167
+ overflow-y: scroll;
168
+ touch-action: none;
169
+ overscroll-behavior: none;
170
+ }
171
+ html.loader-scroll-lock body {
172
+ height: 100dvh;
173
+ overflow: hidden;
174
+ }
175
+ ```
176
+
177
+ Apply the class while the opaque curtain is present and remove it only after the exit completes. Measure `document.documentElement.clientWidth` before/during/after the lock in browser coverage; all three values must match.
178
+
179
+ ---
180
+
181
+ ## 3. Loader preset menu (pick one per project)
182
+
183
+ All are SSR-safe overlays at `z-[10001]`, reveal on exit, respect reduced-motion, and use `mixBlendMode: "difference"` so the mark reads on any backdrop. Add the `loaderSeen`/`markLoaderSeen` gate (inside `useGSAP`) for any loader you want to play once per session.
184
+
185
+ | Preset | Feel | Mechanic | Exit |
186
+ |---|---|---|---|
187
+ | **Counter** | data / precise | % count tied to real asset load | slide up `yPercent:-100` 0.9s `power4.inOut` |
188
+ | **Mark** | minimal / brand | wordmark fades in, holds, layer fades | `opacity→0` 0.6s `power2.inOut` |
189
+ | **Shutter** | architectural | grid of tiles holds brand, staggered grid-wipe | tiles `scaleY→0` 0.6s `power4.inOut` (the baseline curtain ease) |
190
+ | **Odometer** | mechanical | rolling 0→100 reel | slide up 0.9s `power4.inOut` |
191
+ | **WordCycle** | editorial | masked word reel settles on brand | slide up 0.9s `power4.inOut` |
192
+ | **ProgressBar** | restrained | hairline fills 0→100% under a wordmark | slide up 0.8s `power4.inOut` |
193
+ | **DrawMark** | crafted | SVG mark draws in (DrawSVG, recipe in [svg-motion.md](svg-motion.md)) + wordmark | layer fades 0.6s |
194
+
195
+ > **Warning: avoid a scramble / "decode" / terminal-typewriter loader by default.** That monospaced, glitchy-text aesthetic reads as techy filler and collides with the monospace ban ([anti-patterns.md](anti-patterns.md)). Use it only when the brand is genuinely a terminal/dev product.
196
+
197
+ Example of the gate wired into a preset (Shutter):
198
+
199
+ ```tsx
200
+ // components/ui/loaders/LoaderShutter.tsx (abridged)
201
+ "use client";
202
+ import { useRef, useState } from "react";
203
+ import { gsap, useGSAP } from "@/lib/gsap";
204
+ import { loaderSeen, markLoaderSeen } from "./useFirstVisit";
205
+
206
+ export function LoaderShutter({ id, brand = "Brand", rows = 3, cols = 4 }: {
207
+ id?: string; brand?: string; rows?: number; cols?: number;
208
+ }) {
209
+ const root = useRef<HTMLDivElement>(null);
210
+ const [done, setDone] = useState(false);
211
+
212
+ useGSAP(() => {
213
+ if (loaderSeen(id)) { setDone(true); return; } // revisit → hide before paint
214
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
215
+ const tiles = gsap.utils.toArray<HTMLElement>(".sh-tile");
216
+ const tl = gsap.timeline();
217
+ tl.from(".sh-word", { autoAlpha: 0, y: 14, duration: reduce ? 0.2 : 0.5, ease: "power2.out" });
218
+ tl.to(".sh-word", { autoAlpha: 0, duration: reduce ? 0.2 : 0.4, ease: "power2.in" }, "+=" + (reduce ? 0.1 : 0.35));
219
+ tl.to(tiles, {
220
+ scaleY: 0, transformOrigin: "top", duration: reduce ? 0.3 : 0.6, ease: "power4.inOut", // curtains: quint-in-out
221
+ stagger: { each: reduce ? 0 : 0.04, grid: [rows, cols], from: "start" },
222
+ onComplete: () => {
223
+ markLoaderSeen(id); // persist only after the last tile has cleared
224
+ setDone(true);
225
+ },
226
+ }, "-=0.1");
227
+ }, { scope: root });
228
+
229
+ if (done) return null;
230
+ return (
231
+ <div ref={root} className="fixed inset-0 z-[10001]">
232
+ <div className="absolute inset-0 grid"
233
+ style={{ gridTemplateColumns: `repeat(${cols},1fr)`, gridTemplateRows: `repeat(${rows},1fr)` }}>
234
+ {Array.from({ length: rows * cols }).map((_, i) => (
235
+ <div key={i} className="sh-tile bg-surface-0 will-change-transform" />
236
+ ))}
237
+ </div>
238
+ <div className="sh-word pointer-events-none absolute inset-0 grid place-items-center font-display text-h2 text-ink"
239
+ style={{ mixBlendMode: "difference" }}>{brand}</div>
240
+ </div>
241
+ );
242
+ }
243
+ ```
244
+
245
+ **Why SSR-safe matters:** the overlay sits *on top of* the rendered HTML and reveals it. The page is fully in the SSR markup underneath, so there's no flash of unstyled content, no hydration mismatch, no SEO cost, and if JS fails the content is still there.
246
+
247
+ Loaders mount inside `<body>`, above `children`. The root-layout wiring is in [route-transitions.md](route-transitions.md) §2.
248
+
249
+ ---
250
+
251
+ ## Timing reference
252
+
253
+ | Phase | Duration | Ease | Transform |
254
+ |---|---|---|---|
255
+ | Loader exit (slide) | 0.8-0.9s | `power4.inOut` | `yPercent 0→-100` |
256
+ | Loader exit (fade) | 0.6s | `power2.inOut` | opacity |
257
+ | **Reduced motion** | 0.2-0.3s | none | opacity only |
258
+
259
+ ---
260
+
261
+ ## Anti-patterns (sweep before shipping)
262
+
263
+ - **Spinner / loading gif** while content pops in → an asset-aware overlay that reveals finished content.
264
+ - **Loader on every navigation** (stacked on the route wipe) → session-gate with `id`; loader is first-visit-only.
265
+ - **FOUC / content reflowing in** as fonts/images land → SSR-safe overlay + explicit media aspect ratios; entrances double as load choreography.
266
+ - **Completed wordmark flashes, then restarts** → its start state only exists in a hydration-time `.from()` tween; encode it in SSR CSS/inline visibility first.
267
+ - **Curtain disappears instead of lifting** → the session `seen` attribute activated `display:none` before exit completion; persist only in `onComplete`.
268
+ - **Page jumps horizontally at lock/unlock** → `overflow:hidden` collapsed the root scrollbar gutter; keep `<html>` at `overflow-y:scroll` and lock body/content.
269
+ - **Warm loads blink / slow loads hang** → readiness has neither a minimum floor nor a hard safety ceiling; use both in one shared promise.
270
+ - **Scramble/terminal-decode loader** as a default aesthetic → use a typographic preset instead (monospace ban).
271
+
272
+ ## When to use a loader
273
+
274
+ - **Use** on narrative, portfolio, campaign, and launch sites, and only **once per session** (`id`). Keep it under ~1.5s of real work.
275
+ - **Skip** on app/dashboard zones and utility pages. There, the route wipe alone (or even just the fast app entrance, [page-entrances.md](page-entrances.md)) is enough; a theatrical intro on a tool feels wrong.
276
+ - Reduced motion: every preset branches to a 0.2-0.3s fade, no travel.
277
+
278
+ ## Regression coverage: required for loader work
279
+
280
+ At minimum, automate these four checks in a real browser:
281
+
282
+ 1. First visit: loader is attached and visible; the persistent `seen` attribute is absent while it plays.
283
+ 2. Reveal: loader enters an explicit `revealing` phase, remains `display != none`, and its transform moves off-screen before unmount.
284
+ 3. Same-session revisit: the pre-paint attribute exists and no frame ever shows the SSR loader layer.
285
+ 4. Scroll lock: root `clientWidth` is identical before, during, and after lock; the lock class is removed when the loader finishes.
286
+
287
+ Also inspect raw SSR HTML: the `<head>` pre-paint script must occur before loader markup, and animated content must carry its hidden first-paint state.
@@ -0,0 +1,270 @@
1
+ # Micro-Interactions
2
+
3
+ Garnish, not the meal: micro-interactions are the last 5% that make the first 95% feel intentional. They only land if the macro layer (type, spacing, reveals) is already right. A magnetic button on a templated layout is lipstick.
4
+
5
+ Budget rule: pick **2 to 3** from this file per site, executed perfectly, rather than all of them. Density of gimmicks reads as junior.
6
+
7
+ All hover-dependent effects must be gated: wrap in `@media (hover: hover)` / check `window.matchMedia("(hover: hover)").matches`. Magnetic buttons and cursor followers on touch devices are dead weight that still costs listeners.
8
+
9
+ **Library split (SOTA, not taste):** the high-frequency imperative effects here (magnetic buttons, custom cursor) stay on **GSAP `quickTo`**, because they update every frame and must share the one Lenis+GSAP ticker. A competing Motion spring loop is what makes otherwise-good motion feel subtly off. **Motion** owns the state/lifecycle layer: press/toggle feedback (`whileTap`), `AnimatePresence`, `layout`/`layoutId`, and `whileHover`/`whileInView` one-offs. Same rule as the SKILL.md decision table: *every frame or scroll-tied means GSAP; React state/lifecycle means Motion.* The weighted, smooth feel comes from one clock and tuned easing, not from the library choice.
10
+
11
+ ---
12
+
13
+ ## 1. Magnetic button
14
+
15
+ **Term:** Magnetic / gravitational hover.
16
+ **Mechanic:** Within a proximity zone, the button drifts toward the cursor (and its label drifts slightly further, creating internal depth). On leave, it snaps home with an elastic ease. Implemented with `gsap.quickTo`, which gives pre-compiled tweens for high-frequency updates. Never `setState` per mousemove (re-render per frame) and never raw `gsap.to` per event (allocates a tween per pixel moved).
17
+
18
+ **When:** Primary CTAs, nav logo, social icons. 1 to 3 magnetic elements per viewport max.
19
+ **Cost:** Trivial with quickTo. The gate is taste, not perf.
20
+
21
+ ```tsx
22
+ "use client";
23
+
24
+ import { useRef } from "react";
25
+ import { gsap, useGSAP } from "@/lib/gsap";
26
+
27
+ export function MagneticButton({ children, strength = 0.35 }: { children: React.ReactNode; strength?: number }) {
28
+ const wrap = useRef<HTMLDivElement>(null);
29
+ const label = useRef<HTMLSpanElement>(null);
30
+
31
+ useGSAP(
32
+ (_, contextSafe) => {
33
+ if (!window.matchMedia("(hover: hover)").matches) return;
34
+
35
+ const el = wrap.current!;
36
+ const xTo = gsap.quickTo(el, "x", { duration: 0.4, ease: "power3.out" });
37
+ const yTo = gsap.quickTo(el, "y", { duration: 0.4, ease: "power3.out" });
38
+ const lx = gsap.quickTo(label.current, "x", { duration: 0.45, ease: "power3.out" });
39
+ const ly = gsap.quickTo(label.current, "y", { duration: 0.45, ease: "power3.out" });
40
+
41
+ const onMove = contextSafe!((e: MouseEvent) => {
42
+ const r = el.getBoundingClientRect();
43
+ const dx = e.clientX - (r.left + r.width / 2);
44
+ const dy = e.clientY - (r.top + r.height / 2);
45
+ xTo(dx * strength); yTo(dy * strength);
46
+ lx(dx * strength * 0.5); ly(dy * strength * 0.5); // label trails → internal parallax
47
+ });
48
+
49
+ const onLeave = contextSafe!(() => {
50
+ gsap.to([el, label.current], { x: 0, y: 0, duration: 0.7, ease: "elastic.out(1, 0.4)" });
51
+ });
52
+
53
+ el.addEventListener("mousemove", onMove);
54
+ el.addEventListener("mouseleave", onLeave);
55
+ return () => { el.removeEventListener("mousemove", onMove); el.removeEventListener("mouseleave", onLeave); };
56
+ },
57
+ { scope: wrap }
58
+ );
59
+
60
+ return (
61
+ <div ref={wrap} className="inline-block">
62
+ <button className="group relative overflow-hidden rounded-full border border-line bg-surface-2
63
+ px-8 py-4 font-ui text-ui text-ink">
64
+ <span ref={label} className="relative z-10 inline-block">{children}</span>
65
+ {/* fill swell on hover: scales from bottom, CSS-only */}
66
+ <span aria-hidden className="absolute inset-0 origin-bottom scale-y-0 bg-accent
67
+ transition-transform duration-500 ease-out-expo group-hover:scale-y-100" />
68
+ </button>
69
+ </div>
70
+ );
71
+ }
72
+ ```
73
+
74
+ The two depth cues, the label trailing at 0.5× and the `elastic.out` return, are the difference between "follows the mouse" and "magnetic." Strength stays at or below 0.4. Buttons chasing cursors across the screen is the parody version.
75
+
76
+ **Cheap tell:** `hover:scale-105 transition` on the button. **Instead:** Proximity drift + trailing label + elastic return + fill swell.
77
+
78
+ ---
79
+
80
+ ## 2. Custom cursor / follower
81
+
82
+ **Term:** Cursor follower / contextual cursor.
83
+ **Mechanic:** A fixed element lerps toward the real cursor (trailing it by its smoothing factor) and **changes state per hover context**: growing into a "View" pill over project cards, a circle over links, hiding over native inputs. The context-reactivity is the entire justification. A dot that only follows is decoration.
84
+
85
+ **When:** Image-heavy portfolios and campaign pages where "View / Drag / Play" affordances add real information.
86
+ **Skip when:** content/SaaS/utility products. Fighting the system cursor in a tool context is hostile. This is the #1 over-reached-for effect, so when in doubt, skip.
87
+ **Cost:** One quickTo pair on a `fixed` element, which is cheap. Never hide the native cursor without a perfect replacement, and never `cursor: none` globally (it breaks inputs, iframes, selection).
88
+
89
+ ```tsx
90
+ "use client";
91
+
92
+ import { useEffect, useRef, useState } from "react";
93
+ import { gsap } from "@/lib/gsap";
94
+
95
+ export function Cursor() {
96
+ const dot = useRef<HTMLDivElement>(null);
97
+ const [variant, setVariant] = useState<"default" | "view">("default");
98
+
99
+ useEffect(() => {
100
+ if (!window.matchMedia("(hover: hover)").matches) return;
101
+
102
+ // duration here IS the trail amount: 0.5-0.7 visible trail, 0.2 tight
103
+ const xTo = gsap.quickTo(dot.current, "x", { duration: 0.55, ease: "power3.out" });
104
+ const yTo = gsap.quickTo(dot.current, "y", { duration: 0.55, ease: "power3.out" });
105
+
106
+ const onMove = (e: MouseEvent) => { xTo(e.clientX); yTo(e.clientY); };
107
+
108
+ // Event delegation: one listener, data-cursor attributes opt elements in
109
+ const onOver = (e: MouseEvent) => {
110
+ const t = (e.target as HTMLElement).closest<HTMLElement>("[data-cursor]");
111
+ setVariant((t?.dataset.cursor as "view") ?? "default");
112
+ };
113
+
114
+ window.addEventListener("mousemove", onMove);
115
+ document.addEventListener("mouseover", onOver);
116
+ return () => { window.removeEventListener("mousemove", onMove); document.removeEventListener("mouseover", onOver); };
117
+ }, []);
118
+
119
+ return (
120
+ <div ref={dot} className="pointer-events-none fixed left-0 top-0 z-[100] -translate-x-1/2 -translate-y-1/2">
121
+ <div className={`flex items-center justify-center rounded-full bg-ink text-surface-0
122
+ transition-all duration-300 ease-out-expo
123
+ ${variant === "view" ? "eyebrow text-eyebrow h-20 w-20" : "h-2.5 w-2.5"}`}>
124
+ {variant === "view" && "View"}
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ // Usage: <article data-cursor="view">…</article>. The native cursor stays visible alongside.
131
+ ```
132
+
133
+ **Cheap tell:** a ring + dot following the cursor everywhere, no state changes, native cursor hidden. **Instead:** Small dot that *becomes an affordance* over opted-in elements, delegation-driven, hover-gated.
134
+
135
+ ---
136
+
137
+ ## 3. Hover state choreography
138
+
139
+ **Term:** Compound hover / hover scene.
140
+ **Mechanic:** A hover state where 2 to 4 properties move on *different* timings: image scales while a caption rises while siblings dim. The compound timing is what reads as choreography. Mostly CSS via `group`; Motion/GSAP only when state must persist or sequence.
141
+
142
+ ### Hover vocabulary (summary)
143
+
144
+ | Effect | What it is | Tool | Where |
145
+ |---|---|---|---|
146
+ | Link underline | Line exits one side, redraws from the other (origin flip) | CSS | §3a |
147
+ | Card hover scene | Image scale + caption rise + sibling dim on three clocks | CSS (`group`, `group/list`) | §3b |
148
+ | Text swap | Label rolls out of its mask while its twin rolls in | CSS (JS + SplitText for per-char) | §3c |
149
+ | Magnetic | Element and label drift toward the cursor, elastic return | GSAP `quickTo` | §1 |
150
+ | Cursor follower | Lerped dot that becomes "View / Drag / Play" over opted-in elements | GSAP `quickTo` + delegation | §2 |
151
+ | Image distortion on hover | Displacement/ripple of a texture under the pointer, WebGL only | R3F shader | [webgl-scenes.md](webgl-scenes.md) |
152
+
153
+ Pick from this table by genre (see the selection heuristic at the end). Hover distortion on images is the heaviest item here, so it needs a real poster fallback and the one-canvas budget from [webgl-r3f.md](webgl-r3f.md).
154
+
155
+ ### a) Link underline: draws out one side, redraws from the other
156
+
157
+ ```tsx
158
+ <a className="group relative inline-block text-ink" href="/work">
159
+ Selected work
160
+ <span aria-hidden className="absolute -bottom-0.5 left-0 h-px w-full origin-right scale-x-0
161
+ bg-current transition-transform duration-400 ease-out-quart
162
+ group-hover:origin-left group-hover:scale-x-100" />
163
+ </a>
164
+ ```
165
+
166
+ The origin flip (`origin-right` at rest, `origin-left` on hover) makes the line *exit the way it came in*: exit stage right, re-enter stage left. It is the detail people feel and can't name.
167
+
168
+ ### b) Card hover scene: image, caption, arrow, siblings
169
+
170
+ ```tsx
171
+ export function WorkCard({ p }: { p: { img: string; title: string; tag: string } }) {
172
+ return (
173
+ // parent <ul> gets: className="group/list", enables sibling dimming.
174
+ // The <li> is the scroll-reveal target ([data-reveal], GSAP autoAlpha + y), so it carries
175
+ // NO transition and no opacity class: the dim lives on the inner <a>. A CSS opacity
176
+ // transition on a GSAP target leaves it stuck at opacity 0 (pitfalls.md §3.1).
177
+ <li data-reveal className="group">
178
+ <a href="#" className="block transition-opacity duration-500
179
+ group-hover/list:opacity-40 group-hover/list:hover:opacity-100">
180
+ <div className="overflow-hidden">
181
+ <img src={p.img} alt="" className="aspect-[4/3] w-full object-cover
182
+ transition-transform duration-700 ease-out-expo
183
+ group-hover:scale-[1.06]" />
184
+ </div>
185
+ <div className="mt-4 flex items-baseline justify-between">
186
+ <h3 className="type-h3 text-h3">{p.title}</h3>
187
+ <span className="text-small translate-x-2 text-ink-muted opacity-0
188
+ transition-all duration-400 delay-75 ease-out-quart
189
+ group-hover:translate-x-0 group-hover:opacity-100">
190
+ {p.tag} →
191
+ </span>
192
+ </div>
193
+ </a>
194
+ </li>
195
+ );
196
+ }
197
+ ```
198
+
199
+ Three timings: image `700ms` (heavy thing, moves slow), tag `400ms` + `delay-75` (responds after), siblings `500ms` dim. **Sibling dimming** (`group/list` named group: list dims everyone, hovered card restores itself) is the single highest-value hover trick. It turns a grid into a spotlight.
200
+
201
+ **Reveal the wrapper, transition the child.** The rule behind the markup above: an element GSAP animates (here the `li`, revealed by [scroll-reveals.md](scroll-reveals.md) §1) never also carries a CSS `transition-*` on a property GSAP writes (`opacity`, `transform`, `translate`, `scale`). Hover transitions go one level in. The same applies to Tailwind `translate-*`, `scale-*` and `rotate-*` offsets: GSAP sets those properties to `none` on its targets, so they belong on a child or a wrapper ([pitfalls.md](pitfalls.md) §3).
202
+
203
+ ### c) Text swap (slot-machine label)
204
+
205
+ ```tsx
206
+ <button className="group relative overflow-hidden px-6 py-3">
207
+ <span className="block transition-transform duration-400 ease-out-expo
208
+ group-hover:-translate-y-full">Get in touch</span>
209
+ <span aria-hidden className="absolute inset-x-6 top-full block transition-transform duration-400
210
+ ease-out-expo group-hover:-translate-y-full">Get in touch</span>
211
+ </button>
212
+ ```
213
+
214
+ Label rolls up and out of its mask while its twin rolls in: the masked-reveal principle ([scroll-reveals.md](scroll-reveals.md)) at button scale. With per-char stagger it becomes the full slot-machine; that needs JS (SplitText chars + `stagger: 0.02`) and is worth it on exactly one element (the main CTA or logo).
215
+
216
+ **Cheap tell:** `hover:shadow-xl hover:-translate-y-1` on cards: everything moves at once, on one duration, with a shadow change for "lift." **Instead:** Image, caption, and siblings on three different clocks; elevation never via shadow swell.
217
+
218
+ ---
219
+
220
+ ## 4. Micro-feedback (press, focus, toggle)
221
+
222
+ The smallest layer: `whileTap` and friends. Motion is the right tool (state-driven):
223
+
224
+ ```tsx
225
+ import { motion } from "motion/react";
226
+
227
+ <motion.button
228
+ whileTap={{ scale: 0.97 }}
229
+ transition={{ duration: 0.15, ease: [0.25, 1, 0.5, 1] }}
230
+ className="... focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent"
231
+ >
232
+ Submit
233
+ </motion.button>
234
+ ```
235
+
236
+ - Press = scale **0.97**, 150ms. (Not 0.9, that's a toy.)
237
+ - Focus rings: visible, offset, in the accent. Designed, not suppressed. Keyboard users get states as considered as hover users'.
238
+ - Toggles/accordions animate `grid-template-rows: 0fr → 1fr` (or Motion `height: "auto"`), 300ms quart-out, with a chevron rotating on the same clock.
239
+
240
+ ---
241
+
242
+ ## 5. Animated icons (Lordicon / Lottie)
243
+
244
+ An animated icon is a micro-interaction and obeys this file's rules: budget 2 to 3 per view, hover-gated, reduced-motion renders static (the still frame). It's state/lifecycle-driven (a Lottie player toggled on hover/click), so triggering lives in **Motion** territory, not GSAP.
245
+
246
+ The discipline that separates "delightful" from "distracting" is **timing**, and it's locked:
247
+
248
+ | Trigger | Duration | Notes |
249
+ |---|---|---|
250
+ | Click / tap | **≤ 500ms** | must keep up with repeat clicks; longer lags the cursor and annoys by the 3rd press |
251
+ | Hover (decorative) | **1500-3000ms** | the delight sweet spot; more complex symbol → longer end |
252
+ | GIF fallback loop | **2000-5000ms delay between loops** | only if tech forces a looping GIF |
253
+
254
+ - **Never loop in a dashboard / standard UI**; trigger on hover instead. (Looping is for a page *loader*, not ambient decoration. Same as "nothing moves the user didn't cause," [feel-layer.md](feel-layer.md) §5.)
255
+ - **No flashing/flickering** (seizure hazard); **natural physics only**. Unnatural bounce or motion that contradicts the icon's meaning leaves users unsettled.
256
+
257
+ Full icon doctrine (source via Iconify, system vs decorative registers, color/state, labels, accessibility, touch targets) is in [iconography.md](iconography.md).
258
+
259
+ **Cheap tell:** a row of icons all looping forever, or a 700ms "success" spin that lags every click. **Instead:** One hover-triggered animated anchor that plays once, ≤500ms on click, static under reduced motion.
260
+
261
+ ---
262
+
263
+ ## Selection heuristic
264
+
265
+ | Site genre | Ship | Skip |
266
+ |---|---|---|
267
+ | Portfolio / studio | cursor "View" states, sibling dimming, magnetic CTA | none |
268
+ | Campaign / launch | magnetic CTA, text-swap buttons, underline choreography | cursor follower (usually) |
269
+ | Editorial / content | underline choreography, image scale-in-mask | magnetic, cursor |
270
+ | SaaS / product | press feedback, focus design, hover scenes on cards | cursor follower, magnetic (almost always) |
@@ -0,0 +1,70 @@
1
+ # Mobile Composition: translating the art direction to 360-430px
2
+
3
+ The scaling knob ([scaling.md](scaling.md)) already solves *sizing* on mobile, so do **not** re-introduce breakpoint size ladders here. What the knob can't solve is *composition*: overlaps, asymmetric grids, and lockups are authored in twelve columns, and a naive `grid-cols-1` collapse turns art direction back into the centered-stack template. This file is the translation table.
4
+
5
+ The principle: **mobile keeps the attitude, not the geometry.** Asymmetry, trespass, and scale contrast must survive; the specific desktop arrangement doesn't. A mobile page that is a centered column of full-width blocks has lost the design even if every rem is correct.
6
+
7
+ ---
8
+
9
+ ## 1. Translation table: every desktop technique, its mobile form
10
+
11
+ | Desktop technique | Mobile translation | What survives |
12
+ |---|---|---|
13
+ | **Layered lockup** ([composition.md](composition.md) §3): type and image sharing grid cells | Stack, but keep a *deliberate small overlap*: headline pulls `-mb-[0.5em]` over the image top (or image pulls under the headline's last line). Drop `mix-blend-difference` if the overlap zone gets busy at small width; a `pointer-events-none` scrim or plain ink type is more legible | the trespass |
14
+ | **Asymmetric 12-col rows** (features as editorial rows, [anti-patterns.md](anti-patterns.md) §2) | Single column with **alternating indents** (`even:pl-[10vw]`) or a hanging eyebrow column (`grid-cols-[5rem_1fr]`). Never centered | the off-axis rhythm |
15
+ | **Section bleed / seam** ([composition.md](composition.md) §1) | Keep it: negative margins are width-independent. Reduce overlap depth ~50% (`-mb-16` not `-mb-32`) so the seam doesn't eat a whole viewport | the trespass |
16
+ | **Full-bleed breakout** ([composition.md](composition.md) §2) | The `content-grid` collapses gracefully (side lanes hit their 1.5rem floor), but full-bleed only *contrasts* if contained content is visibly inset. Keep body at the `content` lane; never let everything go edge-to-edge | contained vs escaping |
17
+ | **Off-canvas ghost type** ([composition.md](composition.md) §4) | Shrink to ~24-30vw and keep the crop, or drop it. At 18vw-of-360px it's a smudge: if it can't be huge, it shouldn't exist | scale contrast (or nothing) |
18
+ | **Hero composition** | Poster logic still applies: left-aligned h1 (centered mobile heroes = template), meta pushed to the hero's edges, media bleeding off one side rather than politely contained | the poster edges |
19
+ | **Sticky-stack cards** ([depth-parallax.md](depth-parallax.md) §3) | **Keep**: sticky stacking is touch-native. Cards go `min-h-[100svh]` → content-height + `top` offset; keep the recede scrub | everything |
20
+ | **Pinned / horizontal showpieces** | Never pin on touch. The §4 matchMedia + scroll-snap fallback in [pinning-scrub.md](pinning-scrub.md) is the law, not a suggestion | the content, natively |
21
+ | **Multi-speed parallax** ([depth-parallax.md](depth-parallax.md) §1) | Halve the deltas (0.95/1/1.06) or drop to window-parallax only, since small screens amplify relative motion | one breathing layer |
22
+ | **Footer-reveal** ([composition.md](composition.md) §5) | Keep if the footer fits `~60svh` at mobile width; otherwise release to normal flow (`fixed` + tall footers = covered content) | the close |
23
+
24
+ ---
25
+
26
+ ## 2. Mobile rhythm: the numbers
27
+
28
+ Section padding compresses to roughly **60% of the desktop role values**, keeping the *ratio* between roles (the asymmetry is the design; the absolute air is negotiable):
29
+
30
+ | Role | Desktop | Mobile |
31
+ |---|---|---|
32
+ | Hero | 12-20rem | 8-12rem |
33
+ | Dense content | 6-8rem | 4-5rem |
34
+ | Breather | 16-24rem | 10-14rem |
35
+
36
+ - Express the compression **structurally** (`py-16 md:py-28`, a layout decision and therefore allowed) or via a `--section-pad` token per role. This is not a size ladder, it's one deliberate rhythm change.
37
+ - **Display type floor:** hero h1 bottoms out at 2.875rem (46px at 390), or the pairing's own floor ([typography.md](typography.md) §3.1). The `text-display` token already handles it; check the *line count* instead: a mobile hero h1 over 4 lines needs shorter copy ([page-architecture.md](page-architecture.md) §5), not smaller type.
38
+ - Body measure self-solves via the root knob; check `min-h-[100vh]` sections use `svh` so mobile browser chrome doesn't cause jumps.
39
+ - Horizontal padding: 1.25 to 1.5rem lanes; keep at least one element per screen *ignoring* them (a bleed, a crop) so mobile keeps its grid-breaks too.
40
+
41
+ ---
42
+
43
+ ## 3. What to cut vs keep
44
+
45
+ **Cut on touch (already hover-gated; verify, don't assume):** magnetic buttons, cursor followers, hover scenes ([micro-interactions.md](micro-interactions.md) gates them via `(hover: hover)`). Sibling-dimming and tag-slides need a non-hover story: default-visible tags, or active-state on tap.
46
+
47
+ **Keep, tuned:**
48
+ - Reveals and staggers: shorten travel (`y: 24` not 80) and durations ~15%; small screens exaggerate motion.
49
+ - Split-line headlines: `autoSplit` re-splits at mobile line breaks for free ([scroll-reveals.md](scroll-reveals.md) §3).
50
+ - The departure scrub ([pinning-scrub.md](pinning-scrub.md) §3): no pin, so it's touch-safe as-is.
51
+ - Lenis: keep mounted (the provider doctrine, [setup.md](setup.md) §2). It applies lightly or not at all to native touch scrolling, and the ScrollTrigger wiring stays consistent.
52
+
53
+ **Branch declaratively**, one `gsap.matchMedia()` block per component, the same pattern as reduced-motion ([setup.md](setup.md) §4). Never `if (window.innerWidth < 768)` at setup time: it doesn't revert on rotate/resize.
54
+
55
+ ---
56
+
57
+ ## 4. The two-viewport check, plus 768 where columns change
58
+
59
+ Design every section at **1440 and 390 simultaneously**, not desktop-then-shrink. Then check **768** on every section whose column count changes at a breakpoint: `md` is 768px wide, and a 12-column split there gives a 5-column text lane about 280px wide, where an `h2` breaks into one word per line and an email input shrinks to nothing. Rule of thumb: **text-plus-media splits start at `lg`**, not `md`; between 768 and 1023 they stay stacked or go to an asymmetric single column. Offsets on revealed elements use padding or margin (`even:pl-[10vw]`), never a `translate-*` utility, which GSAP erases ([pitfalls.md](pitfalls.md) §3.2).
60
+
61
+ Before moving on, ask of the 390 view:
62
+
63
+ 1. Is anything overlapping, bleeding, or cropped, or did it collapse to a centered stack?
64
+ 2. Do two consecutive sections still have different skeletons?
65
+ 3. Is the display type still *display* (≥3× body scale contrast)?
66
+ 4. Can a thumb reach/operate everything (48px targets, no pin traps)?
67
+ 5. Does the showpiece content still exist (as snap-scroll or stacked list), even though the showpiece mechanics don't?
68
+
69
+ **Cheap tell:** a mobile view where every section is a centered full-width column: same skeleton repeating, overlaps gone, ghost type gone, hero centered. The art direction evaporated below 768px.
70
+ **Instead:** The 390px page still has trespassing elements, alternating indents, one crop, poster-edge heroes, and compressed-but-asymmetric rhythm. It is recognizably the same design, recomposed.