vectorvesper 2.0.7 → 2.1.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.
package/dist/hooks.json
CHANGED
|
@@ -1,17 +1,700 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "1.0.0",
|
|
3
3
|
"engineVersion": "1.0.2",
|
|
4
|
-
"generatedAt": "2026-08-
|
|
5
|
-
"contractLevel": "
|
|
4
|
+
"generatedAt": "2026-08-09T14:27:32.779Z",
|
|
5
|
+
"contractLevel": "full",
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
|
-
"
|
|
8
|
+
"name": "AdaptiveQuality",
|
|
9
|
+
"importFrom": "@vectorvesper/motion",
|
|
10
|
+
"packageName": "@vectorvesper/motion",
|
|
11
|
+
"since": "0.2.0",
|
|
12
|
+
"category": "core",
|
|
13
|
+
"tier": "free",
|
|
14
|
+
"aliases": [
|
|
15
|
+
"getAdaptiveQuality",
|
|
16
|
+
"device detection",
|
|
17
|
+
"gpu tier",
|
|
18
|
+
"low-end device",
|
|
19
|
+
"device capability",
|
|
20
|
+
"webgl detection",
|
|
21
|
+
"reduced motion",
|
|
22
|
+
"quality signal"
|
|
23
|
+
],
|
|
24
|
+
"exports": [
|
|
25
|
+
"getAdaptiveQuality"
|
|
26
|
+
],
|
|
27
|
+
"tagline": "Device floor fused with live frame health — the core accessor.",
|
|
28
|
+
"problem": "Imperative scene code — a three.js renderer, a shader compiler, a Framer code component — needs the complete quality verdict without a React tree to host the hook.",
|
|
29
|
+
"summary": "getAdaptiveQuality() returns the fusion singleton. subscribe(fn) fires with the current state immediately, then whenever the live budget tier changes; .state answers one-off checks. The effective tier is max(deviceTier, budgetTier) — the device floor is probed lazily on first use (WebGL2, renderer class, memory, cores) and never changes while the page is open; the budget half tracks live frame health. reducedMotion is carried in the same state. In React, prefer the useAdaptiveQuality hook.",
|
|
30
|
+
"mechanism": "First use runs a one-time probe: create a WebGL2 context, read the unmasked renderer string, release the context, and score it with conservative zero-dependency heuristics — no WebGL2 or a software renderer floors the device at the lowest tier, a mobile-class GPU floors it one below top, and constrained memory or few cores push one tier worse. Every verdict is recorded as a human-readable reason. The floor exists because measurement alone oscillates on weak devices: degrade, frames recover because you degraded, upgrade, jank, forever. Fusion takes the worse of floor and live budget; listeners are notified when the budget side changes tier.",
|
|
31
|
+
"signature": "getAdaptiveQuality().subscribe(fn: (s: AdaptiveState) => void): () => void · .state: AdaptiveState",
|
|
32
|
+
"options": [],
|
|
33
|
+
"returns": [
|
|
34
|
+
{
|
|
35
|
+
"name": "state",
|
|
36
|
+
"type": "AdaptiveState",
|
|
37
|
+
"description": "{ tier, label, deviceTier, budgetTier, reasons: string[], reducedMotion }. Consume tier — it is already the most restrictive verdict. reasons is for HUDs and support."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"runtime": {
|
|
41
|
+
"lane": null,
|
|
42
|
+
"priority": null,
|
|
43
|
+
"requiresClient": true,
|
|
44
|
+
"ssrSafeImport": true,
|
|
45
|
+
"respectsReducedMotion": false,
|
|
46
|
+
"reRendersPerFrame": 0,
|
|
47
|
+
"usesPointer": false,
|
|
48
|
+
"usesScroll": false,
|
|
49
|
+
"usesWebGL": true,
|
|
50
|
+
"ownsTransform": false,
|
|
51
|
+
"sharedSingletons": [
|
|
52
|
+
"AnimationBudget",
|
|
53
|
+
"AdaptiveQuality"
|
|
54
|
+
],
|
|
55
|
+
"conflictsWith": [],
|
|
56
|
+
"pairsWith": [
|
|
57
|
+
"useAdaptiveQuality",
|
|
58
|
+
"AnimationBudget",
|
|
59
|
+
"useSafeToMount"
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"quickStart": "import { getAdaptiveQuality } from \"@vectorvesper/motion\";\n\nconst stop = getAdaptiveQuality().subscribe((q) => {\n renderer.setPixelRatio(q.tier === 0 ? window.devicePixelRatio : 1);\n bloomPass.enabled = q.tier === 0 && !q.reducedMotion;\n});\n\n// stop() on teardown.",
|
|
63
|
+
"recipes": [
|
|
64
|
+
{
|
|
65
|
+
"name": "Sizing a scene before it exists",
|
|
66
|
+
"blurb": "One .state read at construction time — no subscription needed for a static choice.",
|
|
67
|
+
"code": "import { getAdaptiveQuality } from \"@vectorvesper/motion\";\n\nexport function particleCountForThisDevice(): number {\n const { tier, reducedMotion } = getAdaptiveQuality().state;\n if (reducedMotion) return 0;\n return tier === 0 ? 5000 : tier === 1 ? 1500 : 300;\n}"
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"dos": [
|
|
71
|
+
"Consume the fused tier, not the halves — each half alone is wrong in a known way.",
|
|
72
|
+
"Honor reducedMotion independently of tier; it is a preference, not a performance state.",
|
|
73
|
+
"Use reasons in diagnostics HUDs, never in user-facing copy."
|
|
74
|
+
],
|
|
75
|
+
"donts": [
|
|
76
|
+
"Don't re-derive your own verdict from deviceTier and budgetTier.",
|
|
77
|
+
"Don't call the probe path in SSR — subscribe and .state are client-only."
|
|
78
|
+
],
|
|
79
|
+
"whenNotToUse": [
|
|
80
|
+
{
|
|
81
|
+
"when": "You are in React and want conditional rendering.",
|
|
82
|
+
"instead": "useAdaptiveQuality — same singleton with lifecycle handled."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"when": "You only need a reduced-motion check.",
|
|
86
|
+
"instead": "window.matchMedia(\"(prefers-reduced-motion: reduce)\") — no probe required."
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"guardrails": [
|
|
90
|
+
"practices/frame-loop-rules.md"
|
|
91
|
+
],
|
|
92
|
+
"docsUrl": "https://vectorvesper.dev/docs/adaptive-quality",
|
|
93
|
+
"labUrl": "https://vectorvesper.dev/docs/adaptive-quality",
|
|
94
|
+
"disclosure": {
|
|
95
|
+
"exposeImplementation": false,
|
|
96
|
+
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
97
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
98
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"name": "AnimationBudget",
|
|
103
|
+
"importFrom": "@vectorvesper/motion",
|
|
104
|
+
"packageName": "@vectorvesper/motion",
|
|
105
|
+
"since": "0.1.0",
|
|
106
|
+
"category": "core",
|
|
107
|
+
"tier": "free",
|
|
108
|
+
"aliases": [
|
|
109
|
+
"getAnimationBudget",
|
|
110
|
+
"fps monitor",
|
|
111
|
+
"performance tier",
|
|
112
|
+
"frame health",
|
|
113
|
+
"degrade quality",
|
|
114
|
+
"quality tier",
|
|
115
|
+
"headroom",
|
|
116
|
+
"budget reset",
|
|
117
|
+
"route change reset"
|
|
118
|
+
],
|
|
119
|
+
"exports": [
|
|
120
|
+
"getAnimationBudget"
|
|
121
|
+
],
|
|
122
|
+
"tagline": "The frame-health governor's core accessor — subscribe, poll, and reset.",
|
|
123
|
+
"problem": "Non-React code needs the live quality tier, frame callbacks need to poll it without React, and SPA navigations need to reset the rolling window — none of which the React hook exposes.",
|
|
124
|
+
"summary": "getAnimationBudget() returns the governor singleton. subscribe(fn) delivers the current state immediately, then tier changes and a ~2Hz heartbeat; it measures only while at least one subscriber exists. .state is the poll path for frame callbacks. reset() discards the rolling window — call it on SPA route changes so a new scene is not judged by the frames the previous one produced. In React, prefer the useAnimationBudget hook for rendering decisions.",
|
|
125
|
+
"mechanism": "A pure hysteresis state machine consumes frame intervals measured on the conductor's input lane at essential priority. It keeps a ninety-frame rolling window, makes no verdict before forty samples, and scores frames against thresholds that are multiples of the measured display refresh interval — so a 120Hz panel limping at 70fps is correctly called slow. Degrading is fast, with a cooldown between steps and a panic path that jumps straight to the lowest tier on a burst of very slow frames; upgrading needs eight seconds of near-clean frames, which is what stops quality flapping. Frames longer than 150ms are treated as tab sleep and reset the window rather than trigger a false panic; returning from a hidden tab resets automatically. Headroom is honest about vsync: while frames land on time, the only attributable consumption is the runtime's own measured work, so headroom is budget minus work; once a frame runs slow, the wall clock is the truth and headroom goes negative by the overrun.",
|
|
126
|
+
"signature": "getAnimationBudget().subscribe(fn: (s: BudgetState) => void): () => void · .state: BudgetState · .reset(): void",
|
|
127
|
+
"options": [],
|
|
128
|
+
"returns": [
|
|
129
|
+
{
|
|
130
|
+
"name": "state",
|
|
131
|
+
"type": "BudgetState",
|
|
132
|
+
"description": "{ tier: 0|1|2, label, avgFrameMs, slowRatio, headroom, workMs, frameBudgetMs }. tier is the verdict; headroom is the leading indicator useSafeToMount gates on."
|
|
133
|
+
}
|
|
134
|
+
],
|
|
135
|
+
"runtime": {
|
|
136
|
+
"lane": "input",
|
|
137
|
+
"priority": "essential",
|
|
138
|
+
"requiresClient": true,
|
|
139
|
+
"ssrSafeImport": true,
|
|
140
|
+
"respectsReducedMotion": false,
|
|
141
|
+
"reRendersPerFrame": 0,
|
|
142
|
+
"usesPointer": false,
|
|
143
|
+
"usesScroll": false,
|
|
144
|
+
"usesWebGL": false,
|
|
145
|
+
"ownsTransform": false,
|
|
146
|
+
"sharedSingletons": [
|
|
147
|
+
"FrameConductor",
|
|
148
|
+
"AnimationBudget"
|
|
149
|
+
],
|
|
150
|
+
"conflictsWith": [],
|
|
151
|
+
"pairsWith": [
|
|
152
|
+
"useAnimationBudget",
|
|
153
|
+
"useSafeToMount",
|
|
154
|
+
"AdaptiveQuality"
|
|
155
|
+
]
|
|
156
|
+
},
|
|
157
|
+
"quickStart": "import { getAnimationBudget } from \"@vectorvesper/motion\";\n\nconst stop = getAnimationBudget().subscribe((budget) => {\n // Fires immediately, then on tier changes and a slow heartbeat.\n scene.setQuality(budget.tier); // 0 high · 1 medium · 2 low\n});\n\n// stop() when the consumer goes away — the governor only measures while subscribed.",
|
|
158
|
+
"recipes": [
|
|
159
|
+
{
|
|
160
|
+
"name": "SPA route change",
|
|
161
|
+
"blurb": "A new scene must not inherit the previous scene's frame history.",
|
|
162
|
+
"code": "import { getAnimationBudget } from \"@vectorvesper/motion\";\n\n// Next.js App Router: call from a top-level effect keyed on pathname.\n\"use client\";\nimport { usePathname } from \"next/navigation\";\nimport { useEffect } from \"react\";\n\nexport function BudgetRouteReset() {\n const pathname = usePathname();\n useEffect(() => { getAnimationBudget().reset(); }, [pathname]);\n return null;\n}"
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"name": "Polling inside frame work",
|
|
166
|
+
"blurb": "Frame callbacks poll .state — never the React hook, never per-frame subscribe.",
|
|
167
|
+
"code": "import { getConductor, getAnimationBudget } from \"@vectorvesper/motion\";\n\nconst off = getConductor().subscribe(\"render\", (dt) => {\n const { tier } = getAnimationBudget().state;\n drawParticles(tier === 0 ? 4000 : tier === 1 ? 1200 : 200, dt);\n}, { priority: \"decorative\", label: \"Particles\" });"
|
|
168
|
+
}
|
|
169
|
+
],
|
|
170
|
+
"dos": [
|
|
171
|
+
"Keep exactly one subscription per consumer and release it — measurement follows subscribers.",
|
|
172
|
+
"Poll .state inside frame callbacks; subscribe for discrete reactions.",
|
|
173
|
+
"Call reset() on SPA navigations."
|
|
174
|
+
],
|
|
175
|
+
"donts": [
|
|
176
|
+
"Don't recreate WebGL contexts on tier changes — adapt the live scene.",
|
|
177
|
+
"Don't treat one heartbeat emission as a change; compare tiers."
|
|
178
|
+
],
|
|
179
|
+
"whenNotToUse": [
|
|
180
|
+
{
|
|
181
|
+
"when": "You are in React and want conditional rendering.",
|
|
182
|
+
"instead": "useAnimationBudget — same singleton, correct consumption pattern."
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"when": "You need a first-paint decision before any frames exist.",
|
|
186
|
+
"instead": "AdaptiveQuality — its device floor answers instantly; this governor is blind for ~40 frames."
|
|
187
|
+
}
|
|
188
|
+
],
|
|
189
|
+
"guardrails": [
|
|
190
|
+
"practices/frame-loop-rules.md"
|
|
191
|
+
],
|
|
192
|
+
"docsUrl": "https://vectorvesper.dev/docs/animation-budget",
|
|
193
|
+
"labUrl": "https://vectorvesper.dev/docs/animation-budget",
|
|
194
|
+
"disclosure": {
|
|
195
|
+
"exposeImplementation": false,
|
|
196
|
+
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
197
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
198
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"name": "FrameConductor",
|
|
203
|
+
"importFrom": "@vectorvesper/motion",
|
|
204
|
+
"packageName": "@vectorvesper/motion",
|
|
205
|
+
"since": "0.1.0",
|
|
206
|
+
"category": "core",
|
|
207
|
+
"tier": "free",
|
|
208
|
+
"aliases": [
|
|
209
|
+
"conductor",
|
|
210
|
+
"getConductor",
|
|
211
|
+
"frame loop",
|
|
212
|
+
"raf",
|
|
213
|
+
"requestAnimationFrame",
|
|
214
|
+
"animation loop",
|
|
215
|
+
"scheduler",
|
|
216
|
+
"ticker",
|
|
217
|
+
"game loop",
|
|
218
|
+
"custom effect",
|
|
219
|
+
"per-frame",
|
|
220
|
+
"frame budget"
|
|
221
|
+
],
|
|
222
|
+
"exports": [
|
|
223
|
+
"getConductor"
|
|
224
|
+
],
|
|
225
|
+
"tagline": "The one requestAnimationFrame loop for the page, with a scheduler inside.",
|
|
226
|
+
"problem": "Every effect that starts its own requestAnimationFrame loop adds another drifting clock, another interleaving of reads and writes, and another callback the browser must run — and ten uncoordinated loops jank where ten coordinated subscribers would not.",
|
|
227
|
+
"summary": "getConductor() returns the page-wide singleton loop that every Vector Vesper primitive runs on. Custom frame work subscribes to one of three ordered lanes — input (read), update (compute), render (write) — and declares a priority so the scheduler can shed decorative work when a frame runs long. The loop starts with its first subscriber, sleeps after its last, and is the ONLY sanctioned way to run per-frame work in a Vector Vesper project.",
|
|
228
|
+
"mechanism": "A lazy module-level singleton wraps one requestAnimationFrame chain. Each tick computes dt from the previous timestamp, clamps it to 100ms so a backgrounded tab waking up cannot feed a multi-second delta into damping math, then walks the three lanes in fixed order: input, update, render. Reads therefore always complete before writes, which is what keeps layout from being recomputed more than once per frame however many effects subscribe.\n\nSubscribers declare a priority. Essential work always runs. Enhanced work is skipped for the remainder of a frame once roughly seventy percent of the frame budget is already spent; decorative work yields at roughly forty-five percent. The budget is the measured display refresh interval — a probe watches real frame timings, so a 120Hz panel is held to its own 8.3ms, not an assumed 60Hz. Shedding is per frame and starvation-guarded: a subscriber skipped four consecutive frames is forced through, so heavy pages degrade ambient work to a lower cadence instead of freezing it.\n\nA subscriber may also declare hz to cap its own cadence. Skipped time is banked and handed over as an accumulated dt on the frame it does run, so frame-rate-independent damping stays mathematically correct at any cadence — a 30Hz background looks identical and costs half.\n\nCost attribution is free by construction: the scheduler must read the clock after each subscriber anyway to know how much frame is left, so the same timestamp powers the shed decision and a per-subscriber cost table (exposed via getStats, rendered by the devtools overlay). One broken subscriber cannot kill the heartbeat: exceptions are caught, reported once per subscriber, and the loop continues.",
|
|
229
|
+
"signature": "getConductor().subscribe(lane: \"input\" | \"update\" | \"render\", fn: (dt: number, time: number) => void, options?: SubscribeOptions): () => void",
|
|
230
|
+
"options": [
|
|
231
|
+
{
|
|
232
|
+
"name": "priority",
|
|
233
|
+
"type": "\"essential\" | \"enhanced\" | \"decorative\"",
|
|
234
|
+
"default": "\"enhanced\"",
|
|
235
|
+
"required": false,
|
|
236
|
+
"description": "Shed order under load. essential never sheds — reserve it for sensors, governors and direct manipulation (a scrub that stutters is a broken scrub). Ambient garnish is decorative."
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"name": "hz",
|
|
240
|
+
"type": "number",
|
|
241
|
+
"default": "— (every frame)",
|
|
242
|
+
"required": false,
|
|
243
|
+
"description": "Cadence cap in runs per second. The dt passed in accumulates across skipped frames, so damping math stays correct at any cadence."
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"name": "label",
|
|
247
|
+
"type": "string",
|
|
248
|
+
"default": "\"anonymous\"",
|
|
249
|
+
"required": false,
|
|
250
|
+
"description": "Name shown in the devtools cost table and slow-subscriber warnings. Always pass one — an unlabeled subscriber is invisible in every diagnostic."
|
|
251
|
+
}
|
|
252
|
+
],
|
|
253
|
+
"returns": [
|
|
254
|
+
{
|
|
255
|
+
"name": "unsubscribe",
|
|
256
|
+
"type": "() => void",
|
|
257
|
+
"description": "Removes the subscriber. Safe to call twice. In React, return it from the effect that subscribed — the loop sleeps when its last subscriber leaves."
|
|
258
|
+
}
|
|
259
|
+
],
|
|
260
|
+
"runtime": {
|
|
261
|
+
"lane": null,
|
|
262
|
+
"priority": null,
|
|
263
|
+
"requiresClient": true,
|
|
264
|
+
"ssrSafeImport": true,
|
|
265
|
+
"respectsReducedMotion": false,
|
|
266
|
+
"reRendersPerFrame": 0,
|
|
267
|
+
"usesPointer": false,
|
|
268
|
+
"usesScroll": false,
|
|
269
|
+
"usesWebGL": false,
|
|
270
|
+
"ownsTransform": false,
|
|
271
|
+
"sharedSingletons": [
|
|
272
|
+
"FrameConductor"
|
|
273
|
+
],
|
|
274
|
+
"conflictsWith": [
|
|
275
|
+
"Private requestAnimationFrame loops — they run outside the budget, invisible to shedding and devtools.",
|
|
276
|
+
"setInterval-driven animation — wrong clock, fights vsync."
|
|
277
|
+
],
|
|
278
|
+
"pairsWith": [
|
|
279
|
+
"useSensorBus",
|
|
280
|
+
"damp",
|
|
281
|
+
"useAnimationBudget"
|
|
282
|
+
]
|
|
283
|
+
},
|
|
284
|
+
"quickStart": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { getConductor, damp } from \"@vectorvesper/motion\";\n\nexport function Breathe() {\n const ref = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n let current = 0;\n return getConductor().subscribe(\"render\", (dt, time) => {\n const target = Math.sin(time * 1.5) * 12;\n current = damp(current, target, 8, dt);\n if (ref.current) ref.current.style.transform = `translate3d(0, ${current}px, 0)`;\n }, { priority: \"decorative\", label: \"Breathe\" });\n }, []);\n\n return <div ref={ref}>V</div>;\n}",
|
|
285
|
+
"recipes": [
|
|
286
|
+
{
|
|
287
|
+
"name": "Read/write split across lanes",
|
|
288
|
+
"blurb": "Measure in input, write in render. Every subscriber's reads then land before any subscriber's writes — layout is computed at most once per frame, page-wide.",
|
|
289
|
+
"code": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { getConductor } from \"@vectorvesper/motion\";\n\nexport function ProgressMeter({ value }: { value: number }) {\n const track = useRef<HTMLDivElement>(null);\n const bar = useRef<HTMLDivElement>(null);\n const valueRef = useRef(value);\n valueRef.current = value;\n\n useEffect(() => {\n let width = 1;\n const offRead = getConductor().subscribe(\"input\", () => {\n const r = track.current?.getBoundingClientRect();\n if (r) width = Math.max(r.width, 1);\n }, { label: \"ProgressMeter(measure)\" });\n\n const offWrite = getConductor().subscribe(\"render\", () => {\n if (bar.current) bar.current.style.transform = `scaleX(${valueRef.current / width})`;\n }, { label: \"ProgressMeter\" });\n\n return () => { offRead(); offWrite(); };\n }, []);\n\n return <div ref={track}><div ref={bar} /></div>;\n}"
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
"name": "Ambient background at half cost",
|
|
293
|
+
"blurb": "Declare hz and decorative priority — the scheduler does the rest.",
|
|
294
|
+
"code": "import { getConductor } from \"@vectorvesper/motion\";\n\nconst off = getConductor().subscribe(\"render\", (dt) => {\n drawAurora(dt); // your canvas draw — dt is banked across skipped frames\n}, { priority: \"decorative\", hz: 30, label: \"AuroraBackground\" });\n\n// Later, when the scene unmounts: off();"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"name": "Development diagnostics",
|
|
298
|
+
"blurb": "configure() and getStats() — find the subscriber eating the frame.",
|
|
299
|
+
"code": "import { getConductor } from \"@vectorvesper/motion\";\n\n// Warn once per subscriber that blows past 4ms on a single frame.\ngetConductor().configure({ slowSubscriberMs: 4 });\n\n// A 2Hz HUD — getStats() allocates, so never call it inside a frame callback.\nsetInterval(() => {\n const stats = getConductor().getStats();\n console.table(stats.subscribers.map((s) => ({\n label: s.label, lane: s.lane, ms: s.costMs.toFixed(2), shed: s.shed,\n })));\n}, 500);"
|
|
300
|
+
}
|
|
301
|
+
],
|
|
302
|
+
"dos": [
|
|
303
|
+
"Subscribe inside an effect and return the unsubscribe function as the cleanup.",
|
|
304
|
+
"Pass a label on every subscription — it is the name in every diagnostic you will ever read.",
|
|
305
|
+
"Keep per-frame values in refs or locals; write results straight to the DOM.",
|
|
306
|
+
"Pick the lane by what the callback does: reads in input, math in update, writes in render.",
|
|
307
|
+
"Declare decorative priority (and an hz cap) for anything the user would not miss for one frame."
|
|
308
|
+
],
|
|
309
|
+
"donts": [
|
|
310
|
+
"Don't call requestAnimationFrame yourself anywhere in a Vector Vesper project.",
|
|
311
|
+
"Don't setState inside a frame callback — that is a React render per frame.",
|
|
312
|
+
"Don't read layout in the render lane; a read after earlier writes forces synchronous layout.",
|
|
313
|
+
"Don't call getStats() inside a frame callback — it allocates; poll it at a human rate.",
|
|
314
|
+
"Don't mark your effect essential to dodge shedding; essential is for sensors, governors and direct manipulation."
|
|
315
|
+
],
|
|
316
|
+
"whenNotToUse": [
|
|
317
|
+
{
|
|
318
|
+
"when": "A hook already expresses the behaviour (magnetic pull, number tick, video scrub, image trail).",
|
|
319
|
+
"instead": "Use the hook. The conductor is the floor custom effects are built on, not a replacement for the primitives already built on it."
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"when": "A one-off entrance, exit, or hover transition on a single element.",
|
|
323
|
+
"instead": "Use a CSS transition or the Web Animations API — the compositor interpolates for free and no frame-loop subscription is needed."
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
"when": "Discrete state changes on events — a click that toggles a class, a form update.",
|
|
327
|
+
"instead": "Use a plain event listener. Per-frame work is for continuous values only."
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"when": "You need pointer or scroll velocity.",
|
|
331
|
+
"instead": "Read it from useSensorBus / getSensorBus — the bus already computes damped derivatives once per frame on the input lane. Computing your own duplicates the work."
|
|
332
|
+
}
|
|
333
|
+
],
|
|
334
|
+
"guardrails": [
|
|
335
|
+
"practices/frame-loop-rules.md",
|
|
336
|
+
"practices/layout-read-discipline.md",
|
|
337
|
+
"practices/react-motion-rules.md"
|
|
338
|
+
],
|
|
339
|
+
"docsUrl": "https://vectorvesper.dev/docs/frame-conductor",
|
|
340
|
+
"labUrl": "https://vectorvesper.dev/docs/frame-conductor",
|
|
341
|
+
"disclosure": {
|
|
342
|
+
"exposeImplementation": false,
|
|
343
|
+
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
344
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
345
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
"name": "SensorBus",
|
|
350
|
+
"importFrom": "@vectorvesper/motion",
|
|
351
|
+
"packageName": "@vectorvesper/motion",
|
|
352
|
+
"since": "0.1.0",
|
|
353
|
+
"category": "core",
|
|
354
|
+
"tier": "free",
|
|
355
|
+
"aliases": [
|
|
356
|
+
"getSensorBus",
|
|
357
|
+
"sensor bus",
|
|
358
|
+
"mouse position",
|
|
359
|
+
"pointer position",
|
|
360
|
+
"pointer velocity",
|
|
361
|
+
"scroll velocity",
|
|
362
|
+
"scroll position",
|
|
363
|
+
"viewport size",
|
|
364
|
+
"device pixel ratio",
|
|
365
|
+
"input listeners"
|
|
366
|
+
],
|
|
367
|
+
"exports": [
|
|
368
|
+
"getSensorBus",
|
|
369
|
+
"SensorBus"
|
|
370
|
+
],
|
|
371
|
+
"tagline": "The shared input singleton — pointer, scroll and viewport, computed once per frame.",
|
|
372
|
+
"problem": "Non-React code and custom effects need the same pointer, scroll and viewport state the hooks read — and attaching their own listeners reintroduces exactly the duplication the bus removes.",
|
|
373
|
+
"summary": "getSensorBus() returns the page-wide input singleton. retain() it (ref-counted — the first consumer attaches the passive listeners, the last release detaches them), then read .state.pointer / .state.scroll / .state.viewport inside conductor frame callbacks. Positions are CSS pixels; velocities are damped, in pixels per second. In React, prefer the useSensorBus hook — it is this accessor plus lifecycle.",
|
|
374
|
+
"mechanism": "One lazily-created instance for the page. Passive pointer listeners write raw coordinates onto plain fields; scroll and viewport are not listened to at all but sampled inside the frame tick, which catches programmatic scrolls that fire no event. All derivatives are computed once per frame in the conductor's input lane at essential priority, so every consumer in update or render reads values produced this frame. Velocity is an instantaneous delta smoothed with frame-rate-independent exponential damping at k = 14. The state objects are live and mutated in place — the stable reference is why nothing re-renders and why retaining a copy goes stale immediately. Ref-counting means fifty consumers cost one set of listeners, and the last release detaches everything and zeroes velocities.",
|
|
375
|
+
"signature": "getSensorBus(): SensorBus · bus.retain(): () => void · bus.state: SensorState",
|
|
376
|
+
"options": [],
|
|
377
|
+
"returns": [
|
|
378
|
+
{
|
|
379
|
+
"name": "state.pointer",
|
|
380
|
+
"type": "{ x, y, vx, vy, speed, down, seen }",
|
|
381
|
+
"description": "Client-space position (CSS px), damped velocity (px/s), speed magnitude, primary-button state, and seen — false until the first movement; check it before positioning anything."
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"name": "state.scroll",
|
|
385
|
+
"type": "{ x, y, vx, vy }",
|
|
386
|
+
"description": "window scroll position sampled per frame, plus damped velocity in px/s."
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
"name": "state.viewport",
|
|
390
|
+
"type": "{ width, height, dpr }",
|
|
391
|
+
"description": "innerWidth / innerHeight / devicePixelRatio, resynced every frame."
|
|
392
|
+
}
|
|
393
|
+
],
|
|
394
|
+
"runtime": {
|
|
395
|
+
"lane": "input",
|
|
396
|
+
"priority": "essential",
|
|
397
|
+
"requiresClient": true,
|
|
398
|
+
"ssrSafeImport": true,
|
|
399
|
+
"respectsReducedMotion": false,
|
|
400
|
+
"reRendersPerFrame": 0,
|
|
401
|
+
"usesPointer": true,
|
|
402
|
+
"usesScroll": true,
|
|
403
|
+
"usesWebGL": false,
|
|
404
|
+
"ownsTransform": false,
|
|
405
|
+
"sharedSingletons": [
|
|
406
|
+
"FrameConductor",
|
|
407
|
+
"SensorBus"
|
|
408
|
+
],
|
|
409
|
+
"conflictsWith": [],
|
|
410
|
+
"pairsWith": [
|
|
411
|
+
"FrameConductor",
|
|
412
|
+
"damp",
|
|
413
|
+
"usePointerIntent"
|
|
414
|
+
]
|
|
415
|
+
},
|
|
416
|
+
"quickStart": "import { getSensorBus, getConductor } from \"@vectorvesper/motion\";\n\n// Framework-agnostic: works in vanilla JS, Vue, Svelte, or a Framer code component.\nconst release = getSensorBus().retain();\n\nconst off = getConductor().subscribe(\"render\", () => {\n const { x, y, seen } = getSensorBus().state.pointer;\n if (seen) document.documentElement.style.setProperty(\"--pointer\", `${x}px ${y}px`);\n}, { label: \"PointerVar\" });\n\n// Teardown when the consumer goes away:\n// off(); release();",
|
|
417
|
+
"recipes": [
|
|
418
|
+
{
|
|
419
|
+
"name": "Vue integration",
|
|
420
|
+
"blurb": "The hook is only a lifecycle wrapper — any framework's mount/unmount works.",
|
|
421
|
+
"code": "import { getSensorBus, getConductor, damp } from \"@vectorvesper/motion\";\nimport { onMounted, onUnmounted } from \"vue\";\n\nexport function usePointerGlow(el: () => HTMLElement | null) {\n let cleanup = () => {};\n onMounted(() => {\n const release = getSensorBus().retain();\n let glow = 0;\n const off = getConductor().subscribe(\"render\", (dt) => {\n const { speed } = getSensorBus().state.pointer;\n glow = damp(glow, Math.min(speed / 1200, 1), 10, dt);\n el()?.style.setProperty(\"--glow\", glow.toFixed(3));\n }, { priority: \"decorative\", label: \"PointerGlow\" });\n cleanup = () => { off(); release(); };\n });\n onUnmounted(() => cleanup());\n}"
|
|
422
|
+
}
|
|
423
|
+
],
|
|
424
|
+
"dos": [
|
|
425
|
+
"retain() before reading, and keep the release function — listeners follow the last consumer out.",
|
|
426
|
+
"Read state fields fresh inside each frame callback; the objects are live.",
|
|
427
|
+
"Check pointer.seen before deriving anything from coordinates."
|
|
428
|
+
],
|
|
429
|
+
"donts": [
|
|
430
|
+
"Don't spread or store bus.state as a snapshot — a copy goes stale the next frame.",
|
|
431
|
+
"Don't mutate the state objects.",
|
|
432
|
+
"Don't attach your own pointermove/scroll listeners alongside the bus."
|
|
433
|
+
],
|
|
434
|
+
"whenNotToUse": [
|
|
435
|
+
{
|
|
436
|
+
"when": "You are in React.",
|
|
437
|
+
"instead": "Use the useSensorBus hook — same singleton, lifecycle handled."
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
"when": "One element needs one discrete event.",
|
|
441
|
+
"instead": "A plain event listener. The bus is for continuous per-frame values."
|
|
442
|
+
}
|
|
443
|
+
],
|
|
444
|
+
"guardrails": [
|
|
445
|
+
"practices/frame-loop-rules.md"
|
|
446
|
+
],
|
|
447
|
+
"docsUrl": "https://vectorvesper.dev/docs/sensor-bus",
|
|
448
|
+
"labUrl": "https://vectorvesper.dev/docs/sensor-bus",
|
|
449
|
+
"disclosure": {
|
|
450
|
+
"exposeImplementation": false,
|
|
451
|
+
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
452
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
453
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
"name": "damp",
|
|
458
|
+
"importFrom": "@vectorvesper/motion",
|
|
459
|
+
"packageName": "@vectorvesper/motion",
|
|
460
|
+
"since": "0.1.0",
|
|
461
|
+
"category": "core",
|
|
462
|
+
"tier": "free",
|
|
463
|
+
"aliases": [
|
|
464
|
+
"lerp",
|
|
465
|
+
"smoothing",
|
|
466
|
+
"easing",
|
|
467
|
+
"interpolation",
|
|
468
|
+
"exponential decay",
|
|
469
|
+
"smooth follow",
|
|
470
|
+
"clamp",
|
|
471
|
+
"clamp01",
|
|
472
|
+
"rayRectIntersect",
|
|
473
|
+
"math helpers"
|
|
474
|
+
],
|
|
475
|
+
"exports": [
|
|
476
|
+
"damp",
|
|
477
|
+
"clamp01",
|
|
478
|
+
"rayRectIntersect"
|
|
479
|
+
],
|
|
480
|
+
"tagline": "Frame-rate-independent smoothing, a clamp, and a trajectory-intersection test.",
|
|
481
|
+
"problem": "The lerp everyone writes — current += (target - current) * 0.1 per frame — runs twice as fast at 120Hz as at 60Hz, so the interaction that felt right on the build machine feels wrong on half the devices that open it.",
|
|
482
|
+
"summary": "damp(current, target, k, dt) moves a value toward its target with exponential decay that is identical at any frame rate, because dt is part of the math. k is responsiveness per second — higher arrives faster; 6–14 covers most UI feels. clamp01 clamps to [0,1]. rayRectIntersect casts a point and velocity against a rectangle and returns time-to-impact in seconds, or null — the primitive behind pointer-intent prediction, usable for your own.",
|
|
483
|
+
"mechanism": "damp computes current plus the remaining distance scaled by one minus e to the minus k dt — the closed-form solution of exponential decay over an arbitrary timestep. Because the decay constant is applied through dt, thirty large steps and a hundred and twenty small ones land on the same curve, which is exactly what a fixed-factor lerp gets wrong. It pairs with the conductor's dt, including the banked dt handed to hz-throttled subscribers, which is why a 30Hz decorative effect keeps the same feel. rayRectIntersect is a standard slab test: it returns the earliest positive time at which the ray enters the rectangle, or null when the trajectory misses — inputs are client-space position, velocity in pixels per second, and a rect; the result divided into a horizon gives an approach-confidence curve.",
|
|
484
|
+
"signature": "damp(current: number, target: number, k: number, dt: number): number · clamp01(v: number): number · rayRectIntersect(x, y, vx, vy, rect: RectLike): number | null",
|
|
485
|
+
"options": [
|
|
486
|
+
{
|
|
487
|
+
"name": "k",
|
|
488
|
+
"type": "number",
|
|
489
|
+
"default": "—",
|
|
490
|
+
"required": true,
|
|
491
|
+
"description": "Responsiveness per second. 4–6 is languid, 8–12 is standard UI follow, 14+ is snappy. Double k is roughly half the settling time."
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
"name": "dt",
|
|
495
|
+
"type": "number",
|
|
496
|
+
"default": "—",
|
|
497
|
+
"required": true,
|
|
498
|
+
"description": "Seconds since this callback last ran. Always pass the conductor's dt — never a constant."
|
|
499
|
+
}
|
|
500
|
+
],
|
|
501
|
+
"returns": [
|
|
502
|
+
{
|
|
503
|
+
"name": "value",
|
|
504
|
+
"type": "number",
|
|
505
|
+
"description": "The smoothed next value. Feed it back as `current` on the next frame."
|
|
506
|
+
}
|
|
507
|
+
],
|
|
508
|
+
"runtime": {
|
|
509
|
+
"lane": null,
|
|
510
|
+
"priority": null,
|
|
511
|
+
"requiresClient": false,
|
|
512
|
+
"ssrSafeImport": true,
|
|
513
|
+
"respectsReducedMotion": false,
|
|
514
|
+
"reRendersPerFrame": 0,
|
|
515
|
+
"usesPointer": false,
|
|
516
|
+
"usesScroll": false,
|
|
517
|
+
"usesWebGL": false,
|
|
518
|
+
"ownsTransform": false,
|
|
519
|
+
"sharedSingletons": [],
|
|
520
|
+
"conflictsWith": [],
|
|
521
|
+
"pairsWith": [
|
|
522
|
+
"FrameConductor",
|
|
523
|
+
"SensorBus"
|
|
524
|
+
]
|
|
525
|
+
},
|
|
526
|
+
"quickStart": "import { getConductor, damp } from \"@vectorvesper/motion\";\n\nlet x = 0;\nlet targetX = 0;\n\nwindow.addEventListener(\"pointermove\", (e) => { targetX = e.clientX; }, { passive: true });\n\nconst off = getConductor().subscribe(\"render\", (dt) => {\n x = damp(x, targetX, 10, dt);\n el.style.transform = `translate3d(${x}px, 0, 0)`;\n}, { label: \"Follower\" });",
|
|
527
|
+
"recipes": [
|
|
528
|
+
{
|
|
529
|
+
"name": "Approach prediction with rayRectIntersect",
|
|
530
|
+
"blurb": "The same math usePointerIntent uses, applied to your own target — time-to-impact against a rect, turned into a 0..1 confidence.",
|
|
531
|
+
"code": "import { getConductor, getSensorBus, rayRectIntersect, clamp01, damp } from \"@vectorvesper/motion\";\n\nconst release = getSensorBus().retain();\nconst HORIZON = 0.5; // seconds of look-ahead\nlet confidence = 0;\n\nconst off = getConductor().subscribe(\"update\", (dt) => {\n const { pointer } = getSensorBus().state;\n const rect = target.getBoundingClientRect();\n const tHit = pointer.seen\n ? rayRectIntersect(pointer.x, pointer.y, pointer.vx, pointer.vy, rect)\n : null;\n const goal = tHit !== null && tHit <= HORIZON ? clamp01(1 - tHit / HORIZON) : 0;\n confidence = damp(confidence, goal, 10, dt);\n}, { label: \"ApproachConfidence\" });"
|
|
532
|
+
}
|
|
533
|
+
],
|
|
534
|
+
"dos": [
|
|
535
|
+
"Always pass the conductor's dt through — that is the whole point.",
|
|
536
|
+
"Snap to the target when the remaining distance is imperceptible (< ~0.01) to end the decay tail.",
|
|
537
|
+
"Keep the running value in a local or ref between frames."
|
|
538
|
+
],
|
|
539
|
+
"donts": [
|
|
540
|
+
"Don't substitute a fixed factor per frame; that reintroduces frame-rate dependence.",
|
|
541
|
+
"Don't use damp for one-shot transitions with a known end time — that is a tween, use CSS or WAAPI."
|
|
542
|
+
],
|
|
543
|
+
"whenNotToUse": [
|
|
544
|
+
{
|
|
545
|
+
"when": "A transition with a fixed duration and easing curve.",
|
|
546
|
+
"instead": "CSS transitions or the Web Animations API — damp has no duration, only a feel."
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
"when": "Physics with overshoot and bounce.",
|
|
550
|
+
"instead": "damp is pure decay and never overshoots. Use a spring model (or a springy CSS linear() easing) when the design calls for bounce."
|
|
551
|
+
}
|
|
552
|
+
],
|
|
553
|
+
"guardrails": [
|
|
554
|
+
"practices/frame-loop-rules.md"
|
|
555
|
+
],
|
|
556
|
+
"docsUrl": "https://vectorvesper.dev/docs/frame-conductor",
|
|
557
|
+
"labUrl": "https://vectorvesper.dev/docs/frame-conductor",
|
|
558
|
+
"disclosure": {
|
|
559
|
+
"exposeImplementation": false,
|
|
560
|
+
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
561
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
562
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
"name": "mountDevtools",
|
|
567
|
+
"importFrom": "@vectorvesper/motion/devtools",
|
|
568
|
+
"packageName": "@vectorvesper/motion",
|
|
569
|
+
"since": "0.3.0",
|
|
570
|
+
"category": "core",
|
|
571
|
+
"tier": "free",
|
|
572
|
+
"aliases": [
|
|
573
|
+
"devtools",
|
|
574
|
+
"overlay",
|
|
575
|
+
"fps meter",
|
|
576
|
+
"profiler",
|
|
577
|
+
"frame cost",
|
|
578
|
+
"performance hud",
|
|
579
|
+
"debug panel",
|
|
580
|
+
"stats panel"
|
|
581
|
+
],
|
|
582
|
+
"exports": [
|
|
583
|
+
"mountDevtools"
|
|
584
|
+
],
|
|
585
|
+
"tagline": "A live overlay showing which effect is spending the frame.",
|
|
586
|
+
"problem": "\"One rAF loop\" and \"decorative work sheds under load\" are claims until you can watch the numbers move — and browser profilers cannot attribute frame cost per effect.",
|
|
587
|
+
"summary": "mountDevtools() docks a small panel showing the runtime's vitals — fps against the measured display rate, the runtime's own work per frame, headroom, the live tier — and a table of every conductor subscriber with its lane, priority, and smoothed per-frame cost in milliseconds, most expensive first. It is a plain DOM function on a separate entry point: call it from any framework or the console, and it never reaches a bundle that does not explicitly import it.",
|
|
588
|
+
"mechanism": "The panel renders into a shadow root so page styles cannot reach in and its styles cannot leak out. It refreshes at five hertz by reading the conductor's stats snapshot — the same per-subscriber cost the scheduler already measures for shed decisions, so the overlay adds no per-frame instrumentation of its own. Nodes are built once and updated through textContent, and the panel lists itself in its own table rather than hiding its cost. Subscriber rows show the label each subscription registered with, which is why passing a label is a rule everywhere else in this manifest.",
|
|
589
|
+
"signature": "mountDevtools(options?: DevtoolsOptions): () => void",
|
|
590
|
+
"options": [
|
|
591
|
+
{
|
|
592
|
+
"name": "position",
|
|
593
|
+
"type": "\"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\"",
|
|
594
|
+
"default": "\"bottom-right\"",
|
|
595
|
+
"required": false,
|
|
596
|
+
"description": "Which corner to dock in."
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
"name": "expanded",
|
|
600
|
+
"type": "boolean",
|
|
601
|
+
"default": "true",
|
|
602
|
+
"required": false,
|
|
603
|
+
"description": "Start with the subscriber table open; collapsed shows a one-line fps readout."
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
"name": "hz",
|
|
607
|
+
"type": "number",
|
|
608
|
+
"default": "5",
|
|
609
|
+
"required": false,
|
|
610
|
+
"description": "Panel refresh rate — a human cadence, never per frame."
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
"name": "container",
|
|
614
|
+
"type": "HTMLElement",
|
|
615
|
+
"default": "document.body",
|
|
616
|
+
"required": false,
|
|
617
|
+
"description": "Node to attach the host element to."
|
|
618
|
+
}
|
|
619
|
+
],
|
|
620
|
+
"returns": [
|
|
621
|
+
{
|
|
622
|
+
"name": "unmount",
|
|
623
|
+
"type": "() => void",
|
|
624
|
+
"description": "Removes the panel and its refresh timer."
|
|
625
|
+
}
|
|
626
|
+
],
|
|
627
|
+
"runtime": {
|
|
628
|
+
"lane": null,
|
|
629
|
+
"priority": null,
|
|
630
|
+
"requiresClient": true,
|
|
631
|
+
"ssrSafeImport": true,
|
|
632
|
+
"respectsReducedMotion": false,
|
|
633
|
+
"reRendersPerFrame": 0,
|
|
634
|
+
"usesPointer": false,
|
|
635
|
+
"usesScroll": false,
|
|
636
|
+
"usesWebGL": false,
|
|
637
|
+
"ownsTransform": false,
|
|
638
|
+
"sharedSingletons": [
|
|
639
|
+
"FrameConductor",
|
|
640
|
+
"AnimationBudget"
|
|
641
|
+
],
|
|
642
|
+
"conflictsWith": [],
|
|
643
|
+
"pairsWith": [
|
|
644
|
+
"FrameConductor",
|
|
645
|
+
"useAnimationBudget"
|
|
646
|
+
]
|
|
647
|
+
},
|
|
648
|
+
"quickStart": "\"use client\";\nimport { useEffect } from \"react\";\nimport { mountDevtools } from \"@vectorvesper/motion/devtools\";\n\nexport function MotionDevtools() {\n useEffect(() => {\n if (process.env.NODE_ENV === \"production\") return;\n return mountDevtools();\n }, []);\n return null;\n}",
|
|
649
|
+
"recipes": [
|
|
650
|
+
{
|
|
651
|
+
"name": "Console spot-check",
|
|
652
|
+
"blurb": "On any page running the runtime, paste one line to see the table.",
|
|
653
|
+
"code": "const { mountDevtools } = await import(\"@vectorvesper/motion/devtools\");\nconst off = mountDevtools({ position: \"top-right\" });\n// off() to remove."
|
|
654
|
+
}
|
|
655
|
+
],
|
|
656
|
+
"dos": [
|
|
657
|
+
"Gate it behind a development check — it is honest overhead.",
|
|
658
|
+
"Label every conductor subscription; unlabeled rows all read \"anonymous\".",
|
|
659
|
+
"Read \"work vs frame\": small work with a large frame means the cost is outside the runtime."
|
|
660
|
+
],
|
|
661
|
+
"donts": [
|
|
662
|
+
"Don't ship it to production bundles.",
|
|
663
|
+
"Don't use it as a user-facing fps meter — build a HUD from the stats accessors instead."
|
|
664
|
+
],
|
|
665
|
+
"whenNotToUse": [
|
|
666
|
+
{
|
|
667
|
+
"when": "Profiling React commits, layout, or GC.",
|
|
668
|
+
"instead": "The browser's own performance panel — this overlay attributes conductor subscribers only."
|
|
669
|
+
}
|
|
670
|
+
],
|
|
671
|
+
"guardrails": [
|
|
672
|
+
"practices/frame-loop-rules.md"
|
|
673
|
+
],
|
|
674
|
+
"docsUrl": "https://vectorvesper.dev/docs/devtools",
|
|
675
|
+
"labUrl": "https://vectorvesper.dev/docs/devtools",
|
|
676
|
+
"disclosure": {
|
|
677
|
+
"exposeImplementation": false,
|
|
678
|
+
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
679
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
680
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
{
|
|
9
684
|
"name": "useAdaptiveQuality",
|
|
10
685
|
"importFrom": "@vectorvesper/motion/react",
|
|
11
686
|
"packageName": "@vectorvesper/motion",
|
|
12
687
|
"since": "0.1.0",
|
|
13
688
|
"category": "governor",
|
|
14
689
|
"tier": "free",
|
|
690
|
+
"aliases": [
|
|
691
|
+
"adaptive quality",
|
|
692
|
+
"device tier",
|
|
693
|
+
"quality tier",
|
|
694
|
+
"low end device",
|
|
695
|
+
"gpu detection",
|
|
696
|
+
"progressive enhancement"
|
|
697
|
+
],
|
|
15
698
|
"tagline": "Device capability fused with live frame health — the complete quality signal.",
|
|
16
699
|
"problem": "Measured frame health is the truth but is blind for the first seconds; device signals guess instantly but never learn. Using either alone gets one of those wrong.",
|
|
17
700
|
"summary": "Probes the device once (WebGL2, GPU renderer string, memory, cores, reduced-motion) and fuses that with the live AnimationBudget as `tier = max(deviceTier, budgetTier)`. The device tier is a FLOOR, and that is the whole point: without it, weak devices oscillate forever — degrade, frames recover because you degraded, upgrade, jank, degrade. A software renderer stays a software renderer. `reasons` explains the device verdict.",
|
|
@@ -50,7 +733,53 @@
|
|
|
50
733
|
"description": "Whether the user asked for reduced motion."
|
|
51
734
|
}
|
|
52
735
|
],
|
|
736
|
+
"runtime": {
|
|
737
|
+
"lane": "input",
|
|
738
|
+
"priority": "essential",
|
|
739
|
+
"requiresClient": true,
|
|
740
|
+
"ssrSafeImport": true,
|
|
741
|
+
"respectsReducedMotion": true,
|
|
742
|
+
"reRendersPerFrame": 0,
|
|
743
|
+
"usesPointer": false,
|
|
744
|
+
"usesScroll": false,
|
|
745
|
+
"usesWebGL": true,
|
|
746
|
+
"ownsTransform": false,
|
|
747
|
+
"sharedSingletons": [
|
|
748
|
+
"FrameConductor",
|
|
749
|
+
"AnimationBudget",
|
|
750
|
+
"AdaptiveQuality"
|
|
751
|
+
],
|
|
752
|
+
"conflictsWith": [],
|
|
753
|
+
"pairsWith": [
|
|
754
|
+
"useAnimationBudget",
|
|
755
|
+
"useLazyScene",
|
|
756
|
+
"useSafeToMount"
|
|
757
|
+
]
|
|
758
|
+
},
|
|
53
759
|
"quickStart": "\"use client\";\nimport { useAdaptiveQuality } from \"@vectorvesper/motion/react\";\n\nexport function Scene() {\n const { tier, reducedMotion } = useAdaptiveQuality();\n if (reducedMotion) return <Poster />;\n return tier === 0 ? <FullScene /> : <LightScene />;\n}",
|
|
760
|
+
"recipes": [
|
|
761
|
+
{
|
|
762
|
+
"name": "Hero Background",
|
|
763
|
+
"blurb": "Resolve tier into a real settings object once, then hand it to the renderer. The settings — not the tier number — are what your canvas actually consumes.",
|
|
764
|
+
"code": "\"use client\";\nimport { useAdaptiveQuality } from \"@vectorvesper/motion/react\";\n\nconst SETTINGS = {\n 0: { dpr: 2, particles: 220, glow: true, linkLines: true },\n 1: { dpr: 1, particles: 90, glow: false, linkLines: false },\n 2: { dpr: 1, particles: 34, glow: false, linkLines: false },\n} as const;\n\nexport function AuroraHero() {\n const { tier, reducedMotion } = useAdaptiveQuality();\n if (reducedMotion) return <StaticGradient />;\n return <AuroraCanvas settings={SETTINGS[tier]} />;\n}"
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
"name": "Explaining the verdict",
|
|
768
|
+
"blurb": "Surface `reasons` in a debug panel — it turns support tickets into one screenshot.",
|
|
769
|
+
"code": "\"use client\";\nimport { useAdaptiveQuality } from \"@vectorvesper/motion/react\";\n\nexport function QualityBadge() {\n const { label, deviceTier, budgetTier, reasons } = useAdaptiveQuality();\n const clampedBy = deviceTier > budgetTier ? \"device\" : budgetTier > deviceTier ? \"load\" : \"neither\";\n return (\n <div className=\"font-mono text-xs\">\n {label} · clamped by {clampedBy} · {reasons.join(\", \")}\n </div>\n );\n}"
|
|
770
|
+
}
|
|
771
|
+
],
|
|
772
|
+
"dos": [
|
|
773
|
+
"Consume `tier`, not `deviceTier` or `budgetTier` — the fused value is the answer.",
|
|
774
|
+
"Always branch on `reducedMotion` before anything else; it is a user request, not a performance signal.",
|
|
775
|
+
"Resolve tier into a settings object in one place, so the ladder is auditable.",
|
|
776
|
+
"Show `reasons` in a dev HUD — it is the fastest way to understand a slow device report."
|
|
777
|
+
],
|
|
778
|
+
"donts": [
|
|
779
|
+
"Don't expect the device probe to re-run. It happens once per page; a thermally throttled phone will not be re-classified.",
|
|
780
|
+
"Don't call it inside a frame loop.",
|
|
781
|
+
"Don't treat tier 2 as broken — it is a working page with the decoration removed."
|
|
782
|
+
],
|
|
54
783
|
"whenNotToUse": [
|
|
55
784
|
{
|
|
56
785
|
"when": "You have no WebGL, canvas or heavy effects on the page.",
|
|
@@ -65,42 +794,34 @@
|
|
|
65
794
|
"instead": "Use `useAnimationBudget`. This hook deliberately clamps to a device floor and will not report tier 0 on a machine it judged weak, no matter how clean the frames are."
|
|
66
795
|
}
|
|
67
796
|
],
|
|
797
|
+
"guardrails": [
|
|
798
|
+
"practices/adaptive-performance.md",
|
|
799
|
+
"practices/webgl-budget-and-failopen.md"
|
|
800
|
+
],
|
|
68
801
|
"docsUrl": "https://vectorvesper.dev/docs/adaptive-quality",
|
|
69
802
|
"labUrl": "https://vectorvesper.dev/lab/adaptive-quality",
|
|
70
803
|
"disclosure": {
|
|
71
804
|
"exposeImplementation": false,
|
|
72
805
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
73
806
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
74
|
-
"readingSource": "
|
|
75
|
-
}
|
|
76
|
-
"runtime": {
|
|
77
|
-
"requiresClient": true,
|
|
78
|
-
"ssrSafeImport": true,
|
|
79
|
-
"respectsReducedMotion": true,
|
|
80
|
-
"ownsTransform": false,
|
|
81
|
-
"conflictsWith": [],
|
|
82
|
-
"usesPointer": false,
|
|
83
|
-
"usesScroll": false,
|
|
84
|
-
"usesWebGL": true,
|
|
85
|
-
"lane": "input",
|
|
86
|
-
"priority": "essential",
|
|
87
|
-
"reRendersPerFrame": 0,
|
|
88
|
-
"sharedSingletons": [
|
|
89
|
-
"FrameConductor",
|
|
90
|
-
"AnimationBudget",
|
|
91
|
-
"AdaptiveQuality"
|
|
92
|
-
]
|
|
93
|
-
},
|
|
94
|
-
"upgrade": "This is the lean contract: enough to understand the hook and use it correctly. The full contract adds named real-world recipes with complete code, the accumulated dos and don'ts, and routing into the motion guardrails. Available to Vector Vesper members — authenticate the CLI with `npx vectorvesper login <token>`."
|
|
807
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
808
|
+
}
|
|
95
809
|
},
|
|
96
810
|
{
|
|
97
|
-
"contractLevel": "lean",
|
|
98
811
|
"name": "useAnimationBudget",
|
|
99
812
|
"importFrom": "@vectorvesper/motion/react",
|
|
100
813
|
"packageName": "@vectorvesper/motion",
|
|
101
814
|
"since": "0.1.0",
|
|
102
815
|
"category": "governor",
|
|
103
816
|
"tier": "free",
|
|
817
|
+
"aliases": [
|
|
818
|
+
"frame budget",
|
|
819
|
+
"fps",
|
|
820
|
+
"performance tier",
|
|
821
|
+
"degrade quality",
|
|
822
|
+
"shed effects",
|
|
823
|
+
"jank detection"
|
|
824
|
+
],
|
|
104
825
|
"tagline": "A live quality tier from measured frame health, so effects can shed themselves.",
|
|
105
826
|
"problem": "A page looks fine on the machine it was built on and janks on the device it ships to, and nothing in the code knows the difference.",
|
|
106
827
|
"summary": "Measures real frame intervals on the shared conductor and collapses them into a coarse tier every effect can consume. Hysteresis is asymmetric on purpose: degrading is fast (users feel jank within a second), recovering takes 8 seconds of clean frames, so quality never flaps. Since v0.3 the thresholds are relative to the measured display refresh rate, so 70fps on a 120Hz panel correctly reads as degraded.",
|
|
@@ -144,7 +865,58 @@
|
|
|
144
865
|
"description": "One presented frame at the detected display rate."
|
|
145
866
|
}
|
|
146
867
|
],
|
|
868
|
+
"runtime": {
|
|
869
|
+
"lane": "input",
|
|
870
|
+
"priority": "essential",
|
|
871
|
+
"requiresClient": true,
|
|
872
|
+
"ssrSafeImport": true,
|
|
873
|
+
"respectsReducedMotion": false,
|
|
874
|
+
"reRendersPerFrame": 0,
|
|
875
|
+
"usesPointer": false,
|
|
876
|
+
"usesScroll": false,
|
|
877
|
+
"usesWebGL": false,
|
|
878
|
+
"ownsTransform": false,
|
|
879
|
+
"sharedSingletons": [
|
|
880
|
+
"FrameConductor",
|
|
881
|
+
"AnimationBudget"
|
|
882
|
+
],
|
|
883
|
+
"conflictsWith": [],
|
|
884
|
+
"pairsWith": [
|
|
885
|
+
"useAdaptiveQuality",
|
|
886
|
+
"useSafeToMount",
|
|
887
|
+
"useLazyScene"
|
|
888
|
+
]
|
|
889
|
+
},
|
|
147
890
|
"quickStart": "\"use client\";\nimport { useAnimationBudget } from \"@vectorvesper/motion/react\";\n\nexport function Hero() {\n const { tier } = useAnimationBudget();\n return (\n <section>\n {tier === 0 && <ExpensiveParticleLayer />}\n <HeroContent />\n </section>\n );\n}",
|
|
891
|
+
"recipes": [
|
|
892
|
+
{
|
|
893
|
+
"name": "Storefront Grid",
|
|
894
|
+
"blurb": "Shed decoration in cost order and never touch content. The image, title, price and buy button are not on the list at any tier.",
|
|
895
|
+
"code": "\"use client\";\nimport { useAnimationBudget } from \"@vectorvesper/motion/react\";\n\nexport function ProductCard({ product }: { product: Product }) {\n const { tier } = useAnimationBudget();\n return (\n <article className={tier < 1 ? \"backdrop-blur-xl\" : \"bg-neutral-900\"}>\n {tier === 0 && <span className=\"shimmer-sweep\" aria-hidden />}\n <img src={product.image} alt={product.name} />\n <h3>{product.name}</h3>\n <p>{product.price}</p>\n <button>Add to cart</button>\n </article>\n );\n}"
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
"name": "Telemetry HUD",
|
|
899
|
+
"blurb": "Show the numbers during development. Emits on tier change plus a ~2Hz heartbeat.",
|
|
900
|
+
"code": "\"use client\";\nimport { useAnimationBudget } from \"@vectorvesper/motion/react\";\n\nexport function PerfHud() {\n const { label, avgFrameMs, workMs, headroom } = useAnimationBudget();\n return (\n <div className=\"fixed bottom-3 right-3 font-mono text-xs\">\n {label} · {avgFrameMs.toFixed(1)}ms frame · {workMs.toFixed(2)}ms ours · {headroom.toFixed(1)}ms free\n </div>\n );\n}"
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
"name": "Reading it inside a frame loop",
|
|
904
|
+
"blurb": "Never call the hook per frame — poll the singleton instead.",
|
|
905
|
+
"code": "import { getAnimationBudget, getConductor } from \"@vectorvesper/motion\";\n\nconst budget = getAnimationBudget();\nconst release = budget.subscribe(() => {}); // keeps it measuring\n\nconst off = getConductor().subscribe(\"render\", () => {\n const particles = budget.state.tier === 0 ? 220 : 40;\n draw(particles);\n}, { priority: \"decorative\", label: \"Particles\" });"
|
|
906
|
+
}
|
|
907
|
+
],
|
|
908
|
+
"dos": [
|
|
909
|
+
"Use it for conditional rendering of expensive subtrees — that is the intended consumption pattern.",
|
|
910
|
+
"Treat tier as a ladder: shed the most expensive decoration first.",
|
|
911
|
+
"Call `getAnimationBudget().reset()` on SPA route changes so a new scene isn't judged by the old one's frames.",
|
|
912
|
+
"Remember the governor only measures while something is subscribed."
|
|
913
|
+
],
|
|
914
|
+
"donts": [
|
|
915
|
+
"Don't call this hook inside a rAF callback — poll `getAnimationBudget().state` there.",
|
|
916
|
+
"Don't degrade content. Text, images, prices and controls are never decoration.",
|
|
917
|
+
"Don't build your own thresholds off `avgFrameMs`; the tier already has hysteresis you would be fighting.",
|
|
918
|
+
"Don't expect instant recovery — an upgrade needs 8 seconds of clean frames, deliberately."
|
|
919
|
+
],
|
|
148
920
|
"whenNotToUse": [
|
|
149
921
|
{
|
|
150
922
|
"when": "You want to gate a one-time expensive mount.",
|
|
@@ -159,41 +931,34 @@
|
|
|
159
931
|
"instead": "Use a plain `matchMedia(\"(prefers-reduced-motion: reduce)\")` check. Starting a frame-measurement loop for an accessibility preference is disproportionate."
|
|
160
932
|
}
|
|
161
933
|
],
|
|
934
|
+
"guardrails": [
|
|
935
|
+
"practices/adaptive-performance.md",
|
|
936
|
+
"practices/frame-loop-rules.md"
|
|
937
|
+
],
|
|
162
938
|
"docsUrl": "https://vectorvesper.dev/docs/animation-budget",
|
|
163
939
|
"labUrl": "https://vectorvesper.dev/lab/animation-budget",
|
|
164
940
|
"disclosure": {
|
|
165
941
|
"exposeImplementation": false,
|
|
166
942
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
167
943
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
168
|
-
"readingSource": "
|
|
169
|
-
}
|
|
170
|
-
"runtime": {
|
|
171
|
-
"requiresClient": true,
|
|
172
|
-
"ssrSafeImport": true,
|
|
173
|
-
"respectsReducedMotion": false,
|
|
174
|
-
"ownsTransform": false,
|
|
175
|
-
"conflictsWith": [],
|
|
176
|
-
"usesPointer": false,
|
|
177
|
-
"usesScroll": false,
|
|
178
|
-
"usesWebGL": false,
|
|
179
|
-
"lane": "input",
|
|
180
|
-
"priority": "essential",
|
|
181
|
-
"reRendersPerFrame": 0,
|
|
182
|
-
"sharedSingletons": [
|
|
183
|
-
"FrameConductor",
|
|
184
|
-
"AnimationBudget"
|
|
185
|
-
]
|
|
186
|
-
},
|
|
187
|
-
"upgrade": "This is the lean contract: enough to understand the hook and use it correctly. The full contract adds named real-world recipes with complete code, the accumulated dos and don'ts, and routing into the motion guardrails. Available to Vector Vesper members — authenticate the CLI with `npx vectorvesper login <token>`."
|
|
944
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
945
|
+
}
|
|
188
946
|
},
|
|
189
947
|
{
|
|
190
|
-
"contractLevel": "lean",
|
|
191
948
|
"name": "useImageTrail",
|
|
192
949
|
"importFrom": "@vectorvesper/motion/react",
|
|
193
950
|
"packageName": "@vectorvesper/motion",
|
|
194
951
|
"since": "0.1.0",
|
|
195
952
|
"category": "effect",
|
|
196
953
|
"tier": "free",
|
|
954
|
+
"aliases": [
|
|
955
|
+
"image trail",
|
|
956
|
+
"cursor trail",
|
|
957
|
+
"mouse trail",
|
|
958
|
+
"trail effect",
|
|
959
|
+
"gallery flourish",
|
|
960
|
+
"images follow cursor"
|
|
961
|
+
],
|
|
197
962
|
"tagline": "Images spawn along the pointer's path and fade — the award-site gallery flourish.",
|
|
198
963
|
"problem": "The tutorial version creates a DOM node per pointer move and lets the garbage collector deal with it, which is exactly the allocation pattern that produces stutter.",
|
|
199
964
|
"summary": "A fixed pool of `<img>` nodes is created once and recycled. Each flight is animated with the Web Animations API, so there is no rAF loop and nothing is allocated per move — the oldest flight is simply cancelled and reused. Spawns are distance-gated, not event-gated, so the spacing stays even at any pointer speed. Fails open (does nothing) on touch devices and under reduced motion.",
|
|
@@ -229,21 +994,65 @@
|
|
|
229
994
|
"description": "Flight duration in ms."
|
|
230
995
|
},
|
|
231
996
|
{
|
|
232
|
-
"name": "maxActive",
|
|
233
|
-
"type": "number",
|
|
234
|
-
"default": "10",
|
|
235
|
-
"required": false,
|
|
236
|
-
"description": "Pool size, i.e. maximum simultaneously visible images. This is a hard ceiling on cost."
|
|
997
|
+
"name": "maxActive",
|
|
998
|
+
"type": "number",
|
|
999
|
+
"default": "10",
|
|
1000
|
+
"required": false,
|
|
1001
|
+
"description": "Pool size, i.e. maximum simultaneously visible images. This is a hard ceiling on cost."
|
|
1002
|
+
}
|
|
1003
|
+
],
|
|
1004
|
+
"returns": [
|
|
1005
|
+
{
|
|
1006
|
+
"name": "ref",
|
|
1007
|
+
"type": "RefObject<T | null>",
|
|
1008
|
+
"description": "Attach to the container. The hook sets its position and overflow, and restores both on unmount."
|
|
1009
|
+
}
|
|
1010
|
+
],
|
|
1011
|
+
"runtime": {
|
|
1012
|
+
"lane": null,
|
|
1013
|
+
"priority": null,
|
|
1014
|
+
"requiresClient": true,
|
|
1015
|
+
"ssrSafeImport": true,
|
|
1016
|
+
"respectsReducedMotion": true,
|
|
1017
|
+
"reRendersPerFrame": 0,
|
|
1018
|
+
"usesPointer": true,
|
|
1019
|
+
"usesScroll": false,
|
|
1020
|
+
"usesWebGL": false,
|
|
1021
|
+
"ownsTransform": false,
|
|
1022
|
+
"sharedSingletons": [],
|
|
1023
|
+
"conflictsWith": [
|
|
1024
|
+
"A container with `overflow: visible` required — the hook sets overflow hidden to clip the trail."
|
|
1025
|
+
],
|
|
1026
|
+
"pairsWith": [
|
|
1027
|
+
"useAdaptiveQuality",
|
|
1028
|
+
"usePointerIntent"
|
|
1029
|
+
]
|
|
1030
|
+
},
|
|
1031
|
+
"quickStart": "\"use client\";\nimport { useImageTrail } from \"@vectorvesper/motion/react\";\n\nexport function Gallery() {\n const { ref } = useImageTrail<HTMLElement>({\n images: [\"/a.jpg\", \"/b.jpg\", \"/c.jpg\"],\n });\n return <section ref={ref} className=\"relative h-[70vh]\">Move your cursor</section>;\n}",
|
|
1032
|
+
"recipes": [
|
|
1033
|
+
{
|
|
1034
|
+
"name": "Hero Background",
|
|
1035
|
+
"blurb": "A full-bleed hero where the trail sits behind the headline.",
|
|
1036
|
+
"code": "\"use client\";\nimport { useImageTrail } from \"@vectorvesper/motion/react\";\n\nconst SHOTS = [\"/work/1.webp\", \"/work/2.webp\", \"/work/3.webp\", \"/work/4.webp\"];\n\nexport function TrailHero() {\n const { ref } = useImageTrail<HTMLElement>({ images: SHOTS, size: 220, spacing: 120 });\n return (\n <section ref={ref} className=\"relative grid h-screen place-items-center\">\n <h1 className=\"pointer-events-none relative z-10 text-6xl font-semibold\">Selected work</h1>\n </section>\n );\n}"
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
"name": "Budget-aware trail",
|
|
1040
|
+
"blurb": "Shrink the pool on weak devices rather than turning the effect off entirely.",
|
|
1041
|
+
"code": "\"use client\";\nimport { useImageTrail, useAdaptiveQuality } from \"@vectorvesper/motion/react\";\n\nexport function AdaptiveTrail({ images }: { images: string[] }) {\n const { tier } = useAdaptiveQuality();\n const { ref } = useImageTrail<HTMLDivElement>({\n images,\n maxActive: tier === 0 ? 12 : tier === 1 ? 6 : 3,\n spacing: tier === 0 ? 90 : 140,\n });\n return <div ref={ref} className=\"relative h-[60vh]\" />;\n}"
|
|
237
1042
|
}
|
|
238
1043
|
],
|
|
239
|
-
"
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
1044
|
+
"dos": [
|
|
1045
|
+
"Preload the images, or serve them small and cached — a cold fetch mid-trail shows as a hole.",
|
|
1046
|
+
"Give the container real height; it is positioned and clipped by the hook.",
|
|
1047
|
+
"Add `pointer-events: none` to overlaid text so the trail keeps receiving moves.",
|
|
1048
|
+
"Keep `maxActive` low. It is the hard ceiling on how expensive this can get."
|
|
1049
|
+
],
|
|
1050
|
+
"donts": [
|
|
1051
|
+
"Don't change the options after mount — they are snapshotted, by design.",
|
|
1052
|
+
"Don't use large PNGs. Ten simultaneous full-resolution images is the failure mode.",
|
|
1053
|
+
"Don't put it on a container that needs `overflow: visible`.",
|
|
1054
|
+
"Don't rely on it for anything meaningful; it is inert on touch and under reduced motion."
|
|
245
1055
|
],
|
|
246
|
-
"quickStart": "\"use client\";\nimport { useImageTrail } from \"@vectorvesper/motion/react\";\n\nexport function Gallery() {\n const { ref } = useImageTrail<HTMLElement>({\n images: [\"/a.jpg\", \"/b.jpg\", \"/c.jpg\"],\n });\n return <section ref={ref} className=\"relative h-[70vh]\">Move your cursor</section>;\n}",
|
|
247
1056
|
"whenNotToUse": [
|
|
248
1057
|
{
|
|
249
1058
|
"when": "You want a single image that follows the cursor.",
|
|
@@ -258,40 +1067,34 @@
|
|
|
258
1067
|
"instead": "Use a real gallery grid. Trail images are decorative, `aria-hidden`, and visible for under a second each."
|
|
259
1068
|
}
|
|
260
1069
|
],
|
|
1070
|
+
"guardrails": [
|
|
1071
|
+
"practices/texture-and-media-gotchas.md",
|
|
1072
|
+
"practices/adaptive-performance.md"
|
|
1073
|
+
],
|
|
261
1074
|
"docsUrl": "https://vectorvesper.dev/docs/image-trail",
|
|
262
1075
|
"labUrl": "https://vectorvesper.dev/lab/image-trail",
|
|
263
1076
|
"disclosure": {
|
|
264
1077
|
"exposeImplementation": false,
|
|
265
1078
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
266
1079
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
267
|
-
"readingSource": "
|
|
268
|
-
}
|
|
269
|
-
"runtime": {
|
|
270
|
-
"requiresClient": true,
|
|
271
|
-
"ssrSafeImport": true,
|
|
272
|
-
"respectsReducedMotion": true,
|
|
273
|
-
"ownsTransform": false,
|
|
274
|
-
"conflictsWith": [
|
|
275
|
-
"A container with `overflow: visible` required — the hook sets overflow hidden to clip the trail."
|
|
276
|
-
],
|
|
277
|
-
"usesPointer": true,
|
|
278
|
-
"usesScroll": false,
|
|
279
|
-
"usesWebGL": false,
|
|
280
|
-
"lane": null,
|
|
281
|
-
"priority": null,
|
|
282
|
-
"reRendersPerFrame": 0,
|
|
283
|
-
"sharedSingletons": []
|
|
284
|
-
},
|
|
285
|
-
"upgrade": "This is the lean contract: enough to understand the hook and use it correctly. The full contract adds named real-world recipes with complete code, the accumulated dos and don'ts, and routing into the motion guardrails. Available to Vector Vesper members — authenticate the CLI with `npx vectorvesper login <token>`."
|
|
1080
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
1081
|
+
}
|
|
286
1082
|
},
|
|
287
1083
|
{
|
|
288
|
-
"contractLevel": "lean",
|
|
289
1084
|
"name": "useLazyScene",
|
|
290
1085
|
"importFrom": "@vectorvesper/motion/react",
|
|
291
1086
|
"packageName": "@vectorvesper/motion",
|
|
292
1087
|
"since": "0.1.0",
|
|
293
1088
|
"category": "gate",
|
|
294
1089
|
"tier": "free",
|
|
1090
|
+
"aliases": [
|
|
1091
|
+
"lazy load",
|
|
1092
|
+
"defer mount",
|
|
1093
|
+
"below the fold",
|
|
1094
|
+
"intersection observer",
|
|
1095
|
+
"heavy scene",
|
|
1096
|
+
"lazy 3d"
|
|
1097
|
+
],
|
|
295
1098
|
"tagline": "Mount a heavy scene when it's near the viewport, the thread is idle, and frames are healthy.",
|
|
296
1099
|
"problem": "A WebGL canvas three screens down initialises during page load, competing with the content the user is actually looking at.",
|
|
297
1100
|
"summary": "Three gates in sequence: an IntersectionObserver with a generous rootMargin so the scene is ready just before it is seen; an idle-callback so it doesn't land mid-scroll; and a scroll-velocity check that defers while the user is actively moving. Optionally waits for the frame budget to recover, with a 3-second fail-open so a permanently busy page still eventually renders. Once mounted it stays mounted.",
|
|
@@ -332,7 +1135,58 @@
|
|
|
332
1135
|
"description": "False until every gate passes, then true for the rest of the mount."
|
|
333
1136
|
}
|
|
334
1137
|
],
|
|
1138
|
+
"runtime": {
|
|
1139
|
+
"lane": "update",
|
|
1140
|
+
"priority": "essential",
|
|
1141
|
+
"requiresClient": true,
|
|
1142
|
+
"ssrSafeImport": true,
|
|
1143
|
+
"respectsReducedMotion": false,
|
|
1144
|
+
"reRendersPerFrame": 0,
|
|
1145
|
+
"usesPointer": false,
|
|
1146
|
+
"usesScroll": true,
|
|
1147
|
+
"usesWebGL": false,
|
|
1148
|
+
"ownsTransform": false,
|
|
1149
|
+
"sharedSingletons": [
|
|
1150
|
+
"FrameConductor",
|
|
1151
|
+
"SensorBus",
|
|
1152
|
+
"AnimationBudget"
|
|
1153
|
+
],
|
|
1154
|
+
"conflictsWith": [],
|
|
1155
|
+
"pairsWith": [
|
|
1156
|
+
"useSafeToMount",
|
|
1157
|
+
"useAdaptiveQuality",
|
|
1158
|
+
"useAnimationBudget"
|
|
1159
|
+
]
|
|
1160
|
+
},
|
|
335
1161
|
"quickStart": "\"use client\";\nimport { useLazyScene } from \"@vectorvesper/motion/react\";\n\nexport function SceneSection() {\n const { ref, ready } = useLazyScene<HTMLDivElement>();\n return (\n <div ref={ref} className=\"min-h-[60vh]\">\n {ready ? <HeavyWebGLCanvas /> : <ScenePoster />}\n </div>\n );\n}",
|
|
1162
|
+
"recipes": [
|
|
1163
|
+
{
|
|
1164
|
+
"name": "Below-the-fold WebGL",
|
|
1165
|
+
"blurb": "The common case. Give the placeholder the scene's real height so nothing shifts.",
|
|
1166
|
+
"code": "\"use client\";\nimport { useLazyScene } from \"@vectorvesper/motion/react\";\n\nexport function ProductShowcase() {\n const { ref, ready } = useLazyScene<HTMLDivElement>({\n rootMargin: \"400px\",\n deferWhileLow: true,\n });\n return (\n <section ref={ref} className=\"h-[80vh] w-full\">\n {ready ? <ProductCanvas /> : <img src=\"/product-poster.webp\" alt=\"\" className=\"size-full object-cover\" />}\n </section>\n );\n}"
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
"name": "Autoplaying video block",
|
|
1170
|
+
"blurb": "Don't start decoding until it's nearly on screen.",
|
|
1171
|
+
"code": "\"use client\";\nimport { useLazyScene } from \"@vectorvesper/motion/react\";\n\nexport function AmbientVideo({ src, poster }: { src: string; poster: string }) {\n const { ref, ready } = useLazyScene<HTMLDivElement>({ rootMargin: \"300px\" });\n return (\n <div ref={ref} className=\"aspect-video\">\n {ready\n ? <video src={src} poster={poster} muted playsInline autoPlay loop className=\"size-full object-cover\" />\n : <img src={poster} alt=\"\" className=\"size-full object-cover\" />}\n </div>\n );\n}"
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
"name": "Combined with a code-split bundle",
|
|
1175
|
+
"blurb": "Lazy-mount and lazy-load are different problems; use both.",
|
|
1176
|
+
"code": "\"use client\";\nimport dynamic from \"next/dynamic\";\nimport { useLazyScene } from \"@vectorvesper/motion/react\";\n\nconst HeavyCanvas = dynamic(() => import(\"./HeavyCanvas\"), { ssr: false });\n\nexport function Section() {\n const { ref, ready } = useLazyScene<HTMLDivElement>();\n return <div ref={ref} className=\"min-h-[70vh]\">{ready && <HeavyCanvas />}</div>;\n}"
|
|
1177
|
+
}
|
|
1178
|
+
],
|
|
1179
|
+
"dos": [
|
|
1180
|
+
"Give the placeholder the same dimensions as the real scene, so mounting shifts nothing.",
|
|
1181
|
+
"Render a real poster, not a spinner — most users will only ever see the placeholder.",
|
|
1182
|
+
"Pair with `next/dynamic` or `React.lazy` so the bundle is deferred too, not just the mount.",
|
|
1183
|
+
"Raise `rootMargin` for slow-initialising scenes so they're ready by the time they're seen."
|
|
1184
|
+
],
|
|
1185
|
+
"donts": [
|
|
1186
|
+
"Don't attach the ref to a zero-height element — the observer will never fire.",
|
|
1187
|
+
"Don't expect options to be live. They are snapshotted on first mount by design.",
|
|
1188
|
+
"Don't use `deferWhileLow` for anything above the fold; a 3-second delay is very visible there."
|
|
1189
|
+
],
|
|
336
1190
|
"whenNotToUse": [
|
|
337
1191
|
{
|
|
338
1192
|
"when": "The content is above the fold.",
|
|
@@ -347,42 +1201,35 @@
|
|
|
347
1201
|
"instead": "Use a bare IntersectionObserver or a CSS scroll-driven animation. This hook is about deferring expensive work, not about revealing things."
|
|
348
1202
|
}
|
|
349
1203
|
],
|
|
1204
|
+
"guardrails": [
|
|
1205
|
+
"practices/adaptive-performance.md",
|
|
1206
|
+
"practices/react-motion-rules.md",
|
|
1207
|
+
"practices/webgl-budget-and-failopen.md"
|
|
1208
|
+
],
|
|
350
1209
|
"docsUrl": "https://vectorvesper.dev/docs/lazy-scene",
|
|
351
1210
|
"labUrl": "https://vectorvesper.dev/lab/lazy-scene",
|
|
352
1211
|
"disclosure": {
|
|
353
1212
|
"exposeImplementation": false,
|
|
354
1213
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
355
1214
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
356
|
-
"readingSource": "
|
|
357
|
-
}
|
|
358
|
-
"runtime": {
|
|
359
|
-
"requiresClient": true,
|
|
360
|
-
"ssrSafeImport": true,
|
|
361
|
-
"respectsReducedMotion": false,
|
|
362
|
-
"ownsTransform": false,
|
|
363
|
-
"conflictsWith": [],
|
|
364
|
-
"usesPointer": false,
|
|
365
|
-
"usesScroll": true,
|
|
366
|
-
"usesWebGL": false,
|
|
367
|
-
"lane": "update",
|
|
368
|
-
"priority": "essential",
|
|
369
|
-
"reRendersPerFrame": 0,
|
|
370
|
-
"sharedSingletons": [
|
|
371
|
-
"FrameConductor",
|
|
372
|
-
"SensorBus",
|
|
373
|
-
"AnimationBudget"
|
|
374
|
-
]
|
|
375
|
-
},
|
|
376
|
-
"upgrade": "This is the lean contract: enough to understand the hook and use it correctly. The full contract adds named real-world recipes with complete code, the accumulated dos and don'ts, and routing into the motion guardrails. Available to Vector Vesper members — authenticate the CLI with `npx vectorvesper login <token>`."
|
|
1215
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
1216
|
+
}
|
|
377
1217
|
},
|
|
378
1218
|
{
|
|
379
|
-
"contractLevel": "lean",
|
|
380
1219
|
"name": "useMagneticIntent",
|
|
381
1220
|
"importFrom": "@vectorvesper/motion/react",
|
|
382
1221
|
"packageName": "@vectorvesper/motion",
|
|
383
1222
|
"since": "0.1.0",
|
|
384
1223
|
"category": "interaction",
|
|
385
1224
|
"tier": "free",
|
|
1225
|
+
"aliases": [
|
|
1226
|
+
"magnetic button",
|
|
1227
|
+
"magnet",
|
|
1228
|
+
"sticky button",
|
|
1229
|
+
"cursor attraction",
|
|
1230
|
+
"button pull",
|
|
1231
|
+
"hover magnet"
|
|
1232
|
+
],
|
|
386
1233
|
"tagline": "A magnetic button that starts reaching before the cursor gets there.",
|
|
387
1234
|
"problem": "Every magnetic-button tutorial reacts to hover, so the element only starts moving once the cursor has already arrived — which is exactly too late to feel alive.",
|
|
388
1235
|
"summary": "Engagement is max(PointerIntent confidence, proximity falloff), so the element begins reaching while the cursor is still on its way — the pre-touch that makes award-site buttons feel intentional. Writes only `transform`, and restores the previous inline transform exactly on unmount. Fails open to a perfectly normal element on touch-only devices and under reduced motion.",
|
|
@@ -437,7 +1284,61 @@
|
|
|
437
1284
|
"description": "Whether the effect actually engaged. False on touch-only devices and under reduced motion."
|
|
438
1285
|
}
|
|
439
1286
|
],
|
|
1287
|
+
"runtime": {
|
|
1288
|
+
"lane": "render",
|
|
1289
|
+
"priority": "enhanced",
|
|
1290
|
+
"requiresClient": true,
|
|
1291
|
+
"ssrSafeImport": true,
|
|
1292
|
+
"respectsReducedMotion": true,
|
|
1293
|
+
"reRendersPerFrame": 0,
|
|
1294
|
+
"usesPointer": true,
|
|
1295
|
+
"usesScroll": false,
|
|
1296
|
+
"usesWebGL": false,
|
|
1297
|
+
"ownsTransform": true,
|
|
1298
|
+
"sharedSingletons": [
|
|
1299
|
+
"FrameConductor",
|
|
1300
|
+
"SensorBus",
|
|
1301
|
+
"PointerIntent"
|
|
1302
|
+
],
|
|
1303
|
+
"conflictsWith": [
|
|
1304
|
+
"Anything else writing the same element's transform — framer-motion `animate`, GSAP tweens, Tailwind `hover:scale-*`, CSS transitions on transform.",
|
|
1305
|
+
"A parent that also transforms during the same interaction, which compounds the offset."
|
|
1306
|
+
],
|
|
1307
|
+
"pairsWith": [
|
|
1308
|
+
"usePointerIntent",
|
|
1309
|
+
"useSensorBus"
|
|
1310
|
+
]
|
|
1311
|
+
},
|
|
440
1312
|
"quickStart": "\"use client\";\nimport { useMagneticIntent } from \"@vectorvesper/motion/react\";\n\nexport function Cta() {\n const { ref } = useMagneticIntent<HTMLButtonElement>();\n return <button ref={ref}>Get started</button>;\n}",
|
|
1313
|
+
"recipes": [
|
|
1314
|
+
{
|
|
1315
|
+
"name": "CTA Button",
|
|
1316
|
+
"blurb": "Wrap the label in an inner span so text can counter-move slightly.",
|
|
1317
|
+
"code": "\"use client\";\nimport { useMagneticIntent } from \"@vectorvesper/motion/react\";\n\nexport function MagneticCta({ children }: { children: React.ReactNode }) {\n const { ref } = useMagneticIntent<HTMLButtonElement>({ strength: 16, reach: 120 });\n return (\n <button ref={ref} className=\"rounded-full px-6 py-3 bg-white text-black\">\n <span className=\"pointer-events-none\">{children}</span>\n </button>\n );\n}"
|
|
1318
|
+
},
|
|
1319
|
+
{
|
|
1320
|
+
"name": "Icon row",
|
|
1321
|
+
"blurb": "Short reach so neighbours don't all lean at once.",
|
|
1322
|
+
"code": "\"use client\";\nimport { useMagneticIntent } from \"@vectorvesper/motion/react\";\n\nfunction MagneticIcon({ children }: { children: React.ReactNode }) {\n const { ref } = useMagneticIntent<HTMLAnchorElement>({ strength: 8, reach: 48, scale: 1.15 });\n return <a ref={ref} className=\"grid size-10 place-items-center\">{children}</a>;\n}"
|
|
1323
|
+
},
|
|
1324
|
+
{
|
|
1325
|
+
"name": "Coexisting with framer-motion",
|
|
1326
|
+
"blurb": "Never let both own the same node. Give framer the wrapper and the magnet the child.",
|
|
1327
|
+
"code": "\"use client\";\nimport { motion } from \"framer-motion\";\nimport { useMagneticIntent } from \"@vectorvesper/motion/react\";\n\nexport function EnteringMagneticCta() {\n const { ref } = useMagneticIntent<HTMLButtonElement>();\n return (\n <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>\n <button ref={ref}>Get started</button>\n </motion.div>\n );\n}"
|
|
1328
|
+
}
|
|
1329
|
+
],
|
|
1330
|
+
"dos": [
|
|
1331
|
+
"Give it its own element. It owns that node's inline transform completely.",
|
|
1332
|
+
"Keep `strength` well under the element's own size, or it detaches from its layout slot.",
|
|
1333
|
+
"Use a wrapper element for entrance animations from another library.",
|
|
1334
|
+
"Check `active` if you need to know whether the effect is running on this device."
|
|
1335
|
+
],
|
|
1336
|
+
"donts": [
|
|
1337
|
+
"Don't apply Tailwind `hover:scale-*`, a CSS transform transition, or a framer `animate` to the same node — last writer per frame wins and it will look broken.",
|
|
1338
|
+
"Don't put it on large elements; the pull reads as drift rather than magnetism.",
|
|
1339
|
+
"Don't use it on anything inside a scrolling list that moves under the cursor.",
|
|
1340
|
+
"Don't rely on it for affordance — the button must look clickable with the effect off."
|
|
1341
|
+
],
|
|
441
1342
|
"whenNotToUse": [
|
|
442
1343
|
{
|
|
443
1344
|
"when": "You want a simple hover lift or scale.",
|
|
@@ -452,45 +1353,35 @@
|
|
|
452
1353
|
"instead": "Use one shared pointer-driven effect over the container. Each instance retains its own PointerIntent, and dozens of independent predictors is the wrong shape."
|
|
453
1354
|
}
|
|
454
1355
|
],
|
|
1356
|
+
"guardrails": [
|
|
1357
|
+
"practices/damping-and-inertia.md",
|
|
1358
|
+
"practices/layout-read-discipline.md",
|
|
1359
|
+
"practices/component-api-conventions.md"
|
|
1360
|
+
],
|
|
455
1361
|
"docsUrl": "https://vectorvesper.dev/docs/magnetic-intent",
|
|
456
1362
|
"labUrl": "https://vectorvesper.dev/lab/magnetic-element",
|
|
457
1363
|
"disclosure": {
|
|
458
1364
|
"exposeImplementation": false,
|
|
459
1365
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
460
1366
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
461
|
-
"readingSource": "
|
|
462
|
-
}
|
|
463
|
-
"runtime": {
|
|
464
|
-
"requiresClient": true,
|
|
465
|
-
"ssrSafeImport": true,
|
|
466
|
-
"respectsReducedMotion": true,
|
|
467
|
-
"ownsTransform": true,
|
|
468
|
-
"conflictsWith": [
|
|
469
|
-
"Anything else writing the same element's transform — framer-motion `animate`, GSAP tweens, Tailwind `hover:scale-*`, CSS transitions on transform.",
|
|
470
|
-
"A parent that also transforms during the same interaction, which compounds the offset."
|
|
471
|
-
],
|
|
472
|
-
"usesPointer": true,
|
|
473
|
-
"usesScroll": false,
|
|
474
|
-
"usesWebGL": false,
|
|
475
|
-
"lane": "render",
|
|
476
|
-
"priority": "enhanced",
|
|
477
|
-
"reRendersPerFrame": 0,
|
|
478
|
-
"sharedSingletons": [
|
|
479
|
-
"FrameConductor",
|
|
480
|
-
"SensorBus",
|
|
481
|
-
"PointerIntent"
|
|
482
|
-
]
|
|
483
|
-
},
|
|
484
|
-
"upgrade": "This is the lean contract: enough to understand the hook and use it correctly. The full contract adds named real-world recipes with complete code, the accumulated dos and don'ts, and routing into the motion guardrails. Available to Vector Vesper members — authenticate the CLI with `npx vectorvesper login <token>`."
|
|
1367
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
1368
|
+
}
|
|
485
1369
|
},
|
|
486
1370
|
{
|
|
487
|
-
"contractLevel": "lean",
|
|
488
1371
|
"name": "useNumberTicker",
|
|
489
1372
|
"importFrom": "@vectorvesper/motion/react",
|
|
490
1373
|
"packageName": "@vectorvesper/motion",
|
|
491
1374
|
"since": "0.1.0",
|
|
492
1375
|
"category": "effect",
|
|
493
1376
|
"tier": "free",
|
|
1377
|
+
"aliases": [
|
|
1378
|
+
"count up",
|
|
1379
|
+
"counter",
|
|
1380
|
+
"animated number",
|
|
1381
|
+
"number animation",
|
|
1382
|
+
"odometer",
|
|
1383
|
+
"stat ticker"
|
|
1384
|
+
],
|
|
494
1385
|
"tagline": "Animate a number to its target without re-rendering anything.",
|
|
495
1386
|
"problem": "The obvious implementation re-renders the component sixty times a second to change one string, dragging every sibling through reconciliation for a counter.",
|
|
496
1387
|
"summary": "Writes `textContent` directly on the target node from the shared frame loop, so React renders exactly once regardless of how far the number travels. Damping is frame-rate-independent, so it feels identical at 30 and 120fps, and it snaps and stops when close enough rather than easing forever. Enforces tabular figures so digits don't wobble the layout, and jumps straight to the target under reduced motion.",
|
|
@@ -540,7 +1431,57 @@
|
|
|
540
1431
|
"description": "Attach to the element that displays the number. Its text content is owned by the hook."
|
|
541
1432
|
}
|
|
542
1433
|
],
|
|
1434
|
+
"runtime": {
|
|
1435
|
+
"lane": "update",
|
|
1436
|
+
"priority": "enhanced",
|
|
1437
|
+
"requiresClient": true,
|
|
1438
|
+
"ssrSafeImport": true,
|
|
1439
|
+
"respectsReducedMotion": true,
|
|
1440
|
+
"reRendersPerFrame": 0,
|
|
1441
|
+
"usesPointer": false,
|
|
1442
|
+
"usesScroll": false,
|
|
1443
|
+
"usesWebGL": false,
|
|
1444
|
+
"ownsTransform": false,
|
|
1445
|
+
"sharedSingletons": [
|
|
1446
|
+
"FrameConductor"
|
|
1447
|
+
],
|
|
1448
|
+
"conflictsWith": [
|
|
1449
|
+
"Rendering children into the same element — the hook overwrites its textContent every frame."
|
|
1450
|
+
],
|
|
1451
|
+
"pairsWith": [
|
|
1452
|
+
"useLazyScene",
|
|
1453
|
+
"useAnimationBudget"
|
|
1454
|
+
]
|
|
1455
|
+
},
|
|
543
1456
|
"quickStart": "\"use client\";\nimport { useNumberTicker } from \"@vectorvesper/motion/react\";\n\nexport function Stat({ value }: { value: number }) {\n const { ref } = useNumberTicker<HTMLSpanElement>(value);\n return <span ref={ref} />;\n}",
|
|
1457
|
+
"recipes": [
|
|
1458
|
+
{
|
|
1459
|
+
"name": "Pricing Toggle",
|
|
1460
|
+
"blurb": "Monthly ↔ annual. The number travels instead of cutting, which sells the difference.",
|
|
1461
|
+
"code": "\"use client\";\nimport { useState } from \"react\";\nimport { useNumberTicker } from \"@vectorvesper/motion/react\";\n\nexport function PriceCard({ monthly, annual }: { monthly: number; annual: number }) {\n const [yearly, setYearly] = useState(false);\n const { ref } = useNumberTicker<HTMLSpanElement>(yearly ? annual : monthly, {\n format: { style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 },\n k: 9,\n });\n return (\n <div>\n <span ref={ref} className=\"text-5xl font-semibold tabular-nums\" />\n <button onClick={() => setYearly((v) => !v)}>{yearly ? \"Annual\" : \"Monthly\"}</button>\n </div>\n );\n}"
|
|
1462
|
+
},
|
|
1463
|
+
{
|
|
1464
|
+
"name": "Stat Row",
|
|
1465
|
+
"blurb": "Count up when the section scrolls into view, not on page load.",
|
|
1466
|
+
"code": "\"use client\";\nimport { useLazyScene, useNumberTicker } from \"@vectorvesper/motion/react\";\n\nfunction Metric({ target, label, suffix }: { target: number; label: string; suffix?: string }) {\n const { ref: seen, ready } = useLazyScene<HTMLDivElement>({ rootMargin: \"0px\" });\n const { ref } = useNumberTicker<HTMLSpanElement>(ready ? target : 0, { suffix });\n return (\n <div ref={seen}>\n <span ref={ref} className=\"text-4xl tabular-nums\" />\n <p className=\"text-sm text-neutral-400\">{label}</p>\n </div>\n );\n}"
|
|
1467
|
+
},
|
|
1468
|
+
{
|
|
1469
|
+
"name": "Live percentage",
|
|
1470
|
+
"blurb": "Fed from real data. One React render per data change, not per frame.",
|
|
1471
|
+
"code": "\"use client\";\nimport { useNumberTicker } from \"@vectorvesper/motion/react\";\n\nexport function Uptime({ ratio }: { ratio: number }) {\n const { ref } = useNumberTicker<HTMLSpanElement>(ratio, {\n format: { style: \"percent\", minimumFractionDigits: 2 },\n });\n return <span ref={ref} />;\n}"
|
|
1472
|
+
}
|
|
1473
|
+
],
|
|
1474
|
+
"dos": [
|
|
1475
|
+
"Give it an empty element — the hook owns the text content entirely.",
|
|
1476
|
+
"Use `format` rather than pre-formatting the value yourself, so the animation stays numeric.",
|
|
1477
|
+
"Start from 0 and set the real value when the section becomes visible, for the count-up effect.",
|
|
1478
|
+
"Keep the element's font tabular; the hook enforces it, and fighting that causes width wobble."
|
|
1479
|
+
],
|
|
1480
|
+
"donts": [
|
|
1481
|
+
"Don't render children inside the element; they are overwritten on the next frame.",
|
|
1482
|
+
"Don't pass a value that changes every frame — the damping already handles the travel.",
|
|
1483
|
+
"Don't use it for anything the user must read exactly while it moves, like a live checkout total."
|
|
1484
|
+
],
|
|
544
1485
|
"whenNotToUse": [
|
|
545
1486
|
{
|
|
546
1487
|
"when": "The number changes once and the animation is decorative.",
|
|
@@ -555,42 +1496,34 @@
|
|
|
555
1496
|
"instead": "Use a `setInterval` and render the formatted string. Those need to be exact at each step, and easing toward a time reads as broken."
|
|
556
1497
|
}
|
|
557
1498
|
],
|
|
1499
|
+
"guardrails": [
|
|
1500
|
+
"practices/react-motion-rules.md",
|
|
1501
|
+
"practices/damping-and-inertia.md"
|
|
1502
|
+
],
|
|
558
1503
|
"docsUrl": "https://vectorvesper.dev/docs/number-ticker",
|
|
559
1504
|
"labUrl": "https://vectorvesper.dev/lab/number-ticker",
|
|
560
1505
|
"disclosure": {
|
|
561
1506
|
"exposeImplementation": false,
|
|
562
1507
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
563
1508
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
564
|
-
"readingSource": "
|
|
565
|
-
}
|
|
566
|
-
"runtime": {
|
|
567
|
-
"requiresClient": true,
|
|
568
|
-
"ssrSafeImport": true,
|
|
569
|
-
"respectsReducedMotion": true,
|
|
570
|
-
"ownsTransform": false,
|
|
571
|
-
"conflictsWith": [
|
|
572
|
-
"Rendering children into the same element — the hook overwrites its textContent every frame."
|
|
573
|
-
],
|
|
574
|
-
"usesPointer": false,
|
|
575
|
-
"usesScroll": false,
|
|
576
|
-
"usesWebGL": false,
|
|
577
|
-
"lane": "update",
|
|
578
|
-
"priority": "enhanced",
|
|
579
|
-
"reRendersPerFrame": 0,
|
|
580
|
-
"sharedSingletons": [
|
|
581
|
-
"FrameConductor"
|
|
582
|
-
]
|
|
583
|
-
},
|
|
584
|
-
"upgrade": "This is the lean contract: enough to understand the hook and use it correctly. The full contract adds named real-world recipes with complete code, the accumulated dos and don'ts, and routing into the motion guardrails. Available to Vector Vesper members — authenticate the CLI with `npx vectorvesper login <token>`."
|
|
1509
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
1510
|
+
}
|
|
585
1511
|
},
|
|
586
1512
|
{
|
|
587
|
-
"contractLevel": "lean",
|
|
588
1513
|
"name": "usePointerIntent",
|
|
589
1514
|
"importFrom": "@vectorvesper/motion/react",
|
|
590
1515
|
"packageName": "@vectorvesper/motion",
|
|
591
1516
|
"since": "0.1.0",
|
|
592
1517
|
"category": "interaction",
|
|
593
1518
|
"tier": "free",
|
|
1519
|
+
"aliases": [
|
|
1520
|
+
"hover intent",
|
|
1521
|
+
"predict hover",
|
|
1522
|
+
"preload on approach",
|
|
1523
|
+
"cursor prediction",
|
|
1524
|
+
"prefetch hover",
|
|
1525
|
+
"anticipate arrival"
|
|
1526
|
+
],
|
|
594
1527
|
"tagline": "Know the pointer is coming 100–300ms before it arrives, and pre-warm.",
|
|
595
1528
|
"problem": "Expensive hover states start their work at the moment of hover, so the first 200ms of every interaction is the user watching something load.",
|
|
596
1529
|
"summary": "Casts the pointer's smoothed trajectory forward from the SensorBus and measures time-to-impact against the element's inflated rect. Confidence rises as impact nears; being inside is confidence 1. Enter/exit hysteresis keeps the boolean calm. Use it to start the video, compile the shader or fetch the preview before the cursor lands.",
|
|
@@ -664,7 +1597,57 @@
|
|
|
664
1597
|
"description": "Smoothed 0..1, updated per frame with no re-render. Read inside frame callbacks."
|
|
665
1598
|
}
|
|
666
1599
|
],
|
|
1600
|
+
"runtime": {
|
|
1601
|
+
"lane": "update",
|
|
1602
|
+
"priority": "enhanced",
|
|
1603
|
+
"requiresClient": true,
|
|
1604
|
+
"ssrSafeImport": true,
|
|
1605
|
+
"respectsReducedMotion": false,
|
|
1606
|
+
"reRendersPerFrame": 0,
|
|
1607
|
+
"usesPointer": true,
|
|
1608
|
+
"usesScroll": false,
|
|
1609
|
+
"usesWebGL": false,
|
|
1610
|
+
"ownsTransform": false,
|
|
1611
|
+
"sharedSingletons": [
|
|
1612
|
+
"FrameConductor",
|
|
1613
|
+
"SensorBus"
|
|
1614
|
+
],
|
|
1615
|
+
"conflictsWith": [],
|
|
1616
|
+
"pairsWith": [
|
|
1617
|
+
"useSensorBus",
|
|
1618
|
+
"useMagneticIntent",
|
|
1619
|
+
"useVideoScrubber"
|
|
1620
|
+
]
|
|
1621
|
+
},
|
|
667
1622
|
"quickStart": "\"use client\";\nimport { usePointerIntent } from \"@vectorvesper/motion/react\";\n\nexport function PreviewCard() {\n const { ref, intent } = usePointerIntent<HTMLDivElement>();\n return (\n <article ref={ref}>\n {intent && <WarmVideoPreview />} {/* mounts before the cursor arrives */}\n <CardContent />\n </article>\n );\n}",
|
|
1623
|
+
"recipes": [
|
|
1624
|
+
{
|
|
1625
|
+
"name": "CTA Button",
|
|
1626
|
+
"blurb": "Pre-fetch the destination while the cursor is still travelling.",
|
|
1627
|
+
"code": "\"use client\";\nimport { useRouter } from \"next/navigation\";\nimport { usePointerIntent } from \"@vectorvesper/motion/react\";\n\nexport function PrefetchingCta({ href, children }: { href: string; children: React.ReactNode }) {\n const router = useRouter();\n const { ref } = usePointerIntent<HTMLAnchorElement>({\n horizon: 0.4,\n onChange: (coming) => { if (coming) router.prefetch(href); },\n });\n return <a ref={ref} href={href}>{children}</a>;\n}"
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
"name": "Card Grid",
|
|
1631
|
+
"blurb": "Warm one preview at a time. Each card predicts independently.",
|
|
1632
|
+
"code": "\"use client\";\nimport { usePointerIntent } from \"@vectorvesper/motion/react\";\n\nexport function VideoCard({ src, poster }: { src: string; poster: string }) {\n const { ref, intent } = usePointerIntent<HTMLDivElement>({ extend: 24 });\n return (\n <div ref={ref} className=\"relative\">\n <img src={poster} alt=\"\" />\n {intent && <video src={src} muted playsInline preload=\"auto\" autoPlay loop />}\n </div>\n );\n}"
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
"name": "Confidence-driven glow",
|
|
1636
|
+
"blurb": "Use the analog value, not the boolean, for anything continuous.",
|
|
1637
|
+
"code": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { usePointerIntent } from \"@vectorvesper/motion/react\";\nimport { getConductor } from \"@vectorvesper/motion\";\n\nexport function GlowCard() {\n const glowRef = useRef<HTMLDivElement>(null);\n const { ref, confidenceRef } = usePointerIntent<HTMLDivElement>();\n\n useEffect(() => getConductor().subscribe(\"render\", () => {\n if (glowRef.current) glowRef.current.style.opacity = String(confidenceRef.current);\n }, { priority: \"decorative\", label: \"GlowCard\" }), [confidenceRef]);\n\n return (\n <div ref={ref} className=\"relative\">\n <div ref={glowRef} className=\"absolute inset-0 bg-white/20 blur-2xl opacity-0\" />\n </div>\n );\n}"
|
|
1638
|
+
}
|
|
1639
|
+
],
|
|
1640
|
+
"dos": [
|
|
1641
|
+
"Use `intent` for mounting decisions and `confidenceRef` for anything continuous.",
|
|
1642
|
+
"Keep `exit` below `enter` — that gap is the hysteresis that stops flicker.",
|
|
1643
|
+
"Turn on `dynamic` only for targets that actually move; it costs a layout read per frame while approaching.",
|
|
1644
|
+
"Make the warmed thing idempotent — intent can fire, drop, and fire again."
|
|
1645
|
+
],
|
|
1646
|
+
"donts": [
|
|
1647
|
+
"Don't do anything irreversible or user-visible on intent. It is a prediction and it will be wrong sometimes.",
|
|
1648
|
+
"Don't use it for anything that costs money or fires analytics.",
|
|
1649
|
+
"Don't stack it on a huge element — a full-width hero is always 'about to be entered'."
|
|
1650
|
+
],
|
|
668
1651
|
"whenNotToUse": [
|
|
669
1652
|
{
|
|
670
1653
|
"when": "The hover state is cheap — a colour change, an underline, a small transform.",
|
|
@@ -679,41 +1662,35 @@
|
|
|
679
1662
|
"instead": "Use `onMouseEnter`. This hook fires on approach by design and will fire for pointers that pass by without landing."
|
|
680
1663
|
}
|
|
681
1664
|
],
|
|
1665
|
+
"guardrails": [
|
|
1666
|
+
"practices/damping-and-inertia.md",
|
|
1667
|
+
"practices/layout-read-discipline.md",
|
|
1668
|
+
"practices/frame-loop-rules.md"
|
|
1669
|
+
],
|
|
682
1670
|
"docsUrl": "https://vectorvesper.dev/docs/pointer-intent",
|
|
683
1671
|
"labUrl": "https://vectorvesper.dev/lab/pointer-intent",
|
|
684
1672
|
"disclosure": {
|
|
685
1673
|
"exposeImplementation": false,
|
|
686
1674
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
687
1675
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
688
|
-
"readingSource": "
|
|
689
|
-
}
|
|
690
|
-
"runtime": {
|
|
691
|
-
"requiresClient": true,
|
|
692
|
-
"ssrSafeImport": true,
|
|
693
|
-
"respectsReducedMotion": false,
|
|
694
|
-
"ownsTransform": false,
|
|
695
|
-
"conflictsWith": [],
|
|
696
|
-
"usesPointer": true,
|
|
697
|
-
"usesScroll": false,
|
|
698
|
-
"usesWebGL": false,
|
|
699
|
-
"lane": "update",
|
|
700
|
-
"priority": "enhanced",
|
|
701
|
-
"reRendersPerFrame": 0,
|
|
702
|
-
"sharedSingletons": [
|
|
703
|
-
"FrameConductor",
|
|
704
|
-
"SensorBus"
|
|
705
|
-
]
|
|
706
|
-
},
|
|
707
|
-
"upgrade": "This is the lean contract: enough to understand the hook and use it correctly. The full contract adds named real-world recipes with complete code, the accumulated dos and don'ts, and routing into the motion guardrails. Available to Vector Vesper members — authenticate the CLI with `npx vectorvesper login <token>`."
|
|
1676
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
1677
|
+
}
|
|
708
1678
|
},
|
|
709
1679
|
{
|
|
710
|
-
"contractLevel": "lean",
|
|
711
1680
|
"name": "useSafeToMount",
|
|
712
1681
|
"importFrom": "@vectorvesper/motion/react",
|
|
713
1682
|
"packageName": "@vectorvesper/motion",
|
|
714
1683
|
"since": "0.1.0",
|
|
715
1684
|
"category": "gate",
|
|
716
1685
|
"tier": "free",
|
|
1686
|
+
"aliases": [
|
|
1687
|
+
"defer mount",
|
|
1688
|
+
"headroom gate",
|
|
1689
|
+
"mount gate",
|
|
1690
|
+
"hydration safe",
|
|
1691
|
+
"when to mount",
|
|
1692
|
+
"idle mount"
|
|
1693
|
+
],
|
|
717
1694
|
"tagline": "Hold an expensive mount until the main thread can actually absorb it.",
|
|
718
1695
|
"problem": "An expensive component mounts during hydration, when the thread is already saturated, and its cost lands on top of the worst moment of the page's life.",
|
|
719
1696
|
"summary": "Watches the frame loop and returns `true` only after a run of frames with real headroom to spare. One-way: once true it never goes back to false, because a component that unmounted itself the moment it made the page slow would oscillate forever. A hardware core-count floor short-circuits the whole check on very weak devices.",
|
|
@@ -744,12 +1721,56 @@
|
|
|
744
1721
|
],
|
|
745
1722
|
"returns": [
|
|
746
1723
|
{
|
|
747
|
-
"name": "safe",
|
|
748
|
-
"type": "boolean",
|
|
749
|
-
"description": "False until the thread is measurably calm, then true forever within this mount."
|
|
1724
|
+
"name": "safe",
|
|
1725
|
+
"type": "boolean",
|
|
1726
|
+
"description": "False until the thread is measurably calm, then true forever within this mount."
|
|
1727
|
+
}
|
|
1728
|
+
],
|
|
1729
|
+
"runtime": {
|
|
1730
|
+
"lane": "input",
|
|
1731
|
+
"priority": "essential",
|
|
1732
|
+
"requiresClient": true,
|
|
1733
|
+
"ssrSafeImport": true,
|
|
1734
|
+
"respectsReducedMotion": false,
|
|
1735
|
+
"reRendersPerFrame": 0,
|
|
1736
|
+
"usesPointer": false,
|
|
1737
|
+
"usesScroll": false,
|
|
1738
|
+
"usesWebGL": false,
|
|
1739
|
+
"ownsTransform": false,
|
|
1740
|
+
"sharedSingletons": [
|
|
1741
|
+
"FrameConductor",
|
|
1742
|
+
"AnimationBudget"
|
|
1743
|
+
],
|
|
1744
|
+
"conflictsWith": [],
|
|
1745
|
+
"pairsWith": [
|
|
1746
|
+
"useLazyScene",
|
|
1747
|
+
"useAnimationBudget",
|
|
1748
|
+
"useAdaptiveQuality"
|
|
1749
|
+
]
|
|
1750
|
+
},
|
|
1751
|
+
"quickStart": "\"use client\";\nimport { useSafeToMount } from \"@vectorvesper/motion/react\";\n\nexport function Dashboard() {\n const canMount = useSafeToMount();\n return canMount ? <AnalyticsChart /> : <ChartSkeleton />;\n}",
|
|
1752
|
+
"recipes": [
|
|
1753
|
+
{
|
|
1754
|
+
"name": "Dashboard Boot",
|
|
1755
|
+
"blurb": "KPI tiles render instantly; the expensive chart waits out the hydration burst so its mount cost never stacks on top.",
|
|
1756
|
+
"code": "\"use client\";\nimport { useSafeToMount } from \"@vectorvesper/motion/react\";\n\nexport function Overview({ kpis }: { kpis: Kpi[] }) {\n const canMount = useSafeToMount({ minHeadroomMs: 6, requiredCleanFrames: 3 });\n return (\n <>\n <KpiRow items={kpis} /> {/* always immediate */}\n {canMount ? <RevenueChart /> : <ChartSkeleton />}\n </>\n );\n}"
|
|
1757
|
+
},
|
|
1758
|
+
{
|
|
1759
|
+
"name": "Third-party Widget",
|
|
1760
|
+
"blurb": "Be stricter for a bundle whose cost you don't control.",
|
|
1761
|
+
"code": "\"use client\";\nimport { useSafeToMount } from \"@vectorvesper/motion/react\";\n\nexport function MapPanel() {\n const canMount = useSafeToMount({\n minHeadroomMs: 9, // over half a 60Hz frame still free\n requiredCleanFrames: 10, // ~166ms of sustained calm\n minCores: 8, // desktop-class only\n });\n return canMount ? <InteractiveMap /> : <StaticMapImage />;\n}"
|
|
750
1762
|
}
|
|
751
1763
|
],
|
|
752
|
-
"
|
|
1764
|
+
"dos": [
|
|
1765
|
+
"Always render a sized fallback so the swap doesn't shift layout.",
|
|
1766
|
+
"Use it for cost you control the timing of — charts, maps, editors, 3D canvases.",
|
|
1767
|
+
"Let the options stay constant; they are effect dependencies and changing them restarts the gate from closed."
|
|
1768
|
+
],
|
|
1769
|
+
"donts": [
|
|
1770
|
+
"Don't gate anything the user needs. Checkout, auth and forms render unconditionally, always.",
|
|
1771
|
+
"Don't expect it to close again. It is one-way by design.",
|
|
1772
|
+
"Don't wrap it around something cheap — the gate costs more than the thing it is protecting."
|
|
1773
|
+
],
|
|
753
1774
|
"whenNotToUse": [
|
|
754
1775
|
{
|
|
755
1776
|
"when": "The component is below the fold or off-screen.",
|
|
@@ -764,41 +1785,34 @@
|
|
|
764
1785
|
"instead": "Use `next/dynamic`, `React.lazy`, or a plain import. This measures CPU headroom, not bandwidth, and will happily mount a 2MB download on a healthy frame."
|
|
765
1786
|
}
|
|
766
1787
|
],
|
|
1788
|
+
"guardrails": [
|
|
1789
|
+
"practices/adaptive-performance.md",
|
|
1790
|
+
"practices/react-motion-rules.md"
|
|
1791
|
+
],
|
|
767
1792
|
"docsUrl": "https://vectorvesper.dev/docs/safe-to-mount",
|
|
768
1793
|
"labUrl": "https://vectorvesper.dev/lab/safe-to-mount",
|
|
769
1794
|
"disclosure": {
|
|
770
1795
|
"exposeImplementation": false,
|
|
771
1796
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
772
1797
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
773
|
-
"readingSource": "
|
|
774
|
-
}
|
|
775
|
-
"runtime": {
|
|
776
|
-
"requiresClient": true,
|
|
777
|
-
"ssrSafeImport": true,
|
|
778
|
-
"respectsReducedMotion": false,
|
|
779
|
-
"ownsTransform": false,
|
|
780
|
-
"conflictsWith": [],
|
|
781
|
-
"usesPointer": false,
|
|
782
|
-
"usesScroll": false,
|
|
783
|
-
"usesWebGL": false,
|
|
784
|
-
"lane": "input",
|
|
785
|
-
"priority": "essential",
|
|
786
|
-
"reRendersPerFrame": 0,
|
|
787
|
-
"sharedSingletons": [
|
|
788
|
-
"FrameConductor",
|
|
789
|
-
"AnimationBudget"
|
|
790
|
-
]
|
|
791
|
-
},
|
|
792
|
-
"upgrade": "This is the lean contract: enough to understand the hook and use it correctly. The full contract adds named real-world recipes with complete code, the accumulated dos and don'ts, and routing into the motion guardrails. Available to Vector Vesper members — authenticate the CLI with `npx vectorvesper login <token>`."
|
|
1798
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
1799
|
+
}
|
|
793
1800
|
},
|
|
794
1801
|
{
|
|
795
|
-
"contractLevel": "lean",
|
|
796
1802
|
"name": "useSensorBus",
|
|
797
1803
|
"importFrom": "@vectorvesper/motion/react",
|
|
798
1804
|
"packageName": "@vectorvesper/motion",
|
|
799
1805
|
"since": "0.1.0",
|
|
800
1806
|
"category": "sensor",
|
|
801
1807
|
"tier": "free",
|
|
1808
|
+
"aliases": [
|
|
1809
|
+
"mouse position",
|
|
1810
|
+
"pointer velocity",
|
|
1811
|
+
"scroll velocity",
|
|
1812
|
+
"viewport size",
|
|
1813
|
+
"shared listeners",
|
|
1814
|
+
"input state"
|
|
1815
|
+
],
|
|
802
1816
|
"tagline": "One set of pointer, scroll and viewport listeners for the whole page.",
|
|
803
1817
|
"problem": "Ten effects that each attach their own pointermove and scroll listeners, and each compute their own velocity, do ten times the work to answer one question.",
|
|
804
1818
|
"summary": "Retains a page-wide singleton that attaches passive listeners once and computes smoothed derivatives once per frame, in the conductor's input lane — before any consumer runs. Consumers READ a live snapshot during their own frame work. There are no per-event callbacks; the frame loop is the delivery mechanism. Lifecycle is ref-counted, so listeners detach when the last consumer unmounts.",
|
|
@@ -812,7 +1826,58 @@
|
|
|
812
1826
|
"description": "Stable singleton reference. Read `bus.state.pointer` / `.scroll` / `.viewport` inside a frame callback. The reference never changes, so it is safe in a deps array."
|
|
813
1827
|
}
|
|
814
1828
|
],
|
|
1829
|
+
"runtime": {
|
|
1830
|
+
"lane": "input",
|
|
1831
|
+
"priority": "essential",
|
|
1832
|
+
"requiresClient": true,
|
|
1833
|
+
"ssrSafeImport": true,
|
|
1834
|
+
"respectsReducedMotion": false,
|
|
1835
|
+
"reRendersPerFrame": 0,
|
|
1836
|
+
"usesPointer": true,
|
|
1837
|
+
"usesScroll": true,
|
|
1838
|
+
"usesWebGL": false,
|
|
1839
|
+
"ownsTransform": false,
|
|
1840
|
+
"sharedSingletons": [
|
|
1841
|
+
"FrameConductor",
|
|
1842
|
+
"SensorBus"
|
|
1843
|
+
],
|
|
1844
|
+
"conflictsWith": [],
|
|
1845
|
+
"pairsWith": [
|
|
1846
|
+
"usePointerIntent",
|
|
1847
|
+
"useMagneticIntent",
|
|
1848
|
+
"useVideoScrubber"
|
|
1849
|
+
]
|
|
1850
|
+
},
|
|
815
1851
|
"quickStart": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { useSensorBus } from \"@vectorvesper/motion/react\";\nimport { getConductor } from \"@vectorvesper/motion\";\n\nexport function Follower() {\n const ref = useRef<HTMLDivElement>(null);\n const bus = useSensorBus();\n\n useEffect(() => getConductor().subscribe(\"render\", () => {\n const { x, y } = bus.state.pointer;\n if (ref.current) ref.current.style.transform = `translate3d(${x}px, ${y}px, 0)`;\n }), [bus]);\n\n return <div ref={ref} className=\"fixed top-0 left-0 size-4 rounded-full bg-white\" />;\n}",
|
|
1852
|
+
"recipes": [
|
|
1853
|
+
{
|
|
1854
|
+
"name": "Card Grid",
|
|
1855
|
+
"blurb": "Many tiles reacting to one pointer. Listener count stays at 1 no matter how many cards mount, and nothing re-renders.",
|
|
1856
|
+
"code": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { useSensorBus } from \"@vectorvesper/motion/react\";\nimport { getConductor } from \"@vectorvesper/motion\";\n\nexport function TiltCard({ children }: { children: React.ReactNode }) {\n const ref = useRef<HTMLDivElement>(null);\n const bus = useSensorBus();\n\n useEffect(() => {\n // Cache the centre; re-measure only on resize, never per frame.\n let cx = 0, cy = 0;\n const measure = () => {\n const r = ref.current?.getBoundingClientRect();\n if (r) { cx = r.left + r.width / 2; cy = r.top + r.height / 2; }\n };\n measure();\n window.addEventListener(\"resize\", measure);\n\n const off = getConductor().subscribe(\"render\", () => {\n const { x, y } = bus.state.pointer;\n const rx = ((y - cy) / 400) * -6;\n const ry = ((x - cx) / 400) * 6;\n if (ref.current) {\n ref.current.style.transform = `perspective(800px) rotateX(${rx}deg) rotateY(${ry}deg)`;\n }\n }, { priority: \"decorative\", label: \"TiltCard\" });\n\n return () => { off(); window.removeEventListener(\"resize\", measure); };\n }, [bus]);\n\n return <div ref={ref}>{children}</div>;\n}"
|
|
1857
|
+
},
|
|
1858
|
+
{
|
|
1859
|
+
"name": "Scroll Skew",
|
|
1860
|
+
"blurb": "Skew an element by scroll velocity — the damped value is already computed for you.",
|
|
1861
|
+
"code": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { useSensorBus } from \"@vectorvesper/motion/react\";\nimport { getConductor } from \"@vectorvesper/motion\";\n\nexport function SkewOnScroll({ children }: { children: React.ReactNode }) {\n const ref = useRef<HTMLDivElement>(null);\n const bus = useSensorBus();\n\n useEffect(() => getConductor().subscribe(\"render\", () => {\n const skew = Math.max(-8, Math.min(8, bus.state.scroll.vy / 180));\n if (ref.current) ref.current.style.transform = `skewY(${skew.toFixed(2)}deg)`;\n }, { priority: \"decorative\", label: \"SkewOnScroll\" }), [bus]);\n\n return <div ref={ref}>{children}</div>;\n}"
|
|
1862
|
+
},
|
|
1863
|
+
{
|
|
1864
|
+
"name": "Non-React consumer",
|
|
1865
|
+
"blurb": "The bus is framework-agnostic; the hook is only a lifecycle wrapper.",
|
|
1866
|
+
"code": "import { getSensorBus, getConductor } from \"@vectorvesper/motion\";\n\nconst release = getSensorBus().retain();\nconst off = getConductor().subscribe(\"render\", () => {\n const { speed } = getSensorBus().state.pointer;\n document.body.style.setProperty(\"--pointer-speed\", String(Math.round(speed)));\n});\n\n// Later: off(); release();"
|
|
1867
|
+
}
|
|
1868
|
+
],
|
|
1869
|
+
"dos": [
|
|
1870
|
+
"Read `bus.state` fresh inside every frame callback — the objects are live and mutated in place.",
|
|
1871
|
+
"Cache element rects yourself and re-measure on resize/scroll, not per frame.",
|
|
1872
|
+
"Check `pointer.seen` before using coordinates, so nothing jumps from (0,0) before first move.",
|
|
1873
|
+
"Let the ref-counting work: mount and unmount freely, listeners follow."
|
|
1874
|
+
],
|
|
1875
|
+
"donts": [
|
|
1876
|
+
"Don't retain or spread `bus.state` — it is a live object, and a copy goes stale immediately.",
|
|
1877
|
+
"Don't mutate anything on `bus.state`.",
|
|
1878
|
+
"Don't push bus values into React state at frame rate; that is the exact bottleneck this removes.",
|
|
1879
|
+
"Don't attach your own pointermove listener alongside it — that reintroduces the duplication."
|
|
1880
|
+
],
|
|
816
1881
|
"whenNotToUse": [
|
|
817
1882
|
{
|
|
818
1883
|
"when": "You need a single isolated hover or click on one element.",
|
|
@@ -827,41 +1892,35 @@
|
|
|
827
1892
|
"instead": "Attach a dedicated `pointermove` listener and use `getCoalescedEvents()`. The bus is rAF-locked by design and reports one sample per frame."
|
|
828
1893
|
}
|
|
829
1894
|
],
|
|
1895
|
+
"guardrails": [
|
|
1896
|
+
"practices/frame-loop-rules.md",
|
|
1897
|
+
"practices/layout-read-discipline.md",
|
|
1898
|
+
"practices/react-motion-rules.md"
|
|
1899
|
+
],
|
|
830
1900
|
"docsUrl": "https://vectorvesper.dev/docs/sensor-bus",
|
|
831
1901
|
"labUrl": "https://vectorvesper.dev/lab/sensor-bus",
|
|
832
1902
|
"disclosure": {
|
|
833
1903
|
"exposeImplementation": false,
|
|
834
1904
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
835
1905
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
836
|
-
"readingSource": "
|
|
837
|
-
}
|
|
838
|
-
"runtime": {
|
|
839
|
-
"requiresClient": true,
|
|
840
|
-
"ssrSafeImport": true,
|
|
841
|
-
"respectsReducedMotion": false,
|
|
842
|
-
"ownsTransform": false,
|
|
843
|
-
"conflictsWith": [],
|
|
844
|
-
"usesPointer": true,
|
|
845
|
-
"usesScroll": true,
|
|
846
|
-
"usesWebGL": false,
|
|
847
|
-
"lane": "input",
|
|
848
|
-
"priority": "essential",
|
|
849
|
-
"reRendersPerFrame": 0,
|
|
850
|
-
"sharedSingletons": [
|
|
851
|
-
"FrameConductor",
|
|
852
|
-
"SensorBus"
|
|
853
|
-
]
|
|
854
|
-
},
|
|
855
|
-
"upgrade": "This is the lean contract: enough to understand the hook and use it correctly. The full contract adds named real-world recipes with complete code, the accumulated dos and don'ts, and routing into the motion guardrails. Available to Vector Vesper members — authenticate the CLI with `npx vectorvesper login <token>`."
|
|
1906
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
1907
|
+
}
|
|
856
1908
|
},
|
|
857
1909
|
{
|
|
858
|
-
"contractLevel": "lean",
|
|
859
1910
|
"name": "useVideoScrubber",
|
|
860
1911
|
"importFrom": "@vectorvesper/motion/react",
|
|
861
1912
|
"packageName": "@vectorvesper/motion",
|
|
862
1913
|
"since": "0.1.0",
|
|
863
1914
|
"category": "media",
|
|
864
1915
|
"tier": "free",
|
|
1916
|
+
"aliases": [
|
|
1917
|
+
"scroll video",
|
|
1918
|
+
"video scrub",
|
|
1919
|
+
"scrollytelling",
|
|
1920
|
+
"scrub timeline",
|
|
1921
|
+
"video on scroll",
|
|
1922
|
+
"smooth seek"
|
|
1923
|
+
],
|
|
865
1924
|
"tagline": "Drive a video timeline from scroll or pointer, without the seek stutter.",
|
|
866
1925
|
"problem": "Setting `video.currentTime` on every scroll event queues seeks faster than the decoder can serve them, and the result stutters — worst on Safari, worst on the demo you filmed.",
|
|
867
1926
|
"summary": "Seek discipline is the whole product: never issue a seek while one is in flight, skip sub-frame deltas, use `fastSeek` for large jumps, and throttle adaptively when the decoder is measurably struggling. Also handles iOS priming (a muted play/pause round trip so Safari actually buffers), records and restores the video's attributes exactly, and stops seeking entirely when the track is off screen.",
|
|
@@ -926,7 +1985,60 @@
|
|
|
926
1985
|
"description": "The controller. Use `.set(progress)` for the manual driver."
|
|
927
1986
|
}
|
|
928
1987
|
],
|
|
1988
|
+
"runtime": {
|
|
1989
|
+
"lane": "update",
|
|
1990
|
+
"priority": "essential",
|
|
1991
|
+
"requiresClient": true,
|
|
1992
|
+
"ssrSafeImport": true,
|
|
1993
|
+
"respectsReducedMotion": true,
|
|
1994
|
+
"reRendersPerFrame": 0,
|
|
1995
|
+
"usesPointer": true,
|
|
1996
|
+
"usesScroll": true,
|
|
1997
|
+
"usesWebGL": false,
|
|
1998
|
+
"ownsTransform": false,
|
|
1999
|
+
"sharedSingletons": [
|
|
2000
|
+
"FrameConductor",
|
|
2001
|
+
"SensorBus"
|
|
2002
|
+
],
|
|
2003
|
+
"conflictsWith": [
|
|
2004
|
+
"Any other code setting `video.currentTime`, or calling play() on the same element.",
|
|
2005
|
+
"Smooth-scroll libraries that lie about scroll position (verify the mapping if you use one)."
|
|
2006
|
+
],
|
|
2007
|
+
"pairsWith": [
|
|
2008
|
+
"useLazyScene",
|
|
2009
|
+
"useSensorBus"
|
|
2010
|
+
]
|
|
2011
|
+
},
|
|
929
2012
|
"quickStart": "\"use client\";\nimport { useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nexport function ScrollScrub() {\n const { videoRef, trackRef } = useVideoScrubber<HTMLDivElement>();\n return (\n <div ref={trackRef} style={{ height: \"300vh\" }}>\n <div style={{ position: \"sticky\", top: 0, height: \"100vh\" }}>\n <video ref={videoRef} src=\"/clip.mp4\" muted playsInline />\n </div>\n </div>\n );\n}",
|
|
2013
|
+
"recipes": [
|
|
2014
|
+
{
|
|
2015
|
+
"name": "Product Scrollytelling",
|
|
2016
|
+
"blurb": "The Apple pattern: a tall track, sticky content, pinned mapping.",
|
|
2017
|
+
"code": "\"use client\";\nimport { useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nexport function ProductReveal() {\n const { videoRef, trackRef, progressRef } = useVideoScrubber<HTMLDivElement>({\n driver: \"scroll\",\n mapping: \"pin\",\n smooth: 10,\n });\n\n return (\n <section ref={trackRef} className=\"h-[400vh]\">\n <div className=\"sticky top-0 grid h-screen place-items-center\">\n <video ref={videoRef} src=\"/product-allframes.mp4\" muted playsInline className=\"size-full object-cover\" />\n <Captions progressRef={progressRef} />\n </div>\n </section>\n );\n}"
|
|
2018
|
+
},
|
|
2019
|
+
{
|
|
2020
|
+
"name": "Hover-scrub thumbnail",
|
|
2021
|
+
"blurb": "Pointer driver across the card's width — a 360 spin on hover.",
|
|
2022
|
+
"code": "\"use client\";\nimport { useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nexport function SpinCard({ src }: { src: string }) {\n const { videoRef, trackRef } = useVideoScrubber<HTMLDivElement>({\n driver: \"pointer\",\n pointerAxis: \"x\",\n smooth: 14,\n });\n return (\n <div ref={trackRef} className=\"aspect-square cursor-ew-resize\">\n <video ref={videoRef} src={src} muted playsInline className=\"size-full object-cover\" />\n </div>\n );\n}"
|
|
2023
|
+
},
|
|
2024
|
+
{
|
|
2025
|
+
"name": "Manual driver",
|
|
2026
|
+
"blurb": "Drive it from your own value — a slider, a timeline, a gesture.",
|
|
2027
|
+
"code": "\"use client\";\nimport { useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nexport function SliderScrub() {\n const { videoRef, trackRef, scrubberRef } = useVideoScrubber<HTMLDivElement>({ driver: \"manual\" });\n return (\n <div ref={trackRef}>\n <video ref={videoRef} src=\"/clip.mp4\" muted playsInline />\n <input\n type=\"range\" min={0} max={1} step={0.001}\n onChange={(e) => scrubberRef.current?.set(Number(e.target.value))}\n />\n </div>\n );\n}"
|
|
2028
|
+
}
|
|
2029
|
+
],
|
|
2030
|
+
"dos": [
|
|
2031
|
+
"Encode the video with every frame a keyframe: `ffmpeg -i in.mp4 -g 1 -coder 0 -bf 0 -crf 20 -movflags +faststart out.mp4`. This is not optional — it is the single biggest factor in whether scrubbing feels smooth.",
|
|
2032
|
+
"Give the track real height (300vh+) for the pin mapping, and make the inner content sticky.",
|
|
2033
|
+
"Read `progressRef.current` inside a frame callback to drive captions or overlays.",
|
|
2034
|
+
"Host the file somewhere with range-request support."
|
|
2035
|
+
],
|
|
2036
|
+
"donts": [
|
|
2037
|
+
"Don't set `currentTime` yourself anywhere else on that element.",
|
|
2038
|
+
"Don't ship a 4K scrub video. Every frame is a keyframe, so the file is already several times larger than a normal encode.",
|
|
2039
|
+
"Don't change `driver` or `track` after mount — both are fixed by design.",
|
|
2040
|
+
"Don't render `progressRef.current` directly in JSX; it updates per frame and React will not see it."
|
|
2041
|
+
],
|
|
930
2042
|
"whenNotToUse": [
|
|
931
2043
|
{
|
|
932
2044
|
"when": "You want a video that simply plays when it scrolls into view.",
|
|
@@ -941,35 +2053,492 @@
|
|
|
941
2053
|
"instead": "Use `requestVideoFrameCallback` directly. This hook smooths and skips sub-frame deltas on purpose, which is the opposite of what frame-exact work needs."
|
|
942
2054
|
}
|
|
943
2055
|
],
|
|
2056
|
+
"guardrails": [
|
|
2057
|
+
"practices/texture-and-media-gotchas.md",
|
|
2058
|
+
"practices/layout-read-discipline.md",
|
|
2059
|
+
"practices/damping-and-inertia.md"
|
|
2060
|
+
],
|
|
944
2061
|
"docsUrl": "https://vectorvesper.dev/docs/video-scrubber",
|
|
945
2062
|
"labUrl": "https://vectorvesper.dev/lab/video-scrubber",
|
|
946
2063
|
"disclosure": {
|
|
947
2064
|
"exposeImplementation": false,
|
|
948
2065
|
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
949
2066
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
950
|
-
"readingSource": "
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
"
|
|
962
|
-
"
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
"
|
|
966
|
-
"
|
|
967
|
-
"
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
},
|
|
972
|
-
"
|
|
2067
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
],
|
|
2071
|
+
"patterns": [
|
|
2072
|
+
{
|
|
2073
|
+
"name": "adaptive-shader-hero",
|
|
2074
|
+
"title": "Adaptive shader hero",
|
|
2075
|
+
"tagline": "One hero, three renditions — device floor picks it, headroom times it.",
|
|
2076
|
+
"problem": "A WebGL hero ships one experience to every device: gorgeous on the build machine, a slideshow on a mid-range phone, and a hydration-time mount that lands its cost on the worst moment of the page's life.",
|
|
2077
|
+
"uses": [
|
|
2078
|
+
"useAdaptiveQuality",
|
|
2079
|
+
"useSafeToMount"
|
|
2080
|
+
],
|
|
2081
|
+
"wiring": [
|
|
2082
|
+
"useAdaptiveQuality picks the rendition: tier 0 → shader, tier 1 → video loop, tier 2 → static image. reducedMotion wins over everything and gets the still.",
|
|
2083
|
+
"useSafeToMount times the expensive mount: the shader (and only the shader) additionally waits for sustained frame headroom, so it lands after hydration settles.",
|
|
2084
|
+
"Load the WebGL component via dynamic(..., { ssr: false }) — shader code must not run during server rendering.",
|
|
2085
|
+
"Every rendition shares the same layout box, so upgrades swap in place without reflow.",
|
|
2086
|
+
"Inside the mounted scene, keep consuming the tier (poll getAdaptiveQuality().state in frame work) to shed internal detail instead of unmounting."
|
|
2087
|
+
],
|
|
2088
|
+
"code": "\"use client\";\n\nimport dynamic from \"next/dynamic\";\nimport { useAdaptiveQuality, useSafeToMount } from \"@vectorvesper/motion/react\";\n\n// WebGL never renders on the server.\nconst ShaderScene = dynamic(() => import(\"./ShaderScene\"), { ssr: false });\n\nexport function AdaptiveHero() {\n const { tier, reducedMotion } = useAdaptiveQuality();\n const canMountShader = useSafeToMount({ minHeadroomMs: 7, requiredCleanFrames: 4 });\n\n const stillFrame = (\n <img src=\"/hero/still.webp\" alt=\"Aurora over the product\" style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }} />\n );\n\n return (\n <section style={{ position: \"relative\", height: \"90vh\", overflow: \"hidden\" }}>\n {reducedMotion ? (\n stillFrame\n ) : tier === 0 && canMountShader ? (\n <ShaderScene />\n ) : tier <= 1 ? (\n <video src=\"/hero/loop.mp4\" autoPlay muted loop playsInline style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }} />\n ) : (\n stillFrame\n )}\n <div style={{ position: \"absolute\", inset: 0, display: \"grid\", placeItems: \"center\" }}>\n <h1>Motion that holds the frame.</h1>\n </div>\n </section>\n );\n}",
|
|
2089
|
+
"pitfalls": [
|
|
2090
|
+
{
|
|
2091
|
+
"mistake": "Importing the shader component statically.",
|
|
2092
|
+
"consequence": "WebGL code executes during SSR and crashes the server render — or bloats the first bundle even when tier 2 never mounts it.",
|
|
2093
|
+
"rule": "ssr-module-scope"
|
|
2094
|
+
},
|
|
2095
|
+
{
|
|
2096
|
+
"mistake": "Skipping useSafeToMount because the device probed as capable.",
|
|
2097
|
+
"consequence": "Capable hardware still hydrates: the shader's compile lands on a saturated main thread and the first interaction stutters. The floor says CAN run; headroom says run NOW."
|
|
2098
|
+
},
|
|
2099
|
+
{
|
|
2100
|
+
"mistake": "Unmounting the shader when the live tier degrades after mount.",
|
|
2101
|
+
"consequence": "Degrade → frames recover → upgrade → jank → forever. Mounting latches; adapt DETAIL inside the scene instead."
|
|
2102
|
+
},
|
|
2103
|
+
{
|
|
2104
|
+
"mistake": "Treating reducedMotion as tier 2.",
|
|
2105
|
+
"consequence": "They are different axes: a top-tier machine can request less motion. The preference gets the still frame even at tier 0.",
|
|
2106
|
+
"rule": "no-reduced-motion"
|
|
2107
|
+
}
|
|
2108
|
+
],
|
|
2109
|
+
"verify": [
|
|
2110
|
+
"check_motion on the file — expect zero findings.",
|
|
2111
|
+
"Chrome sensors → emulate prefers-reduced-motion: the still must render even on a strong machine.",
|
|
2112
|
+
"CPU throttle 6× and reload: the video or still rendition appears instead of the shader (device floor or budget at work).",
|
|
2113
|
+
"On a clean desktop load, the shader appears a few frames AFTER first paint — that delay is useSafeToMount doing its job, not a bug."
|
|
2114
|
+
],
|
|
2115
|
+
"aliases": [
|
|
2116
|
+
"shader hero",
|
|
2117
|
+
"webgl hero",
|
|
2118
|
+
"adaptive hero",
|
|
2119
|
+
"hero fallback",
|
|
2120
|
+
"three.js hero",
|
|
2121
|
+
"gpu hero",
|
|
2122
|
+
"progressive enhancement hero"
|
|
2123
|
+
]
|
|
2124
|
+
},
|
|
2125
|
+
{
|
|
2126
|
+
"name": "custom-frame-effect",
|
|
2127
|
+
"title": "Custom frame effect",
|
|
2128
|
+
"tagline": "The canonical skeleton for building your own effect on the runtime.",
|
|
2129
|
+
"problem": "You need an effect none of the hooks provide. The habit from every tutorial — start a private requestAnimationFrame loop, attach your own listeners — compiles, passes review, and silently runs outside the frame budget, invisible to shedding and devtools.",
|
|
2130
|
+
"uses": [
|
|
2131
|
+
"FrameConductor",
|
|
2132
|
+
"useSensorBus",
|
|
2133
|
+
"damp"
|
|
2134
|
+
],
|
|
2135
|
+
"wiring": [
|
|
2136
|
+
"Mark the component \"use client\" — frame work needs a browser.",
|
|
2137
|
+
"Retain shared input with useSensorBus() instead of attaching your own listeners.",
|
|
2138
|
+
"Subscribe inside useEffect via getConductor().subscribe(lane, fn, { priority, label }) — reads in the input lane, math in update, style writes in render.",
|
|
2139
|
+
"Smooth values with damp(current, target, k, dt) using the dt the conductor passes — never a fixed per-frame factor.",
|
|
2140
|
+
"Keep per-frame values in refs or locals and write results straight to the DOM — never setState per frame.",
|
|
2141
|
+
"Return the unsubscribe function from the effect so the loop can sleep when the component unmounts.",
|
|
2142
|
+
"If the effect is autonomous motion (moves without user input), gate it behind prefers-reduced-motion."
|
|
2143
|
+
],
|
|
2144
|
+
"code": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { getConductor, damp } from \"@vectorvesper/motion\";\nimport { useSensorBus } from \"@vectorvesper/motion/react\";\n\n/**\n * Example custom effect: a spotlight that trails the pointer.\n * The shape is the pattern — swap the math and the write for your own effect.\n */\nexport function SpotlightPanel({ children }: { children: React.ReactNode }) {\n const ref = useRef<HTMLDivElement>(null);\n const bus = useSensorBus();\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n\n // Interaction-following effects keep working under reduced motion (they are\n // direct manipulation) — but if your effect is AUTONOMOUS, bail here instead.\n let x = 0;\n let y = 0;\n\n return getConductor().subscribe(\"render\", (dt) => {\n const { pointer } = bus.state;\n if (!pointer.seen) return;\n x = damp(x, pointer.x, 10, dt);\n y = damp(y, pointer.y, 10, dt);\n el.style.setProperty(\"--spot\", `${x.toFixed(1)}px ${y.toFixed(1)}px`);\n }, { priority: \"enhanced\", label: \"SpotlightPanel\" });\n }, [bus]);\n\n return (\n <div\n ref={ref}\n style={{\n backgroundImage:\n \"radial-gradient(240px circle at var(--spot, 50% 50%), rgba(255,255,255,0.08), transparent)\",\n }}\n >\n {children}\n </div>\n );\n}",
|
|
2145
|
+
"pitfalls": [
|
|
2146
|
+
{
|
|
2147
|
+
"mistake": "Starting your own requestAnimationFrame loop for the effect.",
|
|
2148
|
+
"consequence": "It runs outside the shared budget — the scheduler cannot shed it, devtools cannot see it, and its reads interleave with other effects' writes into layout thrash.",
|
|
2149
|
+
"rule": "orphan-raf"
|
|
2150
|
+
},
|
|
2151
|
+
{
|
|
2152
|
+
"mistake": "Subscribing in the effect but not returning the unsubscribe function.",
|
|
2153
|
+
"consequence": "The subscriber outlives the component; the loop never sleeps and the closure leaks.",
|
|
2154
|
+
"rule": "missing-cleanup"
|
|
2155
|
+
},
|
|
2156
|
+
{
|
|
2157
|
+
"mistake": "Mirroring a per-frame value into React state to render it.",
|
|
2158
|
+
"consequence": "A React render per frame — the component tree pays reconciliation 60+ times a second.",
|
|
2159
|
+
"rule": "setstate-per-frame"
|
|
2160
|
+
},
|
|
2161
|
+
{
|
|
2162
|
+
"mistake": "Reading getBoundingClientRect inside the render lane after styles were written.",
|
|
2163
|
+
"consequence": "Forced synchronous layout every frame. Measure in the input lane; write in render."
|
|
2164
|
+
},
|
|
2165
|
+
{
|
|
2166
|
+
"mistake": "Autonomous motion with no prefers-reduced-motion guard.",
|
|
2167
|
+
"consequence": "Users who asked for less motion get all of it.",
|
|
2168
|
+
"rule": "no-reduced-motion"
|
|
2169
|
+
}
|
|
2170
|
+
],
|
|
2171
|
+
"verify": [
|
|
2172
|
+
"Run check_motion on the file — expect zero findings; orphan-raf and missing-cleanup are the ones this shape prevents.",
|
|
2173
|
+
"Mount the devtools overlay (mountDevtools from @vectorvesper/motion/devtools): your label should appear in the table with a per-frame cost, ideally under ~1ms.",
|
|
2174
|
+
"Unmount the component and confirm the subscriber row disappears — that proves cleanup ran."
|
|
2175
|
+
],
|
|
2176
|
+
"aliases": [
|
|
2177
|
+
"custom effect",
|
|
2178
|
+
"build your own",
|
|
2179
|
+
"skeleton",
|
|
2180
|
+
"boilerplate",
|
|
2181
|
+
"frame work",
|
|
2182
|
+
"new effect"
|
|
2183
|
+
]
|
|
2184
|
+
},
|
|
2185
|
+
{
|
|
2186
|
+
"name": "dashboard-live-numbers",
|
|
2187
|
+
"title": "Dashboard of live numbers",
|
|
2188
|
+
"tagline": "Dozens of animated stats that shed themselves when the page gets heavy.",
|
|
2189
|
+
"problem": "One animated counter is free. Forty of them on a dashboard are forty frame-loop subscribers — fine on a desktop, real weight on a low-end laptop with the data grid also repainting. The counters are decoration; they should be the first thing to go.",
|
|
2190
|
+
"uses": [
|
|
2191
|
+
"useNumberTicker",
|
|
2192
|
+
"useAnimationBudget"
|
|
2193
|
+
],
|
|
2194
|
+
"wiring": [
|
|
2195
|
+
"Each stat renders through useNumberTicker — the eased value writes straight to the DOM, so N tickers cause zero React renders per frame.",
|
|
2196
|
+
"One useAnimationBudget at the dashboard root decides how much decoration the page can afford.",
|
|
2197
|
+
"Tier 0: animate everything. Tier 1+: render plain text for secondary stats and keep the hero stats animated. The static branch is just {value} — the ticker hook simply isn't mounted.",
|
|
2198
|
+
"Sample fast-moving data sources (once a second) before feeding value to a ticker; the easing is the presentation, not a stream renderer."
|
|
2199
|
+
],
|
|
2200
|
+
"code": "\"use client\";\n\nimport { useAnimationBudget, useNumberTicker } from \"@vectorvesper/motion/react\";\n\nfunction Stat({ label, value, format, animate }: {\n label: string;\n value: number;\n format?: Intl.NumberFormatOptions;\n animate: boolean;\n}) {\n return (\n <div>\n <dt>{label}</dt>\n <dd>{animate ? <Ticker value={value} format={format} /> : new Intl.NumberFormat(undefined, format).format(value)}</dd>\n </div>\n );\n}\n\nfunction Ticker({ value, format }: { value: number; format?: Intl.NumberFormatOptions }) {\n const { ref } = useNumberTicker(value, { format });\n return <span ref={ref} />;\n}\n\nexport function StatsBoard({ stats }: { stats: Array<{ id: string; label: string; value: number; hero?: boolean }> }) {\n const { tier } = useAnimationBudget();\n\n return (\n <dl>\n {stats.map((s) => (\n <Stat\n key={s.id}\n label={s.label}\n value={s.value}\n format={{ maximumFractionDigits: 0 }}\n // Tier 0: everything animates. Under pressure, only hero stats keep the flourish.\n animate={tier === 0 || Boolean(s.hero)}\n />\n ))}\n </dl>\n );\n}",
|
|
2201
|
+
"pitfalls": [
|
|
2202
|
+
{
|
|
2203
|
+
"mistake": "Feeding a websocket stream straight into value many times a second.",
|
|
2204
|
+
"consequence": "The easing never settles and every message restarts it — permanent churn that reads as flicker. Sample the stream; let the ticker present the sample."
|
|
2205
|
+
},
|
|
2206
|
+
{
|
|
2207
|
+
"mistake": "Implementing the counter with setState-per-frame instead of the ticker.",
|
|
2208
|
+
"consequence": "Sixty renders a second per stat, multiplied by forty stats — the dashboard drags itself down.",
|
|
2209
|
+
"rule": "setstate-per-frame"
|
|
2210
|
+
},
|
|
2211
|
+
{
|
|
2212
|
+
"mistake": "Animating stats that are offscreen below the fold.",
|
|
2213
|
+
"consequence": "Subscribers doing invisible work. Virtualize or gate by visibility for very long boards."
|
|
2214
|
+
},
|
|
2215
|
+
{
|
|
2216
|
+
"mistake": "Announcing every tick to screen readers with aria-live.",
|
|
2217
|
+
"consequence": "Sixty announcements a second. Announce the final value once from your data layer if it matters."
|
|
2218
|
+
}
|
|
2219
|
+
],
|
|
2220
|
+
"verify": [
|
|
2221
|
+
"check_motion on the file — expect zero findings.",
|
|
2222
|
+
"React DevTools highlight-updates: numbers visibly counting with zero components flashing.",
|
|
2223
|
+
"Devtools overlay: one subscriber row per animated ticker; CPU-throttle until tier reads 1 and watch secondary stats drop to plain text while hero stats keep animating."
|
|
2224
|
+
],
|
|
2225
|
+
"aliases": [
|
|
2226
|
+
"animated counter",
|
|
2227
|
+
"count up",
|
|
2228
|
+
"stats dashboard",
|
|
2229
|
+
"kpi tiles",
|
|
2230
|
+
"number animation",
|
|
2231
|
+
"metrics board"
|
|
2232
|
+
]
|
|
2233
|
+
},
|
|
2234
|
+
{
|
|
2235
|
+
"name": "lazy-3d-section",
|
|
2236
|
+
"title": "Lazy 3D section below the fold",
|
|
2237
|
+
"tagline": "A heavy scene that boots near the viewport, sized for the device it landed on.",
|
|
2238
|
+
"problem": "A three.js section three screens down initialises during page load, competes with the content the user is reading, and then renders at full quality on hardware that cannot hold it. Two decisions — when to exist, how rich to be — need two different signals.",
|
|
2239
|
+
"uses": [
|
|
2240
|
+
"useLazyScene",
|
|
2241
|
+
"useAdaptiveQuality"
|
|
2242
|
+
],
|
|
2243
|
+
"wiring": [
|
|
2244
|
+
"useLazyScene (with deferWhileLow) decides WHEN: near the viewport, idle slot, scroll settled, budget not critical.",
|
|
2245
|
+
"Load the scene component via dynamic(..., { ssr: false }) so its code and its DOM both stay deferred.",
|
|
2246
|
+
"Inside the mounted scene, useAdaptiveQuality decides HOW RICH: pixel ratio, particle counts, post-processing — from the fused tier.",
|
|
2247
|
+
"The placeholder keeps the scene's final dimensions and meaningful content (a poster, the same heading) so nothing shifts and nothing is lost without JS-heavy rendering.",
|
|
2248
|
+
"Mount once, keep it mounted — adapt detail inside the scene on later tier changes instead of unmounting."
|
|
2249
|
+
],
|
|
2250
|
+
"code": "\"use client\";\n\nimport dynamic from \"next/dynamic\";\nimport { useLazyScene } from \"@vectorvesper/motion/react\";\n\nconst ExplodedProduct = dynamic(() => import(\"./ExplodedProduct\"), { ssr: false });\n\nexport function ProductBreakdown() {\n const { ref, ready } = useLazyScene<HTMLDivElement>({\n rootMargin: \"400px\",\n deferWhileLow: true,\n });\n\n return (\n <div ref={ref} style={{ height: \"100vh\", position: \"relative\" }}>\n {ready ? (\n <ExplodedProduct />\n ) : (\n <img src=\"/scenes/breakdown-poster.webp\" alt=\"Exploded view of the chassis\" style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }} />\n )}\n </div>\n );\n}\n\n// ─── ExplodedProduct.tsx (separate file, loaded on demand) ───────────\n//\n// \"use client\";\n// import { useAdaptiveQuality } from \"@vectorvesper/motion/react\";\n//\n// export default function ExplodedProduct() {\n// const { tier, reducedMotion } = useAdaptiveQuality();\n// return (\n// <Canvas dpr={tier === 0 ? [1, 2] : 1}>\n// <Model spin={!reducedMotion} particles={tier === 0 ? 2000 : tier === 1 ? 500 : 0} />\n// </Canvas>\n// );\n// }",
|
|
2251
|
+
"pitfalls": [
|
|
2252
|
+
{
|
|
2253
|
+
"mistake": "Importing the scene statically and only gating its render.",
|
|
2254
|
+
"consequence": "The three.js bundle parses during page load even when the user never scrolls down — and WebGL touched at module scope crashes SSR.",
|
|
2255
|
+
"rule": "ssr-module-scope"
|
|
2256
|
+
},
|
|
2257
|
+
{
|
|
2258
|
+
"mistake": "A zero-height placeholder host.",
|
|
2259
|
+
"consequence": "The IntersectionObserver fires on a 0px box, not where the scene will render — the gate opens at the wrong scroll position and the mount shifts the page."
|
|
2260
|
+
},
|
|
2261
|
+
{
|
|
2262
|
+
"mistake": "Unmounting the scene when it scrolls back out of view.",
|
|
2263
|
+
"consequence": "Context creation is the expensive part; recreating it per pass costs more than keeping the scene alive. ready latches by design."
|
|
2264
|
+
},
|
|
2265
|
+
{
|
|
2266
|
+
"mistake": "Reading useAdaptiveQuality once at mount and baking the values in.",
|
|
2267
|
+
"consequence": "A capable device that later struggles keeps rendering full detail. Consume the tier live (it re-renders rarely) or poll state in frame work."
|
|
2268
|
+
}
|
|
2269
|
+
],
|
|
2270
|
+
"verify": [
|
|
2271
|
+
"check_motion on both files — expect zero findings.",
|
|
2272
|
+
"Network tab: the scene chunk must not load until you scroll within ~400px.",
|
|
2273
|
+
"Scroll down fast in one flick: the mount waits for the scroll to settle (that pause is the gate, not lag).",
|
|
2274
|
+
"CPU-throttle before scrolling down: with deferWhileLow the mount waits up to 3s for recovery, then fails open."
|
|
2275
|
+
],
|
|
2276
|
+
"aliases": [
|
|
2277
|
+
"lazy 3d",
|
|
2278
|
+
"three.js section",
|
|
2279
|
+
"webgl below fold",
|
|
2280
|
+
"deferred scene",
|
|
2281
|
+
"r3f lazy",
|
|
2282
|
+
"heavy scene"
|
|
2283
|
+
]
|
|
2284
|
+
},
|
|
2285
|
+
{
|
|
2286
|
+
"name": "magnetic-prefetch-cta",
|
|
2287
|
+
"title": "Magnetic CTA that prefetches",
|
|
2288
|
+
"tagline": "The button reaches for the cursor while the route it leads to loads.",
|
|
2289
|
+
"problem": "The primary CTA is where feel and speed both matter most — yet the magnetic pull usually starts at hover (too late to feel alive) and the route fetch starts at click (too late to feel instant). Both have the same fix: act on approach.",
|
|
2290
|
+
"uses": [
|
|
2291
|
+
"useMagneticIntent",
|
|
2292
|
+
"usePointerIntent"
|
|
2293
|
+
],
|
|
2294
|
+
"wiring": [
|
|
2295
|
+
"useMagneticIntent on the button element — it owns that element's transform and starts pulling on predicted approach.",
|
|
2296
|
+
"usePointerIntent on a WRAPPER element (not the button — the magnet owns the button's transform and, with dynamic tracking, its own prediction).",
|
|
2297
|
+
"In onIntentChange, prefetch the route (router.prefetch) — idempotent and cheap, so overlapping zones on nearby CTAs are fine.",
|
|
2298
|
+
"Keep the pull modest (strength 12–18): the button must remain easy to acquire, and focus/keyboard behavior must not depend on the effect."
|
|
2299
|
+
],
|
|
2300
|
+
"code": "\"use client\";\n\nimport { useRouter } from \"next/navigation\";\nimport { useMagneticIntent, usePointerIntent } from \"@vectorvesper/motion/react\";\n\nexport function MagneticPrefetchCta({ href, children }: { href: string; children: React.ReactNode }) {\n const router = useRouter();\n\n // Wrapper predicts the approach and warms the route.\n const { ref: zoneRef } = usePointerIntent<HTMLDivElement>({\n extend: 32,\n onIntentChange: (coming) => { if (coming) router.prefetch(href); },\n });\n\n // Button reaches for the cursor. It owns this element's transform.\n const { ref: magnetRef } = useMagneticIntent<HTMLButtonElement>({\n strength: 16,\n reach: 110,\n scale: 1.04,\n });\n\n return (\n <div ref={zoneRef} style={{ display: \"inline-block\", padding: 12 }}>\n <button ref={magnetRef} onClick={() => router.push(href)}>\n {children}\n </button>\n </div>\n );\n}",
|
|
2301
|
+
"pitfalls": [
|
|
2302
|
+
{
|
|
2303
|
+
"mistake": "Putting both refs on the same element.",
|
|
2304
|
+
"consequence": "It works — but the magnet already runs its own intent prediction internally, and the wrapper split gives the prefetch a larger catchment than the pull, which is the feel you want: warm early, move late."
|
|
2305
|
+
},
|
|
2306
|
+
{
|
|
2307
|
+
"mistake": "Also styling the button with a CSS transition on transform for hover.",
|
|
2308
|
+
"consequence": "Two transform owners — the pull turns syrupy or jitters. Use color/shadow for hover styling.",
|
|
2309
|
+
"rule": "transform-conflict"
|
|
2310
|
+
},
|
|
2311
|
+
{
|
|
2312
|
+
"mistake": "Doing non-idempotent work in onIntentChange (posting analytics, mutating state).",
|
|
2313
|
+
"consequence": "Intent can flip several times on a near-miss. Prefetch is safe because it caches; anything else needs its own latch."
|
|
2314
|
+
},
|
|
2315
|
+
{
|
|
2316
|
+
"mistake": "Scaling strength up until the button visibly chases the cursor.",
|
|
2317
|
+
"consequence": "A control that moves away from where the user aimed — harder to click, the opposite of the point."
|
|
2318
|
+
}
|
|
2319
|
+
],
|
|
2320
|
+
"verify": [
|
|
2321
|
+
"check_motion on the file — expect zero findings.",
|
|
2322
|
+
"Network tab: approach the button without touching it — the route's prefetch request fires while the cursor is still outside the wrapper padding.",
|
|
2323
|
+
"Tab to the button with the keyboard: focus ring appears, Enter navigates, no motion required.",
|
|
2324
|
+
"Toggle reduced motion: the button stays static and everything else still works."
|
|
2325
|
+
],
|
|
2326
|
+
"aliases": [
|
|
2327
|
+
"magnetic button",
|
|
2328
|
+
"cta button",
|
|
2329
|
+
"prefetch on hover",
|
|
2330
|
+
"instant navigation",
|
|
2331
|
+
"hero button",
|
|
2332
|
+
"sticky cursor button"
|
|
2333
|
+
]
|
|
2334
|
+
},
|
|
2335
|
+
{
|
|
2336
|
+
"name": "pointer-parallax-scene",
|
|
2337
|
+
"title": "Pointer parallax scene",
|
|
2338
|
+
"tagline": "Layered depth that follows the pointer — many layers, one loop, zero re-renders.",
|
|
2339
|
+
"problem": "A hero with three to six layers drifting at different depths is the classic award-site opener — and the classic way to ship six pointermove listeners, six rAF loops, and a transform fight with whatever else animates the layers.",
|
|
2340
|
+
"uses": [
|
|
2341
|
+
"useSensorBus",
|
|
2342
|
+
"FrameConductor",
|
|
2343
|
+
"damp"
|
|
2344
|
+
],
|
|
2345
|
+
"wiring": [
|
|
2346
|
+
"One parent component retains the bus and owns ONE conductor subscription for all layers.",
|
|
2347
|
+
"Collect layer elements via a ref map keyed by depth — not one subscription per layer.",
|
|
2348
|
+
"In the render lane, damp a single normalized pointer offset, then write each layer's transform as offset × depth.",
|
|
2349
|
+
"Each layer element belongs to this effect alone — nothing else may animate its transform.",
|
|
2350
|
+
"Depth values stay small (2–20px of travel); parallax reads as depth, not as chase."
|
|
2351
|
+
],
|
|
2352
|
+
"code": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { getConductor, damp } from \"@vectorvesper/motion\";\nimport { useSensorBus } from \"@vectorvesper/motion/react\";\n\nconst LAYERS = [\n { depth: 4, src: \"/hero/sky.webp\" },\n { depth: 9, src: \"/hero/ridge.webp\" },\n { depth: 16, src: \"/hero/subject.webp\" },\n] as const;\n\nexport function ParallaxHero() {\n const layerRefs = useRef<(HTMLImageElement | null)[]>([]);\n const bus = useSensorBus();\n\n useEffect(() => {\n let nx = 0; // normalized -0.5..0.5\n let ny = 0;\n\n return getConductor().subscribe(\"render\", (dt) => {\n const { pointer, viewport } = bus.state;\n if (!pointer.seen || viewport.width === 0) return;\n\n nx = damp(nx, pointer.x / viewport.width - 0.5, 8, dt);\n ny = damp(ny, pointer.y / viewport.height - 0.5, 8, dt);\n\n for (let i = 0; i < LAYERS.length; i++) {\n const el = layerRefs.current[i];\n if (!el) continue;\n const d = LAYERS[i].depth;\n el.style.transform = `translate3d(${(-nx * d).toFixed(2)}px, ${(-ny * d).toFixed(2)}px, 0)`;\n }\n }, { priority: \"decorative\", label: \"ParallaxHero\" });\n }, [bus]);\n\n return (\n <section style={{ position: \"relative\", overflow: \"hidden\", height: \"80vh\" }}>\n {LAYERS.map((layer, i) => (\n <img\n key={layer.src}\n ref={(el) => { layerRefs.current[i] = el; }}\n src={layer.src}\n alt=\"\"\n aria-hidden\n style={{ position: \"absolute\", inset: \"-24px\", width: \"calc(100% + 48px)\", objectFit: \"cover\" }}\n />\n ))}\n </section>\n );\n}",
|
|
2353
|
+
"pitfalls": [
|
|
2354
|
+
{
|
|
2355
|
+
"mistake": "One conductor subscription (or worse, one rAF loop) per layer.",
|
|
2356
|
+
"consequence": "N subscriptions doing identical math. One subscription writing N transforms is the shape.",
|
|
2357
|
+
"rule": "orphan-raf"
|
|
2358
|
+
},
|
|
2359
|
+
{
|
|
2360
|
+
"mistake": "Also giving the layer images a CSS transition or animation on transform.",
|
|
2361
|
+
"consequence": "Two owners write the same transform once per frame — visible jitter, no error.",
|
|
2362
|
+
"rule": "transform-conflict"
|
|
2363
|
+
},
|
|
2364
|
+
{
|
|
2365
|
+
"mistake": "Storing the pointer offset in React state so layers re-render into place.",
|
|
2366
|
+
"consequence": "Full-tree reconciliation at frame rate for a purely visual drift.",
|
|
2367
|
+
"rule": "setstate-per-frame"
|
|
2368
|
+
},
|
|
2369
|
+
{
|
|
2370
|
+
"mistake": "Skipping the pointer.seen check.",
|
|
2371
|
+
"consequence": "Every layer jumps when the first real pointer event arrives, because (0,0) was treated as a position."
|
|
2372
|
+
}
|
|
2373
|
+
],
|
|
2374
|
+
"verify": [
|
|
2375
|
+
"check_motion on the file — expect zero findings.",
|
|
2376
|
+
"Devtools overlay: exactly ONE subscriber row (ParallaxHero) regardless of layer count, cost well under 1ms.",
|
|
2377
|
+
"Under CPU throttling, the layers may pause (decorative sheds) while the rest of the page stays smooth — that is the priority system working."
|
|
2378
|
+
],
|
|
2379
|
+
"aliases": [
|
|
2380
|
+
"parallax",
|
|
2381
|
+
"mouse parallax",
|
|
2382
|
+
"layered hero",
|
|
2383
|
+
"depth effect",
|
|
2384
|
+
"3d hero",
|
|
2385
|
+
"tilt scene"
|
|
2386
|
+
]
|
|
2387
|
+
},
|
|
2388
|
+
{
|
|
2389
|
+
"name": "predictive-media-card",
|
|
2390
|
+
"title": "Predictive media card",
|
|
2391
|
+
"tagline": "Decode the full-res image before the pointer arrives; drive the lift without renders.",
|
|
2392
|
+
"problem": "Hover reveals that decode on hover spend their first 200ms as a blur-to-sharp pop — the user watches the asset load. Prediction hides that latency, but only if the warm-up starts before arrival and the per-frame styling never touches React state.",
|
|
2393
|
+
"uses": [
|
|
2394
|
+
"usePointerIntent",
|
|
2395
|
+
"FrameConductor",
|
|
2396
|
+
"damp"
|
|
2397
|
+
],
|
|
2398
|
+
"wiring": [
|
|
2399
|
+
"usePointerIntent on the card: `intent` (React state, rare) triggers the warm-up; `confidenceRef` (per-frame, no renders) drives the visual.",
|
|
2400
|
+
"On intent: create an Image, set src to the full-res asset, await decode(), cache the result — idempotent, so a veer-away-and-return costs nothing.",
|
|
2401
|
+
"In the render lane, damp a lift value toward confidenceRef.current and write transform + a CSS variable on the card.",
|
|
2402
|
+
"The card element's transform belongs to this subscription alone.",
|
|
2403
|
+
"Show the decoded full-res only once ready — the reveal is then a cache hit, not a decode."
|
|
2404
|
+
],
|
|
2405
|
+
"code": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { getConductor, damp } from \"@vectorvesper/motion\";\nimport { usePointerIntent } from \"@vectorvesper/motion/react\";\n\nexport function PredictiveCard({ thumb, full, title }: { thumb: string; full: string; title: string }) {\n const { ref, intent, confidenceRef } = usePointerIntent<HTMLDivElement>({ extend: 24, horizon: 0.45 });\n const [ready, setReady] = useState(false);\n const warmed = useRef(false);\n\n // Warm-up: fires ~100–300ms before a normal hover would.\n useEffect(() => {\n if (!intent || warmed.current) return;\n warmed.current = true; // idempotent — veer away and come back for free\n const img = new Image();\n img.src = full;\n img.decode().then(() => setReady(true)).catch(() => {});\n }, [intent, full]);\n\n // Per-frame lift from confidence — zero React renders.\n useEffect(() => {\n let lift = 0;\n return getConductor().subscribe(\"render\", (dt) => {\n const el = ref.current;\n if (!el) return;\n lift = damp(lift, confidenceRef.current, 12, dt);\n el.style.transform = `translate3d(0, ${(-6 * lift).toFixed(2)}px, 0) scale(${(1 + 0.02 * lift).toFixed(4)})`;\n el.style.setProperty(\"--glow\", lift.toFixed(3));\n }, { priority: \"enhanced\", label: \"PredictiveCard\" });\n }, [ref, confidenceRef]);\n\n return (\n <div ref={ref} style={{ position: \"relative\", borderRadius: 12, overflow: \"hidden\" }}>\n <img src={thumb} alt={title} style={{ width: \"100%\", display: \"block\" }} />\n {ready && (\n <img\n src={full}\n alt=\"\"\n aria-hidden\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n )}\n <span style={{ boxShadow: \"0 0 calc(var(--glow, 0) * 32px) rgba(255,255,255,0.25)\" }} />\n </div>\n );\n}",
|
|
2406
|
+
"pitfalls": [
|
|
2407
|
+
{
|
|
2408
|
+
"mistake": "Rendering confidence into JSX ({confidenceRef.current}) or mirroring it into state.",
|
|
2409
|
+
"consequence": "Either it never updates (refs don't re-render) or it re-renders per frame. Confidence is a frame value: consume it inside the conductor.",
|
|
2410
|
+
"rule": "setstate-per-frame"
|
|
2411
|
+
},
|
|
2412
|
+
{
|
|
2413
|
+
"mistake": "Re-running the decode on every intent flip.",
|
|
2414
|
+
"consequence": "Approach, veer away, return = three decodes of the same asset. Latch the warm-up with a ref."
|
|
2415
|
+
},
|
|
2416
|
+
{
|
|
2417
|
+
"mistake": "Animating the card's transform with CSS :hover as well.",
|
|
2418
|
+
"consequence": "Two transform owners fight once per frame — jitter with no error.",
|
|
2419
|
+
"rule": "transform-conflict"
|
|
2420
|
+
},
|
|
2421
|
+
{
|
|
2422
|
+
"mistake": "Using this on a dense grid of small cards with default extend.",
|
|
2423
|
+
"consequence": "Neighbouring prediction zones overlap and several cards warm at once. Cheap prefetches can absorb that; heavy decodes cannot — shrink extend or gate on proximity."
|
|
2424
|
+
}
|
|
2425
|
+
],
|
|
2426
|
+
"verify": [
|
|
2427
|
+
"check_motion on the file — expect zero findings.",
|
|
2428
|
+
"Network/decode proof: approach the card slowly from a distance — the full-res request must fire before the cursor crosses the card edge.",
|
|
2429
|
+
"Devtools: PredictiveCard row present; cost under ~0.5ms; zero React renders while moving the pointer around the card (React DevTools highlight updates stays quiet)."
|
|
2430
|
+
],
|
|
2431
|
+
"aliases": [
|
|
2432
|
+
"hover preview",
|
|
2433
|
+
"preload on hover",
|
|
2434
|
+
"image preview card",
|
|
2435
|
+
"predictive hover",
|
|
2436
|
+
"prefetch card",
|
|
2437
|
+
"gallery card"
|
|
2438
|
+
]
|
|
2439
|
+
},
|
|
2440
|
+
{
|
|
2441
|
+
"name": "scrollytelling-video",
|
|
2442
|
+
"title": "Scrollytelling video section",
|
|
2443
|
+
"tagline": "A sticky film scrubbed by scroll, deferred until it approaches the viewport.",
|
|
2444
|
+
"problem": "The Apple-style scroll film has two failure modes: the video initialises during page load three screens before anyone sees it, and the scrub stutters because seeks queue faster than the decoder serves them. Each hook solves one half; the pattern is the order.",
|
|
2445
|
+
"uses": [
|
|
2446
|
+
"useLazyScene",
|
|
2447
|
+
"useVideoScrubber"
|
|
2448
|
+
],
|
|
2449
|
+
"wiring": [
|
|
2450
|
+
"Outer component: useLazyScene with a generous rootMargin (400–600px) — the video element does not exist until the section approaches.",
|
|
2451
|
+
"Render a poster in the not-ready state with the SAME track height, so mounting the scrubber never shifts layout.",
|
|
2452
|
+
"Inner component (mounts only when ready): useVideoScrubber with driver \"scroll\" and mapping \"pin\"; trackRef on the tall section, videoRef on the video inside a sticky container.",
|
|
2453
|
+
"Track height 250–400vh — that is the scrub resolution; each second of film needs scroll distance.",
|
|
2454
|
+
"Encode a dedicated scrub asset with a keyframe every frame (ffmpeg -g 1) — the hook throttles slow seeks but cannot make a long-GOP decode cheap."
|
|
2455
|
+
],
|
|
2456
|
+
"code": "\"use client\";\n\nimport { useLazyScene, useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nconst TRACK_HEIGHT = \"300vh\";\n\nexport function FilmSection() {\n const { ref, ready } = useLazyScene<HTMLDivElement>({ rootMargin: \"500px\" });\n\n return (\n <div ref={ref}>\n {ready ? (\n <ScrubbedFilm />\n ) : (\n // Same height as the live track: mounting must not shift layout.\n <section style={{ height: TRACK_HEIGHT }}>\n <div style={{ position: \"sticky\", top: 0, height: \"100vh\" }}>\n <img src=\"/film/poster.webp\" alt=\"Product film\" style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }} />\n </div>\n </section>\n )}\n </div>\n );\n}\n\nfunction ScrubbedFilm() {\n const { videoRef, trackRef } = useVideoScrubber<HTMLElement>({\n driver: \"scroll\",\n mapping: \"pin\",\n smooth: 8,\n });\n\n return (\n <section ref={trackRef} style={{ height: TRACK_HEIGHT }}>\n <div style={{ position: \"sticky\", top: 0, height: \"100vh\" }}>\n {/* Dedicated scrub encode: ffmpeg -i in.mp4 -g 1 -coder 0 -bf 0 -crf 20 -movflags +faststart scrub.mp4 */}\n <video\n ref={videoRef}\n src=\"/film/scrub.mp4\"\n muted\n playsInline\n preload=\"auto\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n </div>\n </section>\n );\n}",
|
|
2457
|
+
"pitfalls": [
|
|
2458
|
+
{
|
|
2459
|
+
"mistake": "Mounting the video unconditionally and only lazy-loading the src.",
|
|
2460
|
+
"consequence": "The element, its buffer, and the iOS priming all initialise during page load, competing with the content the user is actually reading."
|
|
2461
|
+
},
|
|
2462
|
+
{
|
|
2463
|
+
"mistake": "Shipping the marketing hero encode as the scrub asset.",
|
|
2464
|
+
"consequence": "Long-GOP video forces the decoder to reconstruct dozens of frames per seek — smooth on the build machine, a slideshow on mid-range hardware. The hook will console-warn with the exact ffmpeg line when it measures slow seeks."
|
|
2465
|
+
},
|
|
2466
|
+
{
|
|
2467
|
+
"mistake": "Setting video.currentTime yourself from a scroll listener alongside the hook.",
|
|
2468
|
+
"consequence": "Two seek issuers — the queueing stutter the scrubber exists to prevent, reintroduced.",
|
|
2469
|
+
"rule": "transform-conflict"
|
|
2470
|
+
},
|
|
2471
|
+
{
|
|
2472
|
+
"mistake": "A poster whose height differs from the live track.",
|
|
2473
|
+
"consequence": "The whole page reflows the moment the scrubber mounts, usually mid-scroll."
|
|
2474
|
+
}
|
|
2475
|
+
],
|
|
2476
|
+
"verify": [
|
|
2477
|
+
"check_motion on both files — expect zero findings.",
|
|
2478
|
+
"Network tab: scrub.mp4 must not load until you scroll within ~500px of the section.",
|
|
2479
|
+
"Scrub up and down fast: no console slow-seek warning means the encode is right.",
|
|
2480
|
+
"Devtools overlay: VideoScrubber runs at essential priority — under load, ambient effects shed while the scrub stays glued to the finger."
|
|
2481
|
+
],
|
|
2482
|
+
"aliases": [
|
|
2483
|
+
"scrollytelling",
|
|
2484
|
+
"scroll video",
|
|
2485
|
+
"sticky video",
|
|
2486
|
+
"apple scroll",
|
|
2487
|
+
"video scrub section",
|
|
2488
|
+
"product film"
|
|
2489
|
+
]
|
|
2490
|
+
},
|
|
2491
|
+
{
|
|
2492
|
+
"name": "velocity-marquee",
|
|
2493
|
+
"title": "Velocity-reactive marquee",
|
|
2494
|
+
"tagline": "A marquee that speeds up and skews with scroll velocity.",
|
|
2495
|
+
"problem": "A constant CSS marquee reads as a GIF. The award-site version reacts to how hard the user is scrolling — speed and skew ride scroll velocity — which needs a damped velocity signal and a frame loop, the two things a naive implementation builds badly twice.",
|
|
2496
|
+
"uses": [
|
|
2497
|
+
"useSensorBus",
|
|
2498
|
+
"FrameConductor",
|
|
2499
|
+
"damp"
|
|
2500
|
+
],
|
|
2501
|
+
"wiring": [
|
|
2502
|
+
"Render the strip content twice inside an overflow-hidden track so the wrap is seamless.",
|
|
2503
|
+
"Read the damped scroll velocity from the bus — never attach a scroll listener or compute deltas yourself.",
|
|
2504
|
+
"In the render lane, advance a position by baseSpeed + |velocity| × gain each frame, wrap with modulo, and write one transform.",
|
|
2505
|
+
"Skew by a clamped fraction of velocity for the ink-drag feel; both writes go on the same element from this one owner.",
|
|
2506
|
+
"Under prefers-reduced-motion, do not autoplay: render the strip static (this is autonomous motion)."
|
|
2507
|
+
],
|
|
2508
|
+
"code": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { getConductor, damp } from \"@vectorvesper/motion\";\nimport { useSensorBus } from \"@vectorvesper/motion/react\";\n\nexport function VelocityMarquee({ children }: { children: React.ReactNode }) {\n const stripRef = useRef<HTMLDivElement>(null);\n const bus = useSensorBus();\n\n useEffect(() => {\n const strip = stripRef.current;\n if (!strip) return;\n // Autonomous motion: honor the preference by not animating at all.\n if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n\n let x = 0;\n let skew = 0;\n let half = 0;\n\n // Measure the strip in the input lane; write transforms in render.\n const offMeasure = getConductor().subscribe(\"input\", () => {\n half = strip.scrollWidth / 2 || 1;\n }, { hz: 2, label: \"VelocityMarquee(measure)\" });\n\n const offRender = getConductor().subscribe(\"render\", (dt) => {\n const vy = bus.state.scroll.vy; // damped px/s, precomputed by the bus\n const speed = 90 + Math.min(Math.abs(vy) * 0.35, 600); // px/s\n x = (x + speed * dt) % half;\n skew = damp(skew, Math.max(-10, Math.min(10, vy / 220)), 10, dt);\n strip.style.transform = `translate3d(${(-x).toFixed(1)}px, 0, 0) skewX(${skew.toFixed(2)}deg)`;\n }, { priority: \"decorative\", label: \"VelocityMarquee\" });\n\n return () => { offMeasure(); offRender(); };\n }, [bus]);\n\n return (\n <div style={{ overflow: \"hidden\", whiteSpace: \"nowrap\" }}>\n <div ref={stripRef} style={{ display: \"inline-flex\", gap: \"3rem\", willChange: \"transform\" }}>\n <span>{children}</span>\n <span aria-hidden>{children}</span>\n </div>\n </div>\n );\n}",
|
|
2509
|
+
"pitfalls": [
|
|
2510
|
+
{
|
|
2511
|
+
"mistake": "Attaching a scroll listener and differencing scrollY yourself.",
|
|
2512
|
+
"consequence": "Raw scroll deltas are spiky and browser-throttled differently; the bus's damped velocity is computed once per frame for everyone — duplicating it is wasted work with worse feel."
|
|
2513
|
+
},
|
|
2514
|
+
{
|
|
2515
|
+
"mistake": "Running the advance in a private requestAnimationFrame loop.",
|
|
2516
|
+
"consequence": "Invisible to the scheduler and devtools; drifts against every other effect's clock.",
|
|
2517
|
+
"rule": "orphan-raf"
|
|
2518
|
+
},
|
|
2519
|
+
{
|
|
2520
|
+
"mistake": "Autoplaying under prefers-reduced-motion.",
|
|
2521
|
+
"consequence": "A permanently moving strip for users who explicitly asked for less motion.",
|
|
2522
|
+
"rule": "no-reduced-motion"
|
|
2523
|
+
},
|
|
2524
|
+
{
|
|
2525
|
+
"mistake": "Measuring scrollWidth in the render callback each frame.",
|
|
2526
|
+
"consequence": "A layout read after style writes — forced synchronous layout at frame rate."
|
|
2527
|
+
}
|
|
2528
|
+
],
|
|
2529
|
+
"verify": [
|
|
2530
|
+
"check_motion on the file — expect zero findings.",
|
|
2531
|
+
"Devtools: VelocityMarquee at decorative priority; flick-scroll and watch its cost stay put while the strip accelerates.",
|
|
2532
|
+
"Toggle OS reduced-motion and reload: the strip must render static."
|
|
2533
|
+
],
|
|
2534
|
+
"aliases": [
|
|
2535
|
+
"marquee",
|
|
2536
|
+
"ticker tape",
|
|
2537
|
+
"scrolling text",
|
|
2538
|
+
"infinite scroll strip",
|
|
2539
|
+
"scroll skew",
|
|
2540
|
+
"logo wall"
|
|
2541
|
+
]
|
|
973
2542
|
}
|
|
974
2543
|
]
|
|
975
2544
|
}
|