vectorvesper 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks.json +2314 -244
- package/dist/index.js +200 -67
- package/dist/mcp-H5YYBZ5V.js +300 -0
- package/dist/{server-WQ564YNA.js → server-KJ3YQ7G4.js} +497 -33
- package/package.json +1 -1
- package/dist/mcp-FCM3FZSK.js +0 -116
package/dist/hooks.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "1.0.0",
|
|
3
|
-
"engineVersion": "
|
|
4
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"engineVersion": "4.0.0",
|
|
4
|
+
"generatedAt": "2026-08-27T13:26:41.858Z",
|
|
5
5
|
"contractLevel": "full",
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
@@ -22,11 +22,12 @@
|
|
|
22
22
|
"quality signal"
|
|
23
23
|
],
|
|
24
24
|
"exports": [
|
|
25
|
-
"getAdaptiveQuality"
|
|
25
|
+
"getAdaptiveQuality",
|
|
26
|
+
"fuse"
|
|
26
27
|
],
|
|
27
28
|
"tagline": "Device floor fused with live frame health — the core accessor.",
|
|
28
29
|
"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
|
|
30
|
+
"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 device floor is probed lazily on first use (WebGL2, renderer class, memory, cores) and never changes while the page is open. Every reading carries a `cause` naming why the tier is what it is. `fuse` is the same rule as a pure function, exported so a devtools panel or a docs page explaining a verdict does not have to keep a copy of it. In React, prefer the useAdaptiveQuality hook.",
|
|
30
31
|
"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
32
|
"signature": "getAdaptiveQuality().subscribe(fn: (s: AdaptiveState) => void): () => void · .state: AdaptiveState",
|
|
32
33
|
"options": [],
|
|
@@ -89,8 +90,8 @@
|
|
|
89
90
|
"guardrails": [
|
|
90
91
|
"practices/frame-loop-rules.md"
|
|
91
92
|
],
|
|
92
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
93
|
-
"labUrl": "https://vectorvesper.dev/
|
|
93
|
+
"docsUrl": "https://vectorvesper.dev/runtime/adaptive-quality",
|
|
94
|
+
"labUrl": "https://vectorvesper.dev/runtime/adaptive-quality",
|
|
94
95
|
"disclosure": {
|
|
95
96
|
"exposeImplementation": false,
|
|
96
97
|
"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.",
|
|
@@ -189,8 +190,160 @@
|
|
|
189
190
|
"guardrails": [
|
|
190
191
|
"practices/frame-loop-rules.md"
|
|
191
192
|
],
|
|
192
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
193
|
-
"labUrl": "https://vectorvesper.dev/
|
|
193
|
+
"docsUrl": "https://vectorvesper.dev/runtime/animation-budget",
|
|
194
|
+
"labUrl": "https://vectorvesper.dev/runtime/animation-budget",
|
|
195
|
+
"disclosure": {
|
|
196
|
+
"exposeImplementation": false,
|
|
197
|
+
"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.",
|
|
198
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
199
|
+
"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."
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"name": "EffectEngines",
|
|
204
|
+
"importFrom": "@vectorvesper/motion",
|
|
205
|
+
"packageName": "@vectorvesper/motion",
|
|
206
|
+
"since": "3.0.0",
|
|
207
|
+
"category": "core",
|
|
208
|
+
"tier": "free",
|
|
209
|
+
"aliases": [
|
|
210
|
+
"PointerIntent",
|
|
211
|
+
"MagneticElement",
|
|
212
|
+
"VideoScrubber",
|
|
213
|
+
"scrollProgress",
|
|
214
|
+
"vue",
|
|
215
|
+
"svelte",
|
|
216
|
+
"vanilla js",
|
|
217
|
+
"without react",
|
|
218
|
+
"no framework",
|
|
219
|
+
"framework agnostic",
|
|
220
|
+
"effect classes",
|
|
221
|
+
"imperative effects"
|
|
222
|
+
],
|
|
223
|
+
"exports": [
|
|
224
|
+
"PointerIntent",
|
|
225
|
+
"MagneticElement",
|
|
226
|
+
"VideoScrubber",
|
|
227
|
+
"POINTER_INTENT_SENSITIVITY",
|
|
228
|
+
"VIDEO_SCRUBBER_DEFAULTS",
|
|
229
|
+
"scrollProgress"
|
|
230
|
+
],
|
|
231
|
+
"tagline": "The effect classes, for Vue, Svelte, vanilla JS, and any code without React.",
|
|
232
|
+
"problem": "The React hooks are the only documented way to use the effects, so a Vue or Svelte project gets the frame conductor, the sensors, the governors and the maths, and then has to rewrite every effect by hand.",
|
|
233
|
+
"summary": "Three classes with the same shape: construct with an element and options, call update to change options, call destroy to release everything. They ride the same shared conductor and sensor bus the hooks use, so mixing them with React code on one page costs nothing extra. In React, prefer the hooks, which handle construction and teardown for you.",
|
|
234
|
+
"mechanism": "The engines were always here; until 3.0 they were internal while their option types were public, so a non-React consumer could name the options and had no way to construct the thing they configured.\n\nAll three follow one lifecycle. The constructor takes the element and options, subscribes to the conductor, and retains the sensor bus where it needs pointer or scroll data. `update` accepts a partial set of options and applies them live, so a reactive framework can push prop changes without rebuilding. `destroy` unsubscribes, releases the bus and restores anything it changed on the element. That last part matters for the video scrubber, which records muted, playsInline and preload before overwriting them and puts them back exactly.\n\nMagneticElement composes PointerIntent rather than duplicating the proximity maths, so engagement is the larger of the intent confidence and raw proximity. That is the same arrangement the hooks use, because the hooks are thin wrappers over these.\n\nThe frozen tables are exported for the same reason the rule behind AdaptiveQuality is: a devtools panel or a documentation page explaining why a preset behaves a certain way needs the real numbers, and the alternative is every such surface keeping a copy that drifts.",
|
|
235
|
+
"signature": "new PointerIntent(el, options?, onChange?) · new MagneticElement(el, options?) · new VideoScrubber(video, options?) · each has .update(options) and .destroy()",
|
|
236
|
+
"options": [
|
|
237
|
+
{
|
|
238
|
+
"name": "PointerIntent",
|
|
239
|
+
"type": "new (el: HTMLElement, options?: PointerIntentOptions, onChange?: (intent: boolean) => void)",
|
|
240
|
+
"default": "(class)",
|
|
241
|
+
"required": false,
|
|
242
|
+
"description": "Predicts the pointer is heading for an element before it arrives. Exposes a confidence between 0 and 1. See the usePointerIntent entry for what each option does."
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"name": "MagneticElement",
|
|
246
|
+
"type": "new (el: HTMLElement, options?: MagneticOptions)",
|
|
247
|
+
"default": "(class)",
|
|
248
|
+
"required": false,
|
|
249
|
+
"description": "Pulls an element toward the pointer, owning its transform. See useMagneticIntent for the options and for what it conflicts with."
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"name": "VideoScrubber",
|
|
253
|
+
"type": "new (video: HTMLVideoElement, options?: VideoScrubberOptions)",
|
|
254
|
+
"default": "(class)",
|
|
255
|
+
"required": false,
|
|
256
|
+
"description": "Drives a video timeline from scroll or pointer with seek discipline. See useVideoScrubber for the options."
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
"name": "POINTER_INTENT_SENSITIVITY",
|
|
260
|
+
"type": "Readonly<Record<PointerIntentSensitivity, PointerIntentTuning>>",
|
|
261
|
+
"default": "(frozen table)",
|
|
262
|
+
"required": false,
|
|
263
|
+
"description": "What each sensitivity preset resolves to, readable so a panel can explain a preset."
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"name": "VIDEO_SCRUBBER_DEFAULTS",
|
|
267
|
+
"type": "Readonly<VideoScrubberOptions>",
|
|
268
|
+
"default": "(frozen table)",
|
|
269
|
+
"required": false,
|
|
270
|
+
"description": "The scrubber's default driver, mapping, speed and pointer axis."
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
"name": "scrollProgress",
|
|
274
|
+
"type": "(top: number, height: number, viewportHeight: number, mapping: ScrubMapping) => number",
|
|
275
|
+
"default": "(function)",
|
|
276
|
+
"required": false,
|
|
277
|
+
"description": "The pure scroll-to-progress mapping the scrubber uses, for driving something else from the same curve."
|
|
278
|
+
}
|
|
279
|
+
],
|
|
280
|
+
"returns": [],
|
|
281
|
+
"runtime": {
|
|
282
|
+
"lane": "render",
|
|
283
|
+
"priority": "enhanced",
|
|
284
|
+
"requiresClient": true,
|
|
285
|
+
"ssrSafeImport": true,
|
|
286
|
+
"respectsReducedMotion": true,
|
|
287
|
+
"reRendersPerFrame": 0,
|
|
288
|
+
"usesPointer": true,
|
|
289
|
+
"usesScroll": true,
|
|
290
|
+
"usesWebGL": false,
|
|
291
|
+
"ownsTransform": true,
|
|
292
|
+
"sharedSingletons": [
|
|
293
|
+
"FrameConductor",
|
|
294
|
+
"SensorBus"
|
|
295
|
+
],
|
|
296
|
+
"conflictsWith": [],
|
|
297
|
+
"pairsWith": [
|
|
298
|
+
"FrameConductor",
|
|
299
|
+
"SensorBus",
|
|
300
|
+
"damp"
|
|
301
|
+
]
|
|
302
|
+
},
|
|
303
|
+
"quickStart": "import { MagneticElement } from \"@vectorvesper/motion\";\n\n// Take the element rather than looking it up. Module scope runs during server\n// rendering, where there is no document to query.\nexport function attachMagnet(el) {\n const magnet = new MagneticElement(el, { strength: 30, reach: 220 });\n return () => magnet.destroy();\n}",
|
|
304
|
+
"recipes": [
|
|
305
|
+
{
|
|
306
|
+
"name": "Vue",
|
|
307
|
+
"blurb": "Construct on mount, destroy on unmount. The same shape works for every engine.",
|
|
308
|
+
"code": "<script setup>\nimport { ref, onMounted, onUnmounted } from \"vue\";\nimport { MagneticElement } from \"@vectorvesper/motion\";\n\nconst el = ref(null);\nlet magnet;\n\nonMounted(() => {\n magnet = new MagneticElement(el.value, { strength: 30, reach: 220 });\n});\n\nonUnmounted(() => magnet?.destroy());\n</script>\n\n<template>\n <button ref=\"el\">Get started</button>\n</template>"
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
"name": "Svelte",
|
|
312
|
+
"blurb": "onMount returns its own cleanup, so the whole lifecycle is four lines.",
|
|
313
|
+
"code": "<script>\n import { onMount } from \"svelte\";\n import { PointerIntent } from \"@vectorvesper/motion\";\n\n let el;\n let warming = false;\n\n onMount(() => {\n const intent = new PointerIntent(el, { sensitivity: \"high\" }, (v) => (warming = v));\n return () => intent.destroy();\n });\n</script>\n\n<a bind:this={el} href=\"/product\">Product {warming ? \"(prefetching)\" : \"\"}</a>"
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
"name": "Reacting to option changes",
|
|
317
|
+
"blurb": "update applies live, so a reactive framework never has to rebuild the engine.",
|
|
318
|
+
"code": "import { MagneticElement } from \"@vectorvesper/motion\";\n\nconst magnet = new MagneticElement(el, { strength: 30 });\n\n// later, when a prop changes\nmagnet.update({ strength: 12 });"
|
|
319
|
+
}
|
|
320
|
+
],
|
|
321
|
+
"dos": [
|
|
322
|
+
"Call destroy on teardown. It unsubscribes from the conductor, releases the sensor bus and restores anything it changed on the element.",
|
|
323
|
+
"Use update rather than rebuilding when an option changes.",
|
|
324
|
+
"Prefer the React hooks inside React. They are thin wrappers over exactly these classes and handle the lifecycle for you.",
|
|
325
|
+
"Construct on mount, not at module scope. These touch the DOM, and module scope runs during server rendering."
|
|
326
|
+
],
|
|
327
|
+
"donts": [
|
|
328
|
+
"Don't animate the transform of an element a MagneticElement owns. Two owners of one transform is a fight, and the frame loop wins.",
|
|
329
|
+
"Don't construct one per pointer move. They are long-lived objects that subscribe once.",
|
|
330
|
+
"Don't skip destroy because the element is going away. The conductor subscription outlives the element and keeps the loop running."
|
|
331
|
+
],
|
|
332
|
+
"whenNotToUse": [
|
|
333
|
+
{
|
|
334
|
+
"when": "You are writing React.",
|
|
335
|
+
"instead": "usePointerIntent, useMagneticIntent or useVideoScrubber. Same engines, with construction and teardown handled."
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
"when": "You want the scene policy, the governors or the sensors rather than an effect.",
|
|
339
|
+
"instead": "Those are separate core entries and are not classes: getConductor, getSensorBus, getAnimationBudget, getAdaptiveQuality, getFramePressure and getRendererHealth are all accessors on the same core import."
|
|
340
|
+
}
|
|
341
|
+
],
|
|
342
|
+
"guardrails": [
|
|
343
|
+
"practices/react-motion-rules.md",
|
|
344
|
+
"practices/adaptive-performance.md"
|
|
345
|
+
],
|
|
346
|
+
"docsUrl": "https://vectorvesper.dev/runtime",
|
|
194
347
|
"disclosure": {
|
|
195
348
|
"exposeImplementation": false,
|
|
196
349
|
"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.",
|
|
@@ -225,7 +378,7 @@
|
|
|
225
378
|
"tagline": "The one requestAnimationFrame loop for the page, with a scheduler inside.",
|
|
226
379
|
"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
380
|
"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
|
|
381
|
+
"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 as `state`, rendered by the devtools overlay). One broken subscriber cannot kill the heartbeat: exceptions are caught, reported once per subscriber, and the loop continues.",
|
|
229
382
|
"signature": "getConductor().subscribe(lane: \"input\" | \"update\" | \"render\", fn: (dt: number, time: number) => void, options?: SubscribeOptions): () => void",
|
|
230
383
|
"options": [
|
|
231
384
|
{
|
|
@@ -295,8 +448,8 @@
|
|
|
295
448
|
},
|
|
296
449
|
{
|
|
297
450
|
"name": "Development diagnostics",
|
|
298
|
-
"blurb": "configure() and
|
|
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 —
|
|
451
|
+
"blurb": "configure() and `state` — find the subscriber eating the frame.",
|
|
452
|
+
"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 — reading .state allocates a snapshot, so never do it per frame.\nconst hud = setInterval(() => {\n const stats = getConductor().state;\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);\n\n// Keep the handle. An interval left running holds every subscriber it names.\nexport const stopHud = () => clearInterval(hud);"
|
|
300
453
|
}
|
|
301
454
|
],
|
|
302
455
|
"dos": [
|
|
@@ -310,7 +463,7 @@
|
|
|
310
463
|
"Don't call requestAnimationFrame yourself anywhere in a Vector Vesper project.",
|
|
311
464
|
"Don't setState inside a frame callback — that is a React render per frame.",
|
|
312
465
|
"Don't read layout in the render lane; a read after earlier writes forces synchronous layout.",
|
|
313
|
-
"Don't
|
|
466
|
+
"Don't read `state` inside a frame callback — it allocates a snapshot; poll it a few times a second instead.",
|
|
314
467
|
"Don't mark your effect essential to dodge shedding; essential is for sensors, governors and direct manipulation."
|
|
315
468
|
],
|
|
316
469
|
"whenNotToUse": [
|
|
@@ -336,8 +489,215 @@
|
|
|
336
489
|
"practices/layout-read-discipline.md",
|
|
337
490
|
"practices/react-motion-rules.md"
|
|
338
491
|
],
|
|
339
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
340
|
-
"labUrl": "https://vectorvesper.dev/
|
|
492
|
+
"docsUrl": "https://vectorvesper.dev/runtime/frame-conductor",
|
|
493
|
+
"labUrl": "https://vectorvesper.dev/runtime/frame-conductor",
|
|
494
|
+
"disclosure": {
|
|
495
|
+
"exposeImplementation": false,
|
|
496
|
+
"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.",
|
|
497
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
498
|
+
"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."
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
"name": "FramePressure",
|
|
503
|
+
"importFrom": "@vectorvesper/motion",
|
|
504
|
+
"packageName": "@vectorvesper/motion",
|
|
505
|
+
"since": "2.0.0",
|
|
506
|
+
"category": "core",
|
|
507
|
+
"tier": "free",
|
|
508
|
+
"aliases": [
|
|
509
|
+
"getFramePressure",
|
|
510
|
+
"frame pressure core",
|
|
511
|
+
"what is eating the frame",
|
|
512
|
+
"blame attribution",
|
|
513
|
+
"jank source"
|
|
514
|
+
],
|
|
515
|
+
"exports": [
|
|
516
|
+
"getFramePressure"
|
|
517
|
+
],
|
|
518
|
+
"tagline": "The frame-attribution classifier's core accessor.",
|
|
519
|
+
"problem": "The React hook is state, and state cannot be read inside a frame callback without either holding a snapshot that is stale immediately or re-rendering every frame. Non-React code has no hook to call at all.",
|
|
520
|
+
"summary": "getFramePressure() returns the classifier singleton. `.state` is safe to read inside a frame callback and is always current. `.subscribe(fn)` delivers each new verdict at most about twice a second and returns an unsubscribe. In React, prefer useFramePressure.",
|
|
521
|
+
"mechanism": "The same classifier the hook reads, exposed directly. It splits each frame into this runtime's own subscriber time, other main-thread time sampled with a message-channel probe, and an unattributed remainder, then names the largest with a confidence.\n\nThe distinction that matters for the accessor is cost. Subscribing delivers a callback at roughly two hertz, while reading state costs nothing and allocates nothing, so a frame callback should read rather than subscribe. Subscribing exists for code that wants to act when a verdict changes rather than poll for it.",
|
|
522
|
+
"signature": "getFramePressure(): FramePressure · .state: PressureState · .subscribe(fn): () => void",
|
|
523
|
+
"options": [],
|
|
524
|
+
"returns": [
|
|
525
|
+
{
|
|
526
|
+
"name": "state",
|
|
527
|
+
"type": "PressureState",
|
|
528
|
+
"description": "The current verdict. Safe to read every frame, and allocates nothing."
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
"name": "subscribe",
|
|
532
|
+
"type": "(fn: (state: PressureState) => void) => () => void",
|
|
533
|
+
"description": "Calls fn with each new verdict, at most about twice a second. Returns a function that unsubscribes."
|
|
534
|
+
}
|
|
535
|
+
],
|
|
536
|
+
"runtime": {
|
|
537
|
+
"lane": "input",
|
|
538
|
+
"priority": "essential",
|
|
539
|
+
"requiresClient": true,
|
|
540
|
+
"ssrSafeImport": true,
|
|
541
|
+
"respectsReducedMotion": false,
|
|
542
|
+
"reRendersPerFrame": 0,
|
|
543
|
+
"usesPointer": false,
|
|
544
|
+
"usesScroll": false,
|
|
545
|
+
"usesWebGL": false,
|
|
546
|
+
"ownsTransform": false,
|
|
547
|
+
"sharedSingletons": [
|
|
548
|
+
"FrameConductor",
|
|
549
|
+
"FramePressure"
|
|
550
|
+
],
|
|
551
|
+
"conflictsWith": [],
|
|
552
|
+
"pairsWith": [
|
|
553
|
+
"FrameConductor",
|
|
554
|
+
"AdaptiveQuality",
|
|
555
|
+
"mountDevtools"
|
|
556
|
+
]
|
|
557
|
+
},
|
|
558
|
+
"quickStart": "import { getConductor, getFramePressure } from \"@vectorvesper/motion\";\n\nconst off = getConductor().subscribe(\"render\", () => {\n const { source, confidence } = getFramePressure().state;\n if (source === \"render\" && confidence > 0.4) drawCheaply();\n else drawNormally();\n}, { priority: \"decorative\", label: \"adaptive draw\" });",
|
|
559
|
+
"recipes": [
|
|
560
|
+
{
|
|
561
|
+
"name": "Acting on a verdict change",
|
|
562
|
+
"blurb": "Subscribe when you want to react to the change rather than read it every frame.",
|
|
563
|
+
"code": "import { getFramePressure } from \"@vectorvesper/motion\";\n\nconst off = getFramePressure().subscribe(({ source, confidence }) => {\n if (source === \"main-thread\" && confidence > 0.6) {\n console.warn(\"Something outside the runtime is blocking the thread.\");\n }\n});\n\n// later\noff();"
|
|
564
|
+
}
|
|
565
|
+
],
|
|
566
|
+
"dos": [
|
|
567
|
+
"Read `.state` inside frame callbacks. It allocates nothing and is always current.",
|
|
568
|
+
"Check `confidence` before acting on `source`. A verdict at 0.2 is a coin flip with a name on it.",
|
|
569
|
+
"Prefer useFramePressure inside React components, and this accessor everywhere else."
|
|
570
|
+
],
|
|
571
|
+
"donts": [
|
|
572
|
+
"Don't reduce quality on a main-thread or runtime verdict. Neither is fixed by drawing less.",
|
|
573
|
+
"Don't read `unattributedMs` as GPU time. It is a remainder, and measurement error lands in it.",
|
|
574
|
+
"Don't subscribe from inside a frame callback. Subscribing is for reacting to change, not polling."
|
|
575
|
+
],
|
|
576
|
+
"whenNotToUse": [
|
|
577
|
+
{
|
|
578
|
+
"when": "You are in a React component and not inside a frame callback.",
|
|
579
|
+
"instead": "useFramePressure, which hands you the same object as React state."
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
"when": "You want to know which specific effect is expensive.",
|
|
583
|
+
"instead": "mountDevtools, which lists every subscriber with its own measured cost. This attributes the frame in three broad buckets and never names an effect."
|
|
584
|
+
}
|
|
585
|
+
],
|
|
586
|
+
"guardrails": [
|
|
587
|
+
"practices/adaptive-performance.md"
|
|
588
|
+
],
|
|
589
|
+
"docsUrl": "https://vectorvesper.dev/runtime/frame-pressure",
|
|
590
|
+
"disclosure": {
|
|
591
|
+
"exposeImplementation": false,
|
|
592
|
+
"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.",
|
|
593
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
594
|
+
"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."
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
"name": "RendererHealth",
|
|
599
|
+
"importFrom": "@vectorvesper/motion",
|
|
600
|
+
"packageName": "@vectorvesper/motion",
|
|
601
|
+
"since": "2.0.0",
|
|
602
|
+
"category": "core",
|
|
603
|
+
"tier": "free",
|
|
604
|
+
"aliases": [
|
|
605
|
+
"getRendererHealth",
|
|
606
|
+
"webglcontextlost",
|
|
607
|
+
"context lost",
|
|
608
|
+
"context restored",
|
|
609
|
+
"canvas recovery",
|
|
610
|
+
"generation counter",
|
|
611
|
+
"black canvas"
|
|
612
|
+
],
|
|
613
|
+
"exports": [
|
|
614
|
+
"getRendererHealth"
|
|
615
|
+
],
|
|
616
|
+
"tagline": "Notices a lost graphics context and counts a generation to remount on.",
|
|
617
|
+
"problem": "A browser can take a WebGL context away at any moment: a driver reset, a backgrounded tab, too many live contexts on one page. React Three Fiber has no handler for it, so what a visitor gets is a permanently black canvas and a completely clean console.",
|
|
618
|
+
"summary": "getRendererHealth() returns a page-level singleton holding whether a context is currently lost and a generation number that increments on each loss. Put that generation on a canvas key and React throws the dead tree away and builds a working one.",
|
|
619
|
+
"mechanism": "A counter and a boolean, deliberately small. No context is created or restored here: something that owns a renderer reports a loss, the generation moves, and whatever renders the canvas is expected to rebuild.\n\nIt exposes a generation rather than a restored event because the restore path is not reliable across drivers. Some refuse a replacement context on the page entirely if the loss event was not cancelled, which is why whoever attaches the listener has to call preventDefault before reporting. Rebuilding from a key sidesteps the question: a fresh context, with every texture re-uploaded from the same declarative tree, which is the kind of rebuild React is already good at.\n\nOn the r3f entry, useRenderQuality attaches that listener and reports for you, so most consumers never touch this and only read the scene gate's generation.",
|
|
620
|
+
"signature": "getRendererHealth(): RendererHealth · .state: RendererHealthState · .reportLost() · .reportHealthy() · .subscribe(fn)",
|
|
621
|
+
"options": [],
|
|
622
|
+
"returns": [
|
|
623
|
+
{
|
|
624
|
+
"name": "state.lost",
|
|
625
|
+
"type": "boolean",
|
|
626
|
+
"description": "Whether a context is currently known to be lost."
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
"name": "state.generation",
|
|
630
|
+
"type": "number",
|
|
631
|
+
"description": "Increments on each loss. Put it on the canvas key."
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
"name": "reportLost",
|
|
635
|
+
"type": "() => void",
|
|
636
|
+
"description": "Call after preventDefault on a webglcontextlost event."
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
"name": "reportHealthy",
|
|
640
|
+
"type": "() => void",
|
|
641
|
+
"description": "Call once a working context exists, which is what ends the recovery state."
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
"name": "subscribe",
|
|
645
|
+
"type": "(fn: (state: RendererHealthState) => void) => () => void",
|
|
646
|
+
"description": "Notified on every change. Returns a function that unsubscribes."
|
|
647
|
+
}
|
|
648
|
+
],
|
|
649
|
+
"runtime": {
|
|
650
|
+
"lane": null,
|
|
651
|
+
"priority": null,
|
|
652
|
+
"requiresClient": true,
|
|
653
|
+
"ssrSafeImport": true,
|
|
654
|
+
"respectsReducedMotion": false,
|
|
655
|
+
"reRendersPerFrame": 0,
|
|
656
|
+
"usesPointer": false,
|
|
657
|
+
"usesScroll": false,
|
|
658
|
+
"usesWebGL": true,
|
|
659
|
+
"ownsTransform": false,
|
|
660
|
+
"sharedSingletons": [
|
|
661
|
+
"RendererHealth"
|
|
662
|
+
],
|
|
663
|
+
"conflictsWith": [],
|
|
664
|
+
"pairsWith": [
|
|
665
|
+
"useSceneGate",
|
|
666
|
+
"useRenderQuality"
|
|
667
|
+
]
|
|
668
|
+
},
|
|
669
|
+
"quickStart": "import { getRendererHealth } from \"@vectorvesper/motion\";\n\nexport function watchContext(canvas) {\n const onLost = (event) => {\n // Without preventDefault the browser will not attempt a restore, and some\n // drivers then refuse a new context on the page at all.\n event.preventDefault();\n getRendererHealth().reportLost();\n };\n canvas.addEventListener(\"webglcontextlost\", onLost);\n return () => canvas.removeEventListener(\"webglcontextlost\", onLost);\n}",
|
|
670
|
+
"recipes": [
|
|
671
|
+
{
|
|
672
|
+
"name": "Rebuilding when the generation moves",
|
|
673
|
+
"blurb": "The generation is a key, and the key is the recovery.",
|
|
674
|
+
"code": "import { getRendererHealth } from \"@vectorvesper/motion\";\n\nlet generation = getRendererHealth().state.generation;\n\nconst off = getRendererHealth().subscribe((health) => {\n if (health.generation === generation) return;\n generation = health.generation;\n teardownRenderer();\n buildRenderer(); // fresh context, textures re-uploaded\n getRendererHealth().reportHealthy(); // ends the recovery\n});"
|
|
675
|
+
}
|
|
676
|
+
],
|
|
677
|
+
"dos": [
|
|
678
|
+
"Call preventDefault before reportLost, or a replacement context may be refused outright.",
|
|
679
|
+
"Use the generation as a key rather than trying to revive the context that died.",
|
|
680
|
+
"Call reportHealthy once a working context exists, which is what ends the recovery state."
|
|
681
|
+
],
|
|
682
|
+
"donts": [
|
|
683
|
+
"Don't ignore the loss. The failure mode is a black rectangle with no error anywhere.",
|
|
684
|
+
"Don't tear down on every change. Only a moving generation means the context actually died.",
|
|
685
|
+
"Don't attach your own listener to an r3f canvas that already spreads useRenderQuality's props. It attaches one, and two reports move the generation twice."
|
|
686
|
+
],
|
|
687
|
+
"whenNotToUse": [
|
|
688
|
+
{
|
|
689
|
+
"when": "You are rendering with React Three Fiber.",
|
|
690
|
+
"instead": "useRenderQuality, whose onCreated attaches the listener and reports for you."
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
"when": "You are not rendering with WebGL.",
|
|
694
|
+
"instead": "Nothing. A 2D canvas has no context to lose this way."
|
|
695
|
+
}
|
|
696
|
+
],
|
|
697
|
+
"guardrails": [
|
|
698
|
+
"practices/adaptive-performance.md"
|
|
699
|
+
],
|
|
700
|
+
"docsUrl": "https://vectorvesper.dev/runtime/scene-gate",
|
|
341
701
|
"disclosure": {
|
|
342
702
|
"exposeImplementation": false,
|
|
343
703
|
"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.",
|
|
@@ -444,8 +804,8 @@
|
|
|
444
804
|
"guardrails": [
|
|
445
805
|
"practices/frame-loop-rules.md"
|
|
446
806
|
],
|
|
447
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
448
|
-
"labUrl": "https://vectorvesper.dev/
|
|
807
|
+
"docsUrl": "https://vectorvesper.dev/runtime/sensor-bus",
|
|
808
|
+
"labUrl": "https://vectorvesper.dev/runtime/sensor-bus",
|
|
449
809
|
"disclosure": {
|
|
450
810
|
"exposeImplementation": false,
|
|
451
811
|
"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.",
|
|
@@ -523,7 +883,7 @@
|
|
|
523
883
|
"SensorBus"
|
|
524
884
|
]
|
|
525
885
|
},
|
|
526
|
-
"quickStart": "import { getConductor, damp } from \"@vectorvesper/motion\";\n\
|
|
886
|
+
"quickStart": "import { getConductor, damp } from \"@vectorvesper/motion\";\n\nexport function follow(el) {\n let x = 0;\n let targetX = 0;\n\n const onMove = (e) => { targetX = e.clientX; };\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n\n const off = getConductor().subscribe(\"render\", (dt) => {\n x = damp(x, targetX, 10, dt);\n el.style.transform = `translate3d(${x}px, 0, 0)`;\n }, { label: \"Follower\" });\n\n // Both handles, or the listener and the frame callback outlive the element.\n return () => { window.removeEventListener(\"pointermove\", onMove); off(); };\n}",
|
|
527
887
|
"recipes": [
|
|
528
888
|
{
|
|
529
889
|
"name": "Approach prediction with rayRectIntersect",
|
|
@@ -553,8 +913,8 @@
|
|
|
553
913
|
"guardrails": [
|
|
554
914
|
"practices/frame-loop-rules.md"
|
|
555
915
|
],
|
|
556
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
557
|
-
"labUrl": "https://vectorvesper.dev/
|
|
916
|
+
"docsUrl": "https://vectorvesper.dev/runtime/math",
|
|
917
|
+
"labUrl": "https://vectorvesper.dev/runtime/math",
|
|
558
918
|
"disclosure": {
|
|
559
919
|
"exposeImplementation": false,
|
|
560
920
|
"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.",
|
|
@@ -580,7 +940,8 @@
|
|
|
580
940
|
"stats panel"
|
|
581
941
|
],
|
|
582
942
|
"exports": [
|
|
583
|
-
"mountDevtools"
|
|
943
|
+
"mountDevtools",
|
|
944
|
+
"VERSION"
|
|
584
945
|
],
|
|
585
946
|
"tagline": "A live overlay showing which effect is spending the frame.",
|
|
586
947
|
"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.",
|
|
@@ -671,8 +1032,8 @@
|
|
|
671
1032
|
"guardrails": [
|
|
672
1033
|
"practices/frame-loop-rules.md"
|
|
673
1034
|
],
|
|
674
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
675
|
-
"labUrl": "https://vectorvesper.dev/
|
|
1035
|
+
"docsUrl": "https://vectorvesper.dev/runtime/devtools",
|
|
1036
|
+
"labUrl": "https://vectorvesper.dev/runtime/devtools",
|
|
676
1037
|
"disclosure": {
|
|
677
1038
|
"exposeImplementation": false,
|
|
678
1039
|
"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.",
|
|
@@ -697,7 +1058,7 @@
|
|
|
697
1058
|
],
|
|
698
1059
|
"tagline": "Device capability fused with live frame health — the complete quality signal.",
|
|
699
1060
|
"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.",
|
|
700
|
-
"summary": "Probes the device once (WebGL2, GPU renderer string, memory, cores, reduced-motion) and fuses that with the live AnimationBudget
|
|
1061
|
+
"summary": "Probes the device once (WebGL2, GPU renderer string, memory, cores, reduced-motion) and fuses that with the live AnimationBudget, the pressure verdict and the motion preference into one tier plus a `cause` explaining it. The device tier is a FLOOR, and that is the whole point: without it, weak devices oscillate forever, degrading, recovering because they degraded, upgrading, janking, degrading. A software renderer stays a software renderer. `reasons` explains the device half of the verdict.",
|
|
701
1062
|
"mechanism": "Another thin subscription; the substance is the AdaptiveQuality singleton, which fuses two signals that are each useless alone. The live budget measures truth, but only once frames have accumulated, so the opening seconds of a page are blind. Device heuristics answer instantly but never learn.\n\nThe fusion rule is a maximum, not an average: the device tier is a floor that the live budget can drop quality below but never lift quality above. Without that floor, weak devices oscillate — degrade, frames recover precisely because you degraded, upgrade, jank, degrade again.\n\nDevice tier comes from a single WebGL2 probe taken once and cached. No WebGL2 at all is tier 2 immediately. A software renderer (SwiftShader, llvmpipe) is tier 2. A mobile-class GPU is tier 1 — those run GL perfectly well, but inside a thermal envelope that a sustained effect will find. Four or fewer cores, or four or fewer gigabytes of reported memory, adds one tier on top. The probe releases its GL context immediately rather than holding one open for the page's lifetime, and every branch appends a plain-English string to reasons, so the verdict is explainable instead of a magic number.\n\nThere is no GPU benchmark database anywhere in this, deliberately. The heuristic is conservative and then corrected by real measurement, which ages better than a lookup table of hardware that did not exist when it was written.",
|
|
702
1063
|
"signature": "useAdaptiveQuality(): AdaptiveState",
|
|
703
1064
|
"options": [],
|
|
@@ -705,7 +1066,7 @@
|
|
|
705
1066
|
{
|
|
706
1067
|
"name": "tier",
|
|
707
1068
|
"type": "0 | 1 | 2",
|
|
708
|
-
"description": "
|
|
1069
|
+
"description": "The effective tier, and the one to consume. Fused from the device floor, the live budget, the pressure verdict and the motion preference."
|
|
709
1070
|
},
|
|
710
1071
|
{
|
|
711
1072
|
"name": "label",
|
|
@@ -727,10 +1088,15 @@
|
|
|
727
1088
|
"type": "string[]",
|
|
728
1089
|
"description": "Why the device tier is what it is, e.g. [\"mobile-class GPU\"]. For HUDs and support."
|
|
729
1090
|
},
|
|
1091
|
+
{
|
|
1092
|
+
"name": "cause",
|
|
1093
|
+
"type": "\"ok\" | \"device\" | \"reduced-motion\" | \"render\" | \"frame-rate\" | \"held\"",
|
|
1094
|
+
"description": "Why the tier is what it is. \"held\" is the interesting one: frames are dropping, but something other than drawing is to blame, so quality was deliberately left alone. Branch on this rather than inferring from the tiers."
|
|
1095
|
+
},
|
|
730
1096
|
{
|
|
731
1097
|
"name": "reducedMotion",
|
|
732
1098
|
"type": "boolean",
|
|
733
|
-
"description": "Whether the
|
|
1099
|
+
"description": "Whether the visitor asked for reduced motion. When true the effective tier is forced to 2 and `cause` reads reduced-motion, so the tier alone cannot tell a weak device from a stated preference."
|
|
734
1100
|
}
|
|
735
1101
|
],
|
|
736
1102
|
"runtime": {
|
|
@@ -752,7 +1118,7 @@
|
|
|
752
1118
|
"conflictsWith": [],
|
|
753
1119
|
"pairsWith": [
|
|
754
1120
|
"useAnimationBudget",
|
|
755
|
-
"
|
|
1121
|
+
"useSceneGate",
|
|
756
1122
|
"useSafeToMount"
|
|
757
1123
|
]
|
|
758
1124
|
},
|
|
@@ -798,8 +1164,8 @@
|
|
|
798
1164
|
"practices/adaptive-performance.md",
|
|
799
1165
|
"practices/webgl-budget-and-failopen.md"
|
|
800
1166
|
],
|
|
801
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
802
|
-
"labUrl": "https://vectorvesper.dev/
|
|
1167
|
+
"docsUrl": "https://vectorvesper.dev/runtime/adaptive-quality",
|
|
1168
|
+
"labUrl": "https://vectorvesper.dev/runtime/adaptive-quality",
|
|
803
1169
|
"disclosure": {
|
|
804
1170
|
"exposeImplementation": false,
|
|
805
1171
|
"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.",
|
|
@@ -884,7 +1250,7 @@
|
|
|
884
1250
|
"pairsWith": [
|
|
885
1251
|
"useAdaptiveQuality",
|
|
886
1252
|
"useSafeToMount",
|
|
887
|
-
"
|
|
1253
|
+
"useSceneGate"
|
|
888
1254
|
]
|
|
889
1255
|
},
|
|
890
1256
|
"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}",
|
|
@@ -935,8 +1301,8 @@
|
|
|
935
1301
|
"practices/adaptive-performance.md",
|
|
936
1302
|
"practices/frame-loop-rules.md"
|
|
937
1303
|
],
|
|
938
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
939
|
-
"labUrl": "https://vectorvesper.dev/
|
|
1304
|
+
"docsUrl": "https://vectorvesper.dev/runtime/animation-budget",
|
|
1305
|
+
"labUrl": "https://vectorvesper.dev/runtime/animation-budget",
|
|
940
1306
|
"disclosure": {
|
|
941
1307
|
"exposeImplementation": false,
|
|
942
1308
|
"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.",
|
|
@@ -945,67 +1311,209 @@
|
|
|
945
1311
|
}
|
|
946
1312
|
},
|
|
947
1313
|
{
|
|
948
|
-
"name": "
|
|
1314
|
+
"name": "useFramePressure",
|
|
949
1315
|
"importFrom": "@vectorvesper/motion/react",
|
|
950
1316
|
"packageName": "@vectorvesper/motion",
|
|
951
|
-
"since": "0.
|
|
952
|
-
"category": "
|
|
1317
|
+
"since": "2.0.0",
|
|
1318
|
+
"category": "governor",
|
|
953
1319
|
"tier": "free",
|
|
954
1320
|
"aliases": [
|
|
955
|
-
"
|
|
956
|
-
"
|
|
957
|
-
"
|
|
958
|
-
"
|
|
959
|
-
"
|
|
960
|
-
"
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
"
|
|
965
|
-
"
|
|
966
|
-
"
|
|
967
|
-
"
|
|
1321
|
+
"frame pressure",
|
|
1322
|
+
"what is slow",
|
|
1323
|
+
"why is it janky",
|
|
1324
|
+
"blame the frame",
|
|
1325
|
+
"main thread blocked",
|
|
1326
|
+
"gpu bound",
|
|
1327
|
+
"render bound",
|
|
1328
|
+
"diagnose jank"
|
|
1329
|
+
],
|
|
1330
|
+
"tagline": "Names which of three unrelated things is eating the frame, and how sure it is.",
|
|
1331
|
+
"problem": "Knowing a page is slow is easy and nearly useless. Reducing quality only helps when drawing is the bottleneck; when a third-party script is blocking the main thread, halving the particle count makes the page uglier and exactly as slow.",
|
|
1332
|
+
"summary": "Splits the frame into this runtime's own subscribers, other main-thread work, and everything left over, then names the largest with a confidence between 0 and 1. Verdicts emit at most twice a second, so this is safe to hold in React state.",
|
|
1333
|
+
"mechanism": "Three numbers per frame, two measured and one inferred.\n\nTotal frame time comes from the interval between presented frames. This runtime's own share comes free, because the scheduler already reads the clock between subscribers to decide what to shed. Other main-thread work is sampled with a message-channel probe at roughly ten times a second: a message posted from the end of the last lane comes back only after the rest of the frame's main-thread work has finished, so the delay is other people's time. What remains after subtracting both is the unattributed remainder.\n\nThat remainder is deliberately not called GPU time. It is evidence consistent with rendering being the cost, and it is also where measurement error goes. Naming it after a thing it cannot see would turn a reasonable inference into a false reading, so it is reported as a remainder and the verdict carries a confidence instead.\n\nConfidence is how clearly the winner beat the others, so a near-tie produces a low number and callers can refuse to act on it. A long-task observer contributes a weak hint where the browser supports it.\n\nThe slow threshold here is deliberately higher than the AnimationBudget's. The budget starts degrading quality at around 54fps, where a page is merely dipping; this classifier stays quiet until roughly 48fps, because attributing blame on a frame that is barely over budget produces confident nonsense. The practical consequence is a window where the tier has already dropped and the source still reads none, which is two signals doing different jobs rather than a contradiction.",
|
|
1334
|
+
"signature": "useFramePressure(): PressureState",
|
|
1335
|
+
"options": [],
|
|
1336
|
+
"returns": [
|
|
968
1337
|
{
|
|
969
|
-
"name": "
|
|
970
|
-
"type": "
|
|
971
|
-
"
|
|
972
|
-
"required": true,
|
|
973
|
-
"description": "Image sources, cycled in order. Preload or serve them small; a cold fetch mid-trail shows as a gap."
|
|
1338
|
+
"name": "source",
|
|
1339
|
+
"type": "\"none\" | \"runtime\" | \"main-thread\" | \"render\" | \"unknown\"",
|
|
1340
|
+
"description": "The largest contributor. `none` means frames are healthy and nothing is being eaten. `unknown` means the frame is over budget and no single cause clearly won."
|
|
974
1341
|
},
|
|
975
1342
|
{
|
|
976
|
-
"name": "
|
|
1343
|
+
"name": "confidence",
|
|
977
1344
|
"type": "number",
|
|
978
|
-
"
|
|
979
|
-
"required": false,
|
|
980
|
-
"description": "Rendered width of each trail image in px. Height is automatic."
|
|
1345
|
+
"description": "0 to 1. How clearly the winner beat the others. Set a floor before acting on a verdict; the scene gate uses 0.4."
|
|
981
1346
|
},
|
|
982
1347
|
{
|
|
983
|
-
"name": "
|
|
1348
|
+
"name": "frameMs",
|
|
984
1349
|
"type": "number",
|
|
985
|
-
"
|
|
986
|
-
"required": false,
|
|
987
|
-
"description": "Pointer distance between spawns in px. Lower is denser and more expensive."
|
|
1350
|
+
"description": "Smoothed wall-clock time between presented frames."
|
|
988
1351
|
},
|
|
989
1352
|
{
|
|
990
|
-
"name": "
|
|
1353
|
+
"name": "budgetMs",
|
|
991
1354
|
"type": "number",
|
|
992
|
-
"
|
|
993
|
-
"required": false,
|
|
994
|
-
"description": "Flight duration in ms."
|
|
1355
|
+
"description": "One frame at the display refresh rate this runtime measured, so 8.3 on a 120Hz screen."
|
|
995
1356
|
},
|
|
996
1357
|
{
|
|
997
|
-
"name": "
|
|
1358
|
+
"name": "runtimeMs",
|
|
998
1359
|
"type": "number",
|
|
999
|
-
"
|
|
1000
|
-
|
|
1001
|
-
"description": "Pool size, i.e. maximum simultaneously visible images. This is a hard ceiling on cost."
|
|
1002
|
-
}
|
|
1003
|
-
],
|
|
1004
|
-
"returns": [
|
|
1360
|
+
"description": "Smoothed time this runtime's own subscribers spent."
|
|
1361
|
+
},
|
|
1005
1362
|
{
|
|
1006
|
-
"name": "
|
|
1007
|
-
"type": "
|
|
1008
|
-
"description": "
|
|
1363
|
+
"name": "mainOtherMs",
|
|
1364
|
+
"type": "number | null",
|
|
1365
|
+
"description": "Sampled non-runtime main-thread time. Null until the probe has enough samples."
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
"name": "unattributedMs",
|
|
1369
|
+
"type": "number",
|
|
1370
|
+
"description": "The remainder after the other two. Evidence about rendering, and not a GPU reading."
|
|
1371
|
+
},
|
|
1372
|
+
{
|
|
1373
|
+
"name": "longTasks",
|
|
1374
|
+
"type": "number",
|
|
1375
|
+
"description": "Long tasks seen in the window, where the browser supports the observer."
|
|
1376
|
+
}
|
|
1377
|
+
],
|
|
1378
|
+
"runtime": {
|
|
1379
|
+
"lane": "input",
|
|
1380
|
+
"priority": "essential",
|
|
1381
|
+
"requiresClient": true,
|
|
1382
|
+
"ssrSafeImport": true,
|
|
1383
|
+
"respectsReducedMotion": false,
|
|
1384
|
+
"reRendersPerFrame": 0,
|
|
1385
|
+
"usesPointer": false,
|
|
1386
|
+
"usesScroll": false,
|
|
1387
|
+
"usesWebGL": false,
|
|
1388
|
+
"ownsTransform": false,
|
|
1389
|
+
"sharedSingletons": [
|
|
1390
|
+
"FrameConductor",
|
|
1391
|
+
"FramePressure"
|
|
1392
|
+
],
|
|
1393
|
+
"conflictsWith": [],
|
|
1394
|
+
"pairsWith": [
|
|
1395
|
+
"useSceneGate",
|
|
1396
|
+
"useAdaptiveQuality",
|
|
1397
|
+
"mountDevtools"
|
|
1398
|
+
]
|
|
1399
|
+
},
|
|
1400
|
+
"quickStart": "\"use client\";\nimport { useFramePressure } from \"@vectorvesper/motion/react\";\n\nexport function Diagnostics() {\n const p = useFramePressure();\n return <span>{p.source} at {Math.round(p.confidence * 100)}% confidence</span>;\n}",
|
|
1401
|
+
"recipes": [
|
|
1402
|
+
{
|
|
1403
|
+
"name": "Degrade for the right reason",
|
|
1404
|
+
"blurb": "The whole point. Act on render pressure and ignore the rest.",
|
|
1405
|
+
"code": "\"use client\";\nimport { useFramePressure } from \"@vectorvesper/motion/react\";\n\nexport function Scene() {\n const { source, confidence } = useFramePressure();\n\n // A smaller scene does not unblock a blocked main thread, so\n // \"main-thread\" must never reach this branch.\n const reduce = source === \"render\" && confidence >= 0.4;\n\n return <Particles count={reduce ? 12_000 : 40_000} />;\n}"
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
"name": "A support-thread readout",
|
|
1409
|
+
"blurb": "Put the decomposition on screen so a bug report arrives with numbers in it.",
|
|
1410
|
+
"code": "\"use client\";\nimport { useFramePressure } from \"@vectorvesper/motion/react\";\n\nexport function PressureReadout() {\n const p = useFramePressure();\n return (\n <pre>\n {p.source} @ {Math.round(p.confidence * 100)}%\n frame {p.frameMs.toFixed(1)}ms / budget {p.budgetMs.toFixed(1)}ms\n runtime {p.runtimeMs.toFixed(1)} other {p.mainOtherMs?.toFixed(1) ?? \"sampling\"}\n </pre>\n );\n}"
|
|
1411
|
+
},
|
|
1412
|
+
{
|
|
1413
|
+
"name": "Reading it inside a frame callback",
|
|
1414
|
+
"blurb": "The hook is React state. Inside a frame, read the singleton instead.",
|
|
1415
|
+
"code": "\"use client\";\nimport { useEffect } from \"react\";\nimport { getConductor, getFramePressure } from \"@vectorvesper/motion\";\n\nuseEffect(() => {\n return getConductor().subscribe(\"render\", () => {\n const { source } = getFramePressure().state;\n if (source === \"render\") drawCheaply();\n else drawNormally();\n }, { priority: \"decorative\", label: \"adaptive draw\" });\n}, []);"
|
|
1416
|
+
}
|
|
1417
|
+
],
|
|
1418
|
+
"dos": [
|
|
1419
|
+
"Check `confidence` before acting. A verdict at 0.2 is a coin flip with a name on it.",
|
|
1420
|
+
"Prefer `useSceneGate` for a scene. It already consumes this verdict through AdaptiveQuality and applies a sensible floor.",
|
|
1421
|
+
"Read `getFramePressure().state` inside frame callbacks. The hook is React state and re-renders.",
|
|
1422
|
+
"Expect `source` to read none while a tier has already dropped. The two signals answer different questions."
|
|
1423
|
+
],
|
|
1424
|
+
"donts": [
|
|
1425
|
+
"Don't reduce quality on `main-thread` or `runtime`. Neither is fixed by drawing less, and the page ends up uglier at the same frame rate.",
|
|
1426
|
+
"Don't read `unattributedMs` as GPU time. It is a remainder, and measurement error lands in it.",
|
|
1427
|
+
"Don't call this in many components. Verdicts are cheap but re-renders are not; read it near the top and pass the answer down."
|
|
1428
|
+
],
|
|
1429
|
+
"whenNotToUse": [
|
|
1430
|
+
{
|
|
1431
|
+
"when": "You are deciding how much of a scene to build.",
|
|
1432
|
+
"instead": "`useAdaptiveQuality`, which fuses this verdict with the device floor and the live budget and gives you a tier plus a cause."
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
"when": "You want to see which specific effect is expensive.",
|
|
1436
|
+
"instead": "`mountDevtools`, which lists every subscriber with its own measured cost. This hook attributes the frame in three broad buckets and never names an effect."
|
|
1437
|
+
},
|
|
1438
|
+
{
|
|
1439
|
+
"when": "The page is slow and nothing is animating.",
|
|
1440
|
+
"instead": "The browser's own profiler. This measures a running frame loop, so it has nothing to say about a slow network, a slow server render, or a long task before the loop starts."
|
|
1441
|
+
}
|
|
1442
|
+
],
|
|
1443
|
+
"guardrails": [
|
|
1444
|
+
"practices/adaptive-performance.md"
|
|
1445
|
+
],
|
|
1446
|
+
"docsUrl": "https://vectorvesper.dev/runtime/frame-pressure",
|
|
1447
|
+
"labUrl": "https://vectorvesper.dev/runtime/frame-pressure",
|
|
1448
|
+
"disclosure": {
|
|
1449
|
+
"exposeImplementation": false,
|
|
1450
|
+
"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.",
|
|
1451
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
1452
|
+
"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."
|
|
1453
|
+
}
|
|
1454
|
+
},
|
|
1455
|
+
{
|
|
1456
|
+
"name": "useImageTrail",
|
|
1457
|
+
"importFrom": "@vectorvesper/motion/react",
|
|
1458
|
+
"packageName": "@vectorvesper/motion",
|
|
1459
|
+
"since": "0.1.0",
|
|
1460
|
+
"category": "effect",
|
|
1461
|
+
"tier": "free",
|
|
1462
|
+
"aliases": [
|
|
1463
|
+
"image trail",
|
|
1464
|
+
"cursor trail",
|
|
1465
|
+
"mouse trail",
|
|
1466
|
+
"trail effect",
|
|
1467
|
+
"gallery flourish",
|
|
1468
|
+
"images follow cursor"
|
|
1469
|
+
],
|
|
1470
|
+
"tagline": "Images spawn along the pointer's path and fade — the award-site gallery flourish.",
|
|
1471
|
+
"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.",
|
|
1472
|
+
"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.",
|
|
1473
|
+
"mechanism": "Object pooling plus the Web Animations API, and deliberately no frame loop at all. A fixed set of img elements — 10 by default — is created once on mount, positioned absolutely inside the container, and recycled round-robin. Nothing is allocated per pointer move, which is what separates this from the tutorial version that creates and destroys a node on every spawn and hands the garbage collector a steady stream of work during the exact interaction that needs to stay smooth.\n\nSpawning is distance-gated rather than time-gated: a new image appears only once the pointer has travelled the spacing threshold since the last one, compared as squared distance so the square root is never taken. Time-gating would clump images when the cursor moves slowly and stretch them apart when it moves fast; distance-gating makes the trail read identically at any speed.\n\nEach flight is a single three-keyframe Web Animations call — fade in while rising and scaling up, then fade out while drifting down — with a small randomised drift and tilt so repeated spawns do not look stamped from a template. Handing the animation to the browser's compositor means there is no per-frame JavaScript for the images whatsoever. When the pool wraps around, any animation still running on the reused node is cancelled first, so the oldest flight is stolen rather than layered.\n\nThis hook is intentionally self-contained: a local pointermove listener on the container, no SensorBus, no conductor subscription. It fails open on touch devices and under reduced motion, and it restores the container's original position and overflow on unmount.",
|
|
1474
|
+
"signature": "useImageTrail<T>(options: UseImageTrailOptions): { ref }",
|
|
1475
|
+
"options": [
|
|
1476
|
+
{
|
|
1477
|
+
"name": "images",
|
|
1478
|
+
"type": "string[]",
|
|
1479
|
+
"default": "—",
|
|
1480
|
+
"required": true,
|
|
1481
|
+
"description": "Image sources, cycled in order. Preload or serve them small; a cold fetch mid-trail shows as a gap."
|
|
1482
|
+
},
|
|
1483
|
+
{
|
|
1484
|
+
"name": "size",
|
|
1485
|
+
"type": "number",
|
|
1486
|
+
"default": "160",
|
|
1487
|
+
"required": false,
|
|
1488
|
+
"description": "Rendered width of each trail image in px. Height is automatic."
|
|
1489
|
+
},
|
|
1490
|
+
{
|
|
1491
|
+
"name": "spacing",
|
|
1492
|
+
"type": "number",
|
|
1493
|
+
"default": "90",
|
|
1494
|
+
"required": false,
|
|
1495
|
+
"description": "Pointer distance between spawns in px. Lower is denser and more expensive."
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
"name": "life",
|
|
1499
|
+
"type": "number",
|
|
1500
|
+
"default": "900",
|
|
1501
|
+
"required": false,
|
|
1502
|
+
"description": "Flight duration in ms."
|
|
1503
|
+
},
|
|
1504
|
+
{
|
|
1505
|
+
"name": "maxActive",
|
|
1506
|
+
"type": "number",
|
|
1507
|
+
"default": "10",
|
|
1508
|
+
"required": false,
|
|
1509
|
+
"description": "Pool size, i.e. maximum simultaneously visible images. This is a hard ceiling on cost."
|
|
1510
|
+
}
|
|
1511
|
+
],
|
|
1512
|
+
"returns": [
|
|
1513
|
+
{
|
|
1514
|
+
"name": "ref",
|
|
1515
|
+
"type": "RefObject<T | null>",
|
|
1516
|
+
"description": "Attach to the container. The hook sets its position and overflow, and restores both on unmount."
|
|
1009
1517
|
}
|
|
1010
1518
|
],
|
|
1011
1519
|
"runtime": {
|
|
@@ -1071,8 +1579,8 @@
|
|
|
1071
1579
|
"practices/texture-and-media-gotchas.md",
|
|
1072
1580
|
"practices/adaptive-performance.md"
|
|
1073
1581
|
],
|
|
1074
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
1075
|
-
"labUrl": "https://vectorvesper.dev/
|
|
1582
|
+
"docsUrl": "https://vectorvesper.dev/runtime/image-trail",
|
|
1583
|
+
"labUrl": "https://vectorvesper.dev/runtime/image-trail",
|
|
1076
1584
|
"disclosure": {
|
|
1077
1585
|
"exposeImplementation": false,
|
|
1078
1586
|
"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.",
|
|
@@ -1081,133 +1589,90 @@
|
|
|
1081
1589
|
}
|
|
1082
1590
|
},
|
|
1083
1591
|
{
|
|
1084
|
-
"name": "
|
|
1592
|
+
"name": "useInteractionScope",
|
|
1085
1593
|
"importFrom": "@vectorvesper/motion/react",
|
|
1086
1594
|
"packageName": "@vectorvesper/motion",
|
|
1087
|
-
"since": "0.
|
|
1088
|
-
"category": "
|
|
1595
|
+
"since": "2.0.0",
|
|
1596
|
+
"category": "interaction",
|
|
1089
1597
|
"tier": "free",
|
|
1090
1598
|
"aliases": [
|
|
1091
|
-
"
|
|
1092
|
-
"
|
|
1093
|
-
"
|
|
1094
|
-
"
|
|
1095
|
-
"
|
|
1096
|
-
"
|
|
1097
|
-
],
|
|
1098
|
-
"tagline": "
|
|
1099
|
-
"problem": "
|
|
1100
|
-
"summary": "
|
|
1101
|
-
"mechanism": "
|
|
1102
|
-
"signature": "
|
|
1103
|
-
"options": [
|
|
1104
|
-
{
|
|
1105
|
-
"name": "rootMargin",
|
|
1106
|
-
"type": "string",
|
|
1107
|
-
"default": "\"200px\"",
|
|
1108
|
-
"required": false,
|
|
1109
|
-
"description": "IntersectionObserver margin. Larger mounts earlier and wastes more when the user never scrolls there."
|
|
1110
|
-
},
|
|
1111
|
-
{
|
|
1112
|
-
"name": "requireIdle",
|
|
1113
|
-
"type": "boolean",
|
|
1114
|
-
"default": "true",
|
|
1115
|
-
"required": false,
|
|
1116
|
-
"description": "Wait for requestIdleCallback (1500ms timeout) so mounting doesn't land in a busy frame."
|
|
1117
|
-
},
|
|
1118
|
-
{
|
|
1119
|
-
"name": "deferWhileLow",
|
|
1120
|
-
"type": "boolean",
|
|
1121
|
-
"default": "false",
|
|
1122
|
-
"required": false,
|
|
1123
|
-
"description": "Also wait for the AnimationBudget to leave tier 2, capped at 3 seconds so it always fails open."
|
|
1124
|
-
}
|
|
1125
|
-
],
|
|
1599
|
+
"interaction scope",
|
|
1600
|
+
"foreground region",
|
|
1601
|
+
"protect a drag",
|
|
1602
|
+
"yield background work",
|
|
1603
|
+
"scope id",
|
|
1604
|
+
"priority region"
|
|
1605
|
+
],
|
|
1606
|
+
"tagline": "Read the id of the surrounding InteractionScope, for work that subscribes by hand.",
|
|
1607
|
+
"problem": "`useTick` picks up the surrounding region automatically, but code that calls the conductor directly has to pass a scope, and there is no way to guess the generated id from outside.",
|
|
1608
|
+
"summary": "Returns the generated id of the nearest InteractionScope, or null outside one. Most components never need it: `useTick` already reads the same context. Reach for it when you subscribe to the conductor by hand from inside a region and want that work protected too.",
|
|
1609
|
+
"mechanism": "A context read, and nothing else. The provider generates its id with React's own id hook, so ids are unique per instance without the caller inventing names. An earlier version required a page-unique name from the caller, which pushed the uniqueness problem outward and meant two regions that happened to share a hand-written name silently shared one claim on the foreground.\n\nThe value is opaque and is not the label. The label exists so a devtools row can say something better than a generated id, and it has no effect on behaviour and no requirement to be unique.\n\nWhat the id is for: the conductor keeps a stack of scopes that have claimed the foreground, and while any claim is held, a subscriber whose scope does not match is tested against a stricter shedding threshold than its priority would normally give it. Essential work is exempt at any threshold. Passing this id when subscribing is what puts your work on the protected side of that comparison.",
|
|
1610
|
+
"signature": "useInteractionScope(): string | null",
|
|
1611
|
+
"options": [],
|
|
1126
1612
|
"returns": [
|
|
1127
1613
|
{
|
|
1128
|
-
"name": "
|
|
1129
|
-
"type": "
|
|
1130
|
-
"description": "
|
|
1131
|
-
},
|
|
1132
|
-
{
|
|
1133
|
-
"name": "ready",
|
|
1134
|
-
"type": "boolean",
|
|
1135
|
-
"description": "False until every gate passes, then true for the rest of the mount."
|
|
1614
|
+
"name": "scope",
|
|
1615
|
+
"type": "string | null",
|
|
1616
|
+
"description": "The generated id of the nearest InteractionScope, or null outside one. Opaque, and not the label. Pass it as the `scope` option when subscribing to the conductor directly."
|
|
1136
1617
|
}
|
|
1137
1618
|
],
|
|
1138
1619
|
"runtime": {
|
|
1139
|
-
"lane":
|
|
1140
|
-
"priority":
|
|
1620
|
+
"lane": null,
|
|
1621
|
+
"priority": null,
|
|
1141
1622
|
"requiresClient": true,
|
|
1142
1623
|
"ssrSafeImport": true,
|
|
1143
1624
|
"respectsReducedMotion": false,
|
|
1144
1625
|
"reRendersPerFrame": 0,
|
|
1145
1626
|
"usesPointer": false,
|
|
1146
|
-
"usesScroll":
|
|
1627
|
+
"usesScroll": false,
|
|
1147
1628
|
"usesWebGL": false,
|
|
1148
1629
|
"ownsTransform": false,
|
|
1149
|
-
"sharedSingletons": [
|
|
1150
|
-
"FrameConductor",
|
|
1151
|
-
"SensorBus",
|
|
1152
|
-
"AnimationBudget"
|
|
1153
|
-
],
|
|
1630
|
+
"sharedSingletons": [],
|
|
1154
1631
|
"conflictsWith": [],
|
|
1155
1632
|
"pairsWith": [
|
|
1156
|
-
"
|
|
1157
|
-
"
|
|
1158
|
-
"
|
|
1633
|
+
"InteractionScope",
|
|
1634
|
+
"useTick",
|
|
1635
|
+
"FrameConductor"
|
|
1159
1636
|
]
|
|
1160
1637
|
},
|
|
1161
|
-
"quickStart": "\"use client\";\nimport {
|
|
1638
|
+
"quickStart": "\"use client\";\nimport { useEffect } from \"react\";\nimport { useInteractionScope, getConductor } from \"@vectorvesper/motion/react\";\n\nexport function HandRolledEffect() {\n const scope = useInteractionScope();\n\n useEffect(() => {\n return getConductor().subscribe(\"render\", draw, {\n scope: scope ?? undefined,\n label: \"hand-rolled\",\n });\n }, [scope]);\n\n return null;\n}",
|
|
1162
1639
|
"recipes": [
|
|
1163
1640
|
{
|
|
1164
|
-
"name": "
|
|
1165
|
-
"blurb": "
|
|
1166
|
-
"code": "\"use client\";\nimport {
|
|
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}"
|
|
1641
|
+
"name": "Protecting a canvas you drive yourself",
|
|
1642
|
+
"blurb": "A canvas inside a draggable region, drawn from a direct subscription rather than useTick.",
|
|
1643
|
+
"code": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { InteractionScope, useInteractionScope, getConductor } from \"@vectorvesper/motion/react\";\n\nfunction Painter() {\n const canvas = useRef<HTMLCanvasElement>(null);\n const scope = useInteractionScope();\n\n useEffect(() => {\n return getConductor().subscribe(\"render\", () => paint(canvas.current), {\n priority: \"enhanced\",\n scope: scope ?? undefined, // protected while this region is held\n label: \"painter\",\n });\n }, [scope]);\n\n return <canvas ref={canvas} />;\n}\n\nexport function Editor() {\n return (\n <InteractionScope label=\"editor\">\n <Painter />\n </InteractionScope>\n );\n}"
|
|
1172
1644
|
},
|
|
1173
1645
|
{
|
|
1174
|
-
"name": "
|
|
1175
|
-
"blurb": "
|
|
1176
|
-
"code": "\"use client\";\nimport
|
|
1646
|
+
"name": "Checking whether you are inside a region",
|
|
1647
|
+
"blurb": "Null outside one, which is enough to branch on.",
|
|
1648
|
+
"code": "\"use client\";\nimport { useInteractionScope } from \"@vectorvesper/motion/react\";\n\nexport function Widget() {\n const scope = useInteractionScope();\n if (!scope) console.warn(\"Widget expects an InteractionScope above it\");\n return null;\n}"
|
|
1177
1649
|
}
|
|
1178
1650
|
],
|
|
1179
1651
|
"dos": [
|
|
1180
|
-
"
|
|
1181
|
-
"
|
|
1182
|
-
"
|
|
1183
|
-
"Raise `rootMargin` for slow-initialising scenes so they're ready by the time they're seen."
|
|
1652
|
+
"Prefer `useTick`, which reads the same context and needs no id. Use this only when subscribing by hand.",
|
|
1653
|
+
"Pass the id straight through as the `scope` option. It is meant to be opaque.",
|
|
1654
|
+
"List it in the effect dependencies, so remounting under a different region resubscribes correctly."
|
|
1184
1655
|
],
|
|
1185
1656
|
"donts": [
|
|
1186
|
-
"Don't
|
|
1187
|
-
"Don't
|
|
1188
|
-
"Don't
|
|
1657
|
+
"Don't show it to anyone. It is a generated id, and `label` is the human-readable name.",
|
|
1658
|
+
"Don't compare it against a label. They are different values and only one is stable.",
|
|
1659
|
+
"Don't expect it to change during an interaction. It identifies the region, not whether the region is currently active."
|
|
1189
1660
|
],
|
|
1190
1661
|
"whenNotToUse": [
|
|
1191
1662
|
{
|
|
1192
|
-
"when": "
|
|
1193
|
-
"instead": "
|
|
1194
|
-
},
|
|
1195
|
-
{
|
|
1196
|
-
"when": "You only need to defer the JavaScript bundle.",
|
|
1197
|
-
"instead": "Use `next/dynamic` or `React.lazy` alone. This hook gates the mount, not the download, and adds a runtime for a problem the bundler already solves."
|
|
1663
|
+
"when": "You are using useTick.",
|
|
1664
|
+
"instead": "Nothing. It already joins the surrounding region, and passing a scope by hand adds a way to get it wrong."
|
|
1198
1665
|
},
|
|
1199
1666
|
{
|
|
1200
|
-
"when": "You want
|
|
1201
|
-
"instead": "
|
|
1667
|
+
"when": "You want to know whether a region is currently being interacted with.",
|
|
1668
|
+
"instead": "`getConductor().state.activeScope`, which reports the region holding the foreground now. This hook returns which region you are in, whether or not it is active."
|
|
1202
1669
|
}
|
|
1203
1670
|
],
|
|
1204
1671
|
"guardrails": [
|
|
1205
|
-
"practices/
|
|
1206
|
-
"practices/react-motion-rules.md",
|
|
1207
|
-
"practices/webgl-budget-and-failopen.md"
|
|
1672
|
+
"practices/react-motion-rules.md"
|
|
1208
1673
|
],
|
|
1209
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
1210
|
-
"labUrl": "https://vectorvesper.dev/
|
|
1674
|
+
"docsUrl": "https://vectorvesper.dev/runtime/interaction-scope",
|
|
1675
|
+
"labUrl": "https://vectorvesper.dev/runtime/interaction-scope",
|
|
1211
1676
|
"disclosure": {
|
|
1212
1677
|
"exposeImplementation": false,
|
|
1213
1678
|
"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.",
|
|
@@ -1358,8 +1823,8 @@
|
|
|
1358
1823
|
"practices/layout-read-discipline.md",
|
|
1359
1824
|
"practices/component-api-conventions.md"
|
|
1360
1825
|
],
|
|
1361
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
1362
|
-
"labUrl": "https://vectorvesper.dev/
|
|
1826
|
+
"docsUrl": "https://vectorvesper.dev/runtime/magnetic-element",
|
|
1827
|
+
"labUrl": "https://vectorvesper.dev/runtime/magnetic-element",
|
|
1363
1828
|
"disclosure": {
|
|
1364
1829
|
"exposeImplementation": false,
|
|
1365
1830
|
"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.",
|
|
@@ -1449,7 +1914,7 @@
|
|
|
1449
1914
|
"Rendering children into the same element — the hook overwrites its textContent every frame."
|
|
1450
1915
|
],
|
|
1451
1916
|
"pairsWith": [
|
|
1452
|
-
"
|
|
1917
|
+
"useSceneGate",
|
|
1453
1918
|
"useAnimationBudget"
|
|
1454
1919
|
]
|
|
1455
1920
|
},
|
|
@@ -1458,12 +1923,12 @@
|
|
|
1458
1923
|
{
|
|
1459
1924
|
"name": "Pricing Toggle",
|
|
1460
1925
|
"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
|
|
1926
|
+
"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 speed: 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
1927
|
},
|
|
1463
1928
|
{
|
|
1464
1929
|
"name": "Stat Row",
|
|
1465
1930
|
"blurb": "Count up when the section scrolls into view, not on page load.",
|
|
1466
|
-
"code": "\"use client\";\nimport {
|
|
1931
|
+
"code": "\"use client\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { useNumberTicker } from \"@vectorvesper/motion/react\";\n\nfunction Metric({ target, label, suffix }: { target: number; label: string; suffix?: string }) {\n // A plain observer is the right tool here. The scene gate answers three more\n // questions than this needs, and none of them apply to a number.\n const seen = useRef<HTMLDivElement>(null);\n const [visible, setVisible] = useState(false);\n\n useEffect(() => {\n const el = seen.current;\n if (!el) return;\n const io = new IntersectionObserver(\n ([entry]) => entry.isIntersecting && setVisible(true),\n { rootMargin: \"0px\" },\n );\n io.observe(el);\n return () => io.disconnect();\n }, []);\n\n const { ref } = useNumberTicker<HTMLSpanElement>(visible ? 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
1932
|
},
|
|
1468
1933
|
{
|
|
1469
1934
|
"name": "Live percentage",
|
|
@@ -1500,8 +1965,8 @@
|
|
|
1500
1965
|
"practices/react-motion-rules.md",
|
|
1501
1966
|
"practices/damping-and-inertia.md"
|
|
1502
1967
|
],
|
|
1503
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
1504
|
-
"labUrl": "https://vectorvesper.dev/
|
|
1968
|
+
"docsUrl": "https://vectorvesper.dev/runtime/number-ticker",
|
|
1969
|
+
"labUrl": "https://vectorvesper.dev/runtime/number-ticker",
|
|
1505
1970
|
"disclosure": {
|
|
1506
1971
|
"exposeImplementation": false,
|
|
1507
1972
|
"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.",
|
|
@@ -1624,12 +2089,12 @@
|
|
|
1624
2089
|
{
|
|
1625
2090
|
"name": "CTA Button",
|
|
1626
2091
|
"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
|
|
2092
|
+
"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 sensitivity: \"high\",\n onIntentChange: (coming) => { if (coming) router.prefetch(href); },\n });\n return <a ref={ref} href={href}>{children}</a>;\n}"
|
|
1628
2093
|
},
|
|
1629
2094
|
{
|
|
1630
2095
|
"name": "Card Grid",
|
|
1631
2096
|
"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>({
|
|
2097
|
+
"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>({ sensitivity: \"high\" });\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
2098
|
},
|
|
1634
2099
|
{
|
|
1635
2100
|
"name": "Confidence-driven glow",
|
|
@@ -1655,7 +2120,7 @@
|
|
|
1655
2120
|
},
|
|
1656
2121
|
{
|
|
1657
2122
|
"when": "You are on a touch device.",
|
|
1658
|
-
"instead": "Use `
|
|
2123
|
+
"instead": "Use `useSceneGate` or intersection-based warming. There is no pointer trajectory to extrapolate from a tap, so prediction has nothing to work with."
|
|
1659
2124
|
},
|
|
1660
2125
|
{
|
|
1661
2126
|
"when": "You need to know the pointer actually entered.",
|
|
@@ -1667,8 +2132,140 @@
|
|
|
1667
2132
|
"practices/layout-read-discipline.md",
|
|
1668
2133
|
"practices/frame-loop-rules.md"
|
|
1669
2134
|
],
|
|
1670
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
1671
|
-
"labUrl": "https://vectorvesper.dev/
|
|
2135
|
+
"docsUrl": "https://vectorvesper.dev/runtime/pointer-intent",
|
|
2136
|
+
"labUrl": "https://vectorvesper.dev/runtime/pointer-intent",
|
|
2137
|
+
"disclosure": {
|
|
2138
|
+
"exposeImplementation": false,
|
|
2139
|
+
"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.",
|
|
2140
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
2141
|
+
"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."
|
|
2142
|
+
}
|
|
2143
|
+
},
|
|
2144
|
+
{
|
|
2145
|
+
"name": "useRenderQuality",
|
|
2146
|
+
"importFrom": "@vectorvesper/motion/r3f",
|
|
2147
|
+
"packageName": "@vectorvesper/motion",
|
|
2148
|
+
"since": "3.0.0",
|
|
2149
|
+
"category": "gate",
|
|
2150
|
+
"tier": "free",
|
|
2151
|
+
"aliases": [
|
|
2152
|
+
"render quality",
|
|
2153
|
+
"r3f adapter",
|
|
2154
|
+
"react three fiber",
|
|
2155
|
+
"canvas dpr",
|
|
2156
|
+
"pixel ratio",
|
|
2157
|
+
"stop render loop",
|
|
2158
|
+
"frameloop never",
|
|
2159
|
+
"webgl context lost",
|
|
2160
|
+
"three js quality"
|
|
2161
|
+
],
|
|
2162
|
+
"tagline": "Turns a scene gate's verdict into props for <Canvas>.",
|
|
2163
|
+
"problem": "A scene gate decides whether a scene should run and how much of it. Something still has to translate that into renderer settings, stop the loop when nobody is looking, and notice when the browser takes the graphics context away. React Three Fiber does none of those on your behalf, and has no handler for a lost context at all.",
|
|
2164
|
+
"summary": "Call it beside the gate, outside the canvas, and spread the result onto `<Canvas>`. It returns the pixel ratio, the shadow setting, whether the render loop turns, and an `onCreated` that reports a lost graphics context so the gate can hand back a new generation to remount on.",
|
|
2165
|
+
"mechanism": "A pure function of the scene state, returning props. It imports no React Three Fiber value at all, only React, and renders nothing.\n\nThe profile is chosen by state: the full profile while the scene is active, the reduced one in every other state. The frame loop is set to never only while the state is idle, which stops React Three Fiber rendering entirely without touching the context, the textures or the geometry, so coming back into view costs one frame rather than a full re-upload. The returned object is memoised, so spreading it does not reconfigure the canvas on every parent render.\n\nIt returns props rather than applying settings imperatively, and that is the whole design. Earlier versions ran inside the canvas and called the store's setters for pixel ratio and frame loop, and assigned the shadow map directly. None of it survived. React Three Fiber re-runs its configure pass on every canvas render and resets all three from its props, and v9 defaults the pixel ratio and the frame loop rather than leaving them unset, so every imperative call was undone on the next render. Measured against the old behaviour in a real browser: an idle scene rendered a further 167 frames in three seconds, and a renderer asked for a pixel ratio of 1 sat at 1.25. These are React Three Fiber's settings, so the only way to hold them is to be the thing it reconciles against.\n\nThe same reasoning explains the one warning that matters: passing a pixel ratio, frame loop or shadow prop yourself alongside the spread means your value wins on the next reconcile, silently, with no error.",
|
|
2166
|
+
"signature": "useRenderQuality(state: SceneState, profiles: RenderProfiles): RenderQualityProps",
|
|
2167
|
+
"options": [
|
|
2168
|
+
{
|
|
2169
|
+
"name": "state",
|
|
2170
|
+
"type": "SceneState",
|
|
2171
|
+
"default": "(required)",
|
|
2172
|
+
"required": true,
|
|
2173
|
+
"description": "Pass a scene gate's `state` straight through."
|
|
2174
|
+
},
|
|
2175
|
+
{
|
|
2176
|
+
"name": "profiles",
|
|
2177
|
+
"type": "{ full: RenderProfile; reduced: RenderProfile }",
|
|
2178
|
+
"default": "(required)",
|
|
2179
|
+
"required": true,
|
|
2180
|
+
"description": "What each quality level means to the renderer. The full profile is used while the state is active, and reduced in every other state. A RenderProfile carries an optional `dpr` and an optional `shadows`, and nothing else: particle counts and geometry detail are not renderer settings, so they stay in your component keyed off the gate's `quality`."
|
|
2181
|
+
}
|
|
2182
|
+
],
|
|
2183
|
+
"returns": [
|
|
2184
|
+
{
|
|
2185
|
+
"name": "dpr",
|
|
2186
|
+
"type": "number | undefined",
|
|
2187
|
+
"description": "Device pixel ratio from the active profile. The single biggest saving on a dense display, because going from 3 to 1.5 quarters the pixels shaded."
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
"name": "frameloop",
|
|
2191
|
+
"type": "\"always\" | \"never\"",
|
|
2192
|
+
"description": "Never while the state is idle, which stops rendering entirely while keeping the context and everything uploaded to it."
|
|
2193
|
+
},
|
|
2194
|
+
{
|
|
2195
|
+
"name": "shadows",
|
|
2196
|
+
"type": "boolean | undefined",
|
|
2197
|
+
"description": "Shadow setting from the active profile."
|
|
2198
|
+
},
|
|
2199
|
+
{
|
|
2200
|
+
"name": "onCreated",
|
|
2201
|
+
"type": "(state: { gl: { domElement: HTMLCanvasElement } }) => void",
|
|
2202
|
+
"description": "Attaches a webglcontextlost listener that calls preventDefault, without which the browser will not attempt a restore, and reports the loss so the scene gate can hand back a new generation for the canvas key."
|
|
2203
|
+
}
|
|
2204
|
+
],
|
|
2205
|
+
"runtime": {
|
|
2206
|
+
"lane": null,
|
|
2207
|
+
"priority": null,
|
|
2208
|
+
"requiresClient": true,
|
|
2209
|
+
"ssrSafeImport": true,
|
|
2210
|
+
"respectsReducedMotion": false,
|
|
2211
|
+
"reRendersPerFrame": 0,
|
|
2212
|
+
"usesPointer": false,
|
|
2213
|
+
"usesScroll": false,
|
|
2214
|
+
"usesWebGL": true,
|
|
2215
|
+
"ownsTransform": false,
|
|
2216
|
+
"sharedSingletons": [
|
|
2217
|
+
"RendererHealth"
|
|
2218
|
+
],
|
|
2219
|
+
"conflictsWith": [],
|
|
2220
|
+
"pairsWith": [
|
|
2221
|
+
"useSceneGate",
|
|
2222
|
+
"useAdaptiveQuality"
|
|
2223
|
+
]
|
|
2224
|
+
},
|
|
2225
|
+
"quickStart": "\"use client\";\nimport { Canvas } from \"@react-three/fiber\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\nimport { useRenderQuality } from \"@vectorvesper/motion/r3f\";\n\nexport function Hero() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"hero\" });\n\n const canvas = useRenderQuality(scene.state, {\n full: { dpr: 2, shadows: true },\n reduced: { dpr: 1, shadows: false },\n });\n\n return (\n <div ref={scene.ref} className=\"h-screen\">\n {scene.mounted && (\n <Canvas key={scene.generation} {...canvas}>\n <Model detail={scene.quality} />\n </Canvas>\n )}\n </div>\n );\n}",
|
|
2226
|
+
"recipes": [
|
|
2227
|
+
{
|
|
2228
|
+
"name": "Detail the adapter cannot own",
|
|
2229
|
+
"blurb": "Pixel ratio and shadows are renderer settings. Instance counts are not, so the component keeps those and reads the same verdict.",
|
|
2230
|
+
"code": "\"use client\";\nimport { Canvas } from \"@react-three/fiber\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\nimport { useRenderQuality } from \"@vectorvesper/motion/r3f\";\n\nexport function Field() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"field\", cost: \"heavy\" });\n\n const canvas = useRenderQuality(scene.state, {\n full: { dpr: 2, shadows: true },\n reduced: { dpr: 1, shadows: false },\n });\n\n return (\n <div ref={scene.ref}>\n {scene.mounted && (\n <Canvas key={scene.generation} {...canvas}>\n <Spires count={scene.quality === \"full\" ? 3000 : 250} />\n </Canvas>\n )}\n </div>\n );\n}"
|
|
2231
|
+
},
|
|
2232
|
+
{
|
|
2233
|
+
"name": "A poster for devices that will never run it",
|
|
2234
|
+
"blurb": "The gate reports a permanent refusal separately from a temporary one.",
|
|
2235
|
+
"code": "\"use client\";\nimport { Canvas } from \"@react-three/fiber\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\nimport { useRenderQuality } from \"@vectorvesper/motion/r3f\";\n\nexport function Showcase() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"showcase\" });\n const canvas = useRenderQuality(scene.state, {\n full: { dpr: 2, shadows: true },\n reduced: { dpr: 1, shadows: false },\n });\n\n if (scene.state === \"poster\") return <img src=\"/showcase.jpg\" alt=\"\" />;\n\n return (\n <div ref={scene.ref}>\n {scene.mounted && (\n <Canvas key={scene.generation} {...canvas}>\n <Model detail={scene.quality} />\n </Canvas>\n )}\n </div>\n );\n}"
|
|
2236
|
+
}
|
|
2237
|
+
],
|
|
2238
|
+
"dos": [
|
|
2239
|
+
"Call it outside <Canvas>. It returns props, and props go on the element.",
|
|
2240
|
+
"Spread the whole object. Picking fields out of it means picking which of the four jobs you keep.",
|
|
2241
|
+
"Put the gate's `generation` on the canvas key. Without it a lost context leaves a permanently black rectangle.",
|
|
2242
|
+
"Keep instance counts and geometry detail in your own component, keyed off the gate's `quality`."
|
|
2243
|
+
],
|
|
2244
|
+
"donts": [
|
|
2245
|
+
"Don't also pass dpr, frameloop or shadows to <Canvas>. React Three Fiber reconciles all three from props on every render, so your value silently wins and the adapter's decision is discarded.",
|
|
2246
|
+
"Don't call it inside a <Canvas> child. It needs no R3F context, and its output has to reach the canvas element itself.",
|
|
2247
|
+
"Don't put post-processing or particle counts in a profile. A RenderProfile is renderer settings only, and an option that looks applied and is not is worse than no option."
|
|
2248
|
+
],
|
|
2249
|
+
"whenNotToUse": [
|
|
2250
|
+
{
|
|
2251
|
+
"when": "You are not rendering with React Three Fiber.",
|
|
2252
|
+
"instead": "Read the gate's `state` and `quality` directly and apply them to your own renderer. The gate knows nothing about three, which is the point of the split."
|
|
2253
|
+
},
|
|
2254
|
+
{
|
|
2255
|
+
"when": "You want a quality tier for something that is not a scene.",
|
|
2256
|
+
"instead": "`useAdaptiveQuality`. This adapter applies a decision and never makes one."
|
|
2257
|
+
},
|
|
2258
|
+
{
|
|
2259
|
+
"when": "The scene never leaves the viewport and always runs at one quality.",
|
|
2260
|
+
"instead": "Ordinary Canvas props. The adapter earns its place when the answer changes."
|
|
2261
|
+
}
|
|
2262
|
+
],
|
|
2263
|
+
"guardrails": [
|
|
2264
|
+
"practices/adaptive-performance.md",
|
|
2265
|
+
"practices/react-motion-rules.md"
|
|
2266
|
+
],
|
|
2267
|
+
"docsUrl": "https://vectorvesper.dev/runtime/render-quality",
|
|
2268
|
+
"labUrl": "https://vectorvesper.dev/runtime/render-quality",
|
|
1672
2269
|
"disclosure": {
|
|
1673
2270
|
"exposeImplementation": false,
|
|
1674
2271
|
"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.",
|
|
@@ -1689,34 +2286,22 @@
|
|
|
1689
2286
|
"mount gate",
|
|
1690
2287
|
"hydration safe",
|
|
1691
2288
|
"when to mount",
|
|
1692
|
-
"idle mount"
|
|
2289
|
+
"idle mount",
|
|
2290
|
+
"mount cost",
|
|
2291
|
+
"SAFE_TO_MOUNT_COST"
|
|
1693
2292
|
],
|
|
1694
2293
|
"tagline": "Hold an expensive mount until the main thread can actually absorb it.",
|
|
1695
2294
|
"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.",
|
|
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.
|
|
1697
|
-
"mechanism": "A gate that opens once and never closes. It returns false, then true, and stays true for the rest of the mount — an expensive component that unmounted itself the moment it made the page slow would oscillate forever, so the one-way behaviour is deliberate.\n\nThree conditions, in order. A static hardware floor reads logical cores and gives up permanently below the threshold, because core count is the one signal that cannot improve while the page is open. It then holds the AnimationBudget open with an empty subscription: the governor only measures while something is subscribed, so polling an unsubscribed one would read a frozen snapshot forever. Finally it counts consecutive clean frames from the input lane at essential priority, subscribed after the governor's own measure pass so it reads state produced by this very frame. Any frame below the headroom threshold resets the count to zero.\n\nIt polls per frame rather than per budget emission on purpose. The budget only emits on tier changes and a 2Hz heartbeat, which would quietly turn a three-clean-frame requirement into a second and a half of waiting.\n\nThere is deliberately no shortcut for an already-healthy page. Opening immediately would require a synchronous state update inside the effect and a cascading render, and it cannot move into render without breaking hydration — the server has no frame timings, so the first client render must agree with it and start false. Waiting the same
|
|
2295
|
+
"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. You describe how expensive the thing is with a single `cost`, and that picks the thresholds.",
|
|
2296
|
+
"mechanism": "A gate that opens once and never closes. It returns false, then true, and stays true for the rest of the mount — an expensive component that unmounted itself the moment it made the page slow would oscillate forever, so the one-way behaviour is deliberate.\n\nOne option, three thresholds behind it. `cost` resolves to a required headroom in milliseconds, a number of consecutive clean frames, and a CPU core floor. The three used to be separate numeric options, and the catalogue settled the question: of ten call sites, six passed the identical override and four passed nothing. Nobody was tuning, everyone was working around a default, because how much spare frame time a component deserves is not a question the person writing it can answer. How expensive it is, they can. The resolved table is exported as SAFE_TO_MOUNT_COST for anything that has to explain why a gate is still closed.\n\nThree conditions, in order. A static hardware floor reads logical cores and gives up permanently below the threshold, because core count is the one signal that cannot improve while the page is open. It then holds the AnimationBudget open with an empty subscription: the governor only measures while something is subscribed, so polling an unsubscribed one would read a frozen snapshot forever. Finally it counts consecutive clean frames from the input lane at essential priority, subscribed after the governor's own measure pass so it reads state produced by this very frame. Any frame below the headroom threshold resets the count to zero.\n\nIt polls per frame rather than per budget emission on purpose. The budget only emits on tier changes and a 2Hz heartbeat, which would quietly turn a three-clean-frame requirement into a second and a half of waiting.\n\nThere is deliberately no shortcut for an already-healthy page. Opening immediately would require a synchronous state update inside the effect and a cascading render, and it cannot move into render without breaking hydration — the server has no frame timings, so the first client render must agree with it and start false. Waiting the same few frames everywhere buys one code path instead of two.",
|
|
1698
2297
|
"signature": "useSafeToMount(options?: UseSafeToMountOptions): boolean",
|
|
1699
2298
|
"options": [
|
|
1700
2299
|
{
|
|
1701
|
-
"name": "
|
|
1702
|
-
"type": "
|
|
1703
|
-
"default": "
|
|
1704
|
-
"required": false,
|
|
1705
|
-
"description": "Free time each qualifying frame must have left. Measured against the detected display rate, so 6 is about a third of a 60Hz frame and about three quarters of a 120Hz one — the same threshold means the same thing everywhere."
|
|
1706
|
-
},
|
|
1707
|
-
{
|
|
1708
|
-
"name": "requiredCleanFrames",
|
|
1709
|
-
"type": "number",
|
|
1710
|
-
"default": "3",
|
|
1711
|
-
"required": false,
|
|
1712
|
-
"description": "Consecutive qualifying frames needed before the gate opens."
|
|
1713
|
-
},
|
|
1714
|
-
{
|
|
1715
|
-
"name": "minCores",
|
|
1716
|
-
"type": "number",
|
|
1717
|
-
"default": "4",
|
|
2300
|
+
"name": "cost",
|
|
2301
|
+
"type": "\"light\" | \"normal\" | \"heavy\"",
|
|
2302
|
+
"default": "\"normal\"",
|
|
1718
2303
|
"required": false,
|
|
1719
|
-
"description": "
|
|
2304
|
+
"description": "How expensive the thing you are about to mount is. The heavier it is, the more spare frame time the page has to show before starting is worth it. `light` is a small canvas or a handful of animated elements and opens after 1 clean frame with 1ms spare. `normal` covers most components: 2 frames at 2ms. `heavy` is a full 3D scene, post-processing or a large particle system, and wants 3 frames at 6ms. `light` also drops the core floor from 4 to 2, so it can open on a weak device where the other two never will. The exact numbers are readable at runtime as `SAFE_TO_MOUNT_COST`."
|
|
1720
2305
|
}
|
|
1721
2306
|
],
|
|
1722
2307
|
"returns": [
|
|
@@ -1743,7 +2328,7 @@
|
|
|
1743
2328
|
],
|
|
1744
2329
|
"conflictsWith": [],
|
|
1745
2330
|
"pairsWith": [
|
|
1746
|
-
"
|
|
2331
|
+
"useSceneGate",
|
|
1747
2332
|
"useAnimationBudget",
|
|
1748
2333
|
"useAdaptiveQuality"
|
|
1749
2334
|
]
|
|
@@ -1753,28 +2338,35 @@
|
|
|
1753
2338
|
{
|
|
1754
2339
|
"name": "Dashboard Boot",
|
|
1755
2340
|
"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({
|
|
2341
|
+
"code": "\"use client\";\nimport { useSafeToMount } from \"@vectorvesper/motion/react\";\n\nexport function Overview({ kpis }: { kpis: Kpi[] }) {\n const canMount = useSafeToMount({ cost: \"heavy\" });\n return (\n <>\n <KpiRow items={kpis} /> {/* always immediate */}\n {canMount ? <RevenueChart /> : <ChartSkeleton />}\n </>\n );\n}"
|
|
1757
2342
|
},
|
|
1758
2343
|
{
|
|
1759
2344
|
"name": "Third-party Widget",
|
|
1760
|
-
"blurb": "
|
|
1761
|
-
"code": "\"use client\";\nimport { useSafeToMount } from \"@vectorvesper/motion/react\";\n\nexport function MapPanel() {\n const canMount = useSafeToMount({
|
|
2345
|
+
"blurb": "A bundle whose cost you don't control is heavy by definition, whatever its file size suggests.",
|
|
2346
|
+
"code": "\"use client\";\nimport { useSafeToMount } from \"@vectorvesper/motion/react\";\n\nexport function MapPanel() {\n const canMount = useSafeToMount({ cost: \"heavy\" });\n return canMount ? <InteractiveMap /> : <StaticMapImage />;\n}"
|
|
2347
|
+
},
|
|
2348
|
+
{
|
|
2349
|
+
"name": "Explaining a closed gate",
|
|
2350
|
+
"blurb": "The thresholds are readable, so a devtools panel or a docs demo can say why it has not opened yet rather than keeping its own copy of the numbers.",
|
|
2351
|
+
"code": "import { SAFE_TO_MOUNT_COST } from \"@vectorvesper/motion/react\";\n\nconst { headroomMs, cleanFrames, minCores } = SAFE_TO_MOUNT_COST.heavy;\n// → 6, 3, 4"
|
|
1762
2352
|
}
|
|
1763
2353
|
],
|
|
1764
2354
|
"dos": [
|
|
1765
2355
|
"Always render a sized fallback so the swap doesn't shift layout.",
|
|
1766
2356
|
"Use it for cost you control the timing of — charts, maps, editors, 3D canvases.",
|
|
1767
|
-
"
|
|
2357
|
+
"Answer the question it actually asks. `cost` describes the component, not the device or the page it lands on.",
|
|
2358
|
+
"Keep `cost` constant. It is an effect dependency, and changing it restarts the gate from closed."
|
|
1768
2359
|
],
|
|
1769
2360
|
"donts": [
|
|
1770
2361
|
"Don't gate anything the user needs. Checkout, auth and forms render unconditionally, always.",
|
|
1771
2362
|
"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."
|
|
2363
|
+
"Don't wrap it around something cheap — the gate costs more than the thing it is protecting.",
|
|
2364
|
+
"Don't reach for `heavy` to be safe. On a 4-core machine a heavy gate can stay shut for the life of the page, and the fallback is then the only thing anyone sees."
|
|
1773
2365
|
],
|
|
1774
2366
|
"whenNotToUse": [
|
|
1775
2367
|
{
|
|
1776
2368
|
"when": "The component is below the fold or off-screen.",
|
|
1777
|
-
"instead": "Use `
|
|
2369
|
+
"instead": "Use `useSceneGate`, which adds viewport proximity, a wait for the page to stop scrolling, a quality verdict and graphics-context recovery. Frame headroom alone will happily mount something nobody can see."
|
|
1778
2370
|
},
|
|
1779
2371
|
{
|
|
1780
2372
|
"when": "You want to keep tuning a scene that is already running.",
|
|
@@ -1789,8 +2381,8 @@
|
|
|
1789
2381
|
"practices/adaptive-performance.md",
|
|
1790
2382
|
"practices/react-motion-rules.md"
|
|
1791
2383
|
],
|
|
1792
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
1793
|
-
"labUrl": "https://vectorvesper.dev/
|
|
2384
|
+
"docsUrl": "https://vectorvesper.dev/runtime/safe-to-mount",
|
|
2385
|
+
"labUrl": "https://vectorvesper.dev/runtime/safe-to-mount",
|
|
1794
2386
|
"disclosure": {
|
|
1795
2387
|
"exposeImplementation": false,
|
|
1796
2388
|
"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.",
|
|
@@ -1799,11 +2391,176 @@
|
|
|
1799
2391
|
}
|
|
1800
2392
|
},
|
|
1801
2393
|
{
|
|
1802
|
-
"name": "
|
|
2394
|
+
"name": "useSceneGate",
|
|
1803
2395
|
"importFrom": "@vectorvesper/motion/react",
|
|
1804
2396
|
"packageName": "@vectorvesper/motion",
|
|
1805
|
-
"since": "0.
|
|
1806
|
-
"category": "
|
|
2397
|
+
"since": "2.0.0",
|
|
2398
|
+
"category": "gate",
|
|
2399
|
+
"tier": "free",
|
|
2400
|
+
"aliases": [
|
|
2401
|
+
"scene gate",
|
|
2402
|
+
"lazy scene",
|
|
2403
|
+
"lazy 3d",
|
|
2404
|
+
"webgl gate",
|
|
2405
|
+
"mount 3d scene",
|
|
2406
|
+
"pause offscreen",
|
|
2407
|
+
"context lost",
|
|
2408
|
+
"canvas recovery"
|
|
2409
|
+
],
|
|
2410
|
+
"tagline": "One policy for a heavy scene: when to mount, whether to draw, at what quality, and how to come back from a lost context.",
|
|
2411
|
+
"problem": "A heavy scene needs four separate decisions and most code answers only the first. The result is a WebGL canvas that mounted correctly and is now burning frames for someone who scrolled past it ten seconds ago, or a black rectangle after the browser took the graphics context away and nothing noticed.",
|
|
2412
|
+
"summary": "Returns a ref to attach and a verdict to read: whether the scene should exist, whether it should be drawing, how much of it to build, and a generation counter that changes when the graphics context dies. It decides and reports; applying the verdict to a renderer is `useRenderQuality` on the r3f entry, or your own code.",
|
|
2413
|
+
"mechanism": "A small state machine over four inputs, each of which already has its own owner.\n\nProximity comes from an IntersectionObserver with a preload margin, so the scene starts warming before it is visible. Affordability comes from useSafeToMount, which is called rather than reimplemented, so there is one definition of whether the page can carry an expensive mount and every caller shares it. Stillness comes from the sensor bus: the first mount waits for scroll velocity to drop below a threshold, because mounting a scene mid-scroll is what a reader feels as a stutter whatever the frame numbers say. Only the first mount waits for that; once the scene exists, scrolling past it is free.\n\nQuality comes from AdaptiveQuality's fused tier, which already accounts for the device floor, the live frame budget, the motion preference and the pressure verdict. Earlier versions read the pressure classifier here and applied the only-rendering-is-worth-degrading-for rule themselves, which meant that rule was reachable from this one hook and nowhere else. It lives in the governor now, so the tier arrives already knowing whether reducing quality would help, and the cause travels with it.\n\nTwo behaviours exist to stop it flapping. Once the scene has started it stays started, so scrolling past yields the idle state rather than an unmount: tearing down a scene throws away the graphics context and every texture on it, and coming back pays for all of that again. And once quality drops it holds down for a few seconds even if conditions improve, because a scene that rebuilds its detail level twice a second is worse than one that is simply a little plain for a while.\n\nThe ref is a hybrid: a callback ref that also exposes a current property. A plain ref read once from an effect finds nothing when the element arrives on a later commit, which is what happens behind a spinner, a Suspense fallback or a dynamic import placeholder. That shape shipped once and produced a scene stuck in its dormant state forever with no error to explain it.",
|
|
2414
|
+
"signature": "useSceneGate<T extends HTMLElement>(options?: UseSceneGateOptions): SceneGate<T>",
|
|
2415
|
+
"options": [
|
|
2416
|
+
{
|
|
2417
|
+
"name": "label",
|
|
2418
|
+
"type": "string",
|
|
2419
|
+
"default": "undefined",
|
|
2420
|
+
"required": false,
|
|
2421
|
+
"description": "A human-readable name, prefixed onto `reason` and shown in devtools. No effect on behaviour."
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
"name": "cost",
|
|
2425
|
+
"type": "\"light\" | \"normal\" | \"heavy\"",
|
|
2426
|
+
"default": "\"heavy\"",
|
|
2427
|
+
"required": false,
|
|
2428
|
+
"description": "How expensive the scene is. Passed straight to useSafeToMount, so a scene and a chart on the same page share one definition of what the page can afford. Declare what the thing actually is: a 280px wireframe canvas asking for `heavy` will sit unmounted for a very long time on a slow machine, correctly."
|
|
2429
|
+
},
|
|
2430
|
+
{
|
|
2431
|
+
"name": "preload",
|
|
2432
|
+
"type": "number",
|
|
2433
|
+
"default": "200",
|
|
2434
|
+
"required": false,
|
|
2435
|
+
"description": "How far before the viewport to start warming, in pixels. Also the margin the scene must clear before it counts as off screen, which matters for a sticky element: one that comes to rest flush against the viewport edge never gets far enough away to idle."
|
|
2436
|
+
}
|
|
2437
|
+
],
|
|
2438
|
+
"returns": [
|
|
2439
|
+
{
|
|
2440
|
+
"name": "ref",
|
|
2441
|
+
"type": "RefObject<T | null>",
|
|
2442
|
+
"description": "Attach to the element that holds the scene. Safe to attach on a later commit, behind a spinner or a dynamic import."
|
|
2443
|
+
},
|
|
2444
|
+
{
|
|
2445
|
+
"name": "state",
|
|
2446
|
+
"type": "\"dormant\" | \"warming\" | \"active\" | \"constrained\" | \"idle\" | \"recovering\" | \"poster\"",
|
|
2447
|
+
"description": "Where the scene is in its life."
|
|
2448
|
+
},
|
|
2449
|
+
{
|
|
2450
|
+
"name": "mounted",
|
|
2451
|
+
"type": "boolean",
|
|
2452
|
+
"description": "Whether the scene should exist. True for active, constrained, idle and recovering. Idle is included on purpose: a scene that is merely off screen should be paused, not torn down."
|
|
2453
|
+
},
|
|
2454
|
+
{
|
|
2455
|
+
"name": "quality",
|
|
2456
|
+
"type": "\"full\" | \"reduced\" | null",
|
|
2457
|
+
"description": "How much scene to build, and null whenever there is no scene. You decide what full and reduced mean."
|
|
2458
|
+
},
|
|
2459
|
+
{
|
|
2460
|
+
"name": "generation",
|
|
2461
|
+
"type": "number",
|
|
2462
|
+
"description": "Put this on the canvas key. It changes when the graphics context is lost, which is what makes React throw the dead tree away and build a working one."
|
|
2463
|
+
},
|
|
2464
|
+
{
|
|
2465
|
+
"name": "cause",
|
|
2466
|
+
"type": "\"ok\" | \"not-near\" | \"waiting-for-headroom\" | \"off-screen\" | \"render-bound\" | \"frame-rate\" | \"device-floor\" | \"reduced-motion\" | \"context-lost\"",
|
|
2467
|
+
"description": "The machine-readable reason for the current state. Branch on this one."
|
|
2468
|
+
},
|
|
2469
|
+
{
|
|
2470
|
+
"name": "reason",
|
|
2471
|
+
"type": "string",
|
|
2472
|
+
"description": "The same fact written for a person, for a support thread or a devtools row. The wording is free to change between releases, so branch on cause."
|
|
2473
|
+
}
|
|
2474
|
+
],
|
|
2475
|
+
"runtime": {
|
|
2476
|
+
"lane": "update",
|
|
2477
|
+
"priority": "essential",
|
|
2478
|
+
"requiresClient": true,
|
|
2479
|
+
"ssrSafeImport": true,
|
|
2480
|
+
"respectsReducedMotion": true,
|
|
2481
|
+
"reRendersPerFrame": 0,
|
|
2482
|
+
"usesPointer": false,
|
|
2483
|
+
"usesScroll": true,
|
|
2484
|
+
"usesWebGL": false,
|
|
2485
|
+
"ownsTransform": false,
|
|
2486
|
+
"sharedSingletons": [
|
|
2487
|
+
"FrameConductor",
|
|
2488
|
+
"SensorBus",
|
|
2489
|
+
"AnimationBudget",
|
|
2490
|
+
"AdaptiveQuality",
|
|
2491
|
+
"RendererHealth"
|
|
2492
|
+
],
|
|
2493
|
+
"conflictsWith": [],
|
|
2494
|
+
"pairsWith": [
|
|
2495
|
+
"useRenderQuality",
|
|
2496
|
+
"useSafeToMount",
|
|
2497
|
+
"useAdaptiveQuality"
|
|
2498
|
+
]
|
|
2499
|
+
},
|
|
2500
|
+
"quickStart": "\"use client\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\n\nexport function Hero() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"hero\" });\n\n return (\n <div ref={scene.ref} className=\"h-screen\">\n {scene.mounted\n ? <Scene detail={scene.quality} paused={scene.state === \"idle\"} />\n : <img src=\"/hero-poster.jpg\" alt=\"\" />}\n </div>\n );\n}",
|
|
2501
|
+
"recipes": [
|
|
2502
|
+
{
|
|
2503
|
+
"name": "React Three Fiber",
|
|
2504
|
+
"blurb": "The adapter turns the verdict into Canvas props: pixel ratio, shadows, whether the render loop turns, and context-loss reporting.",
|
|
2505
|
+
"code": "\"use client\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\nimport { useRenderQuality } from \"@vectorvesper/motion/r3f\";\nimport { Canvas } from \"@react-three/fiber\";\n\nexport function Hero() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"hero\", cost: \"heavy\" });\n\n const canvas = useRenderQuality(scene.state, {\n full: { dpr: 2, shadows: true },\n reduced: { dpr: 1, shadows: false },\n });\n\n return (\n <div ref={scene.ref} className=\"h-screen\">\n {scene.mounted ? (\n // Do NOT also set dpr, frameloop or shadows here. R3F reconciles all\n // three from props on every render, so a second owner silently wins.\n <Canvas key={scene.generation} {...canvas}>\n <Particles count={scene.quality === \"full\" ? 40_000 : 12_000} />\n </Canvas>\n ) : (\n <img src=\"/hero-poster.jpg\" alt=\"\" />\n )}\n </div>\n );\n}"
|
|
2506
|
+
},
|
|
2507
|
+
{
|
|
2508
|
+
"name": "Reacting to the reason",
|
|
2509
|
+
"blurb": "A device that will never run the scene deserves different copy from a page that is briefly busy.",
|
|
2510
|
+
"code": "\"use client\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\n\nexport function Showcase() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"showcase\" });\n\n if (scene.cause === \"device-floor\" || scene.cause === \"reduced-motion\") {\n // Permanent for this visit. Show the poster and stop promising a scene.\n return <img src=\"/showcase.jpg\" alt=\"\" />;\n }\n\n return (\n <div ref={scene.ref}>\n {scene.mounted ? <Scene detail={scene.quality} /> : <Skeleton />}\n </div>\n );\n}"
|
|
2511
|
+
},
|
|
2512
|
+
{
|
|
2513
|
+
"name": "A plain canvas, no r3f",
|
|
2514
|
+
"blurb": "The gate knows nothing about three. Stop your own loop when it says idle.",
|
|
2515
|
+
"code": "\"use client\";\nimport { useEffect } from \"react\";\nimport { useSceneGate, getConductor } from \"@vectorvesper/motion/react\";\n\nexport function Viz() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"viz\", cost: \"normal\" });\n const running = scene.mounted && scene.state !== \"idle\";\n\n useEffect(() => {\n if (!running) return;\n return getConductor().subscribe(\"render\", () => draw(), {\n priority: \"decorative\",\n label: \"viz\",\n });\n }, [running]);\n\n return <div ref={scene.ref} />;\n}"
|
|
2516
|
+
}
|
|
2517
|
+
],
|
|
2518
|
+
"dos": [
|
|
2519
|
+
"Put the ref on the element whose visibility actually decides things. On a pinned sequence that is the sticky child, because the tall scroll track counts as near the viewport for the whole sequence.",
|
|
2520
|
+
"Put `generation` on the canvas key. Without it a lost graphics context leaves a permanently black rectangle.",
|
|
2521
|
+
"Keep the scene mounted while `state` is idle and stop drawing instead. Unmounting throws away the context and every texture on it.",
|
|
2522
|
+
"Branch on `cause` rather than `reason`. The wording of `reason` is free to change.",
|
|
2523
|
+
"Give the trailing section more height than `preload`, or the scene never gets far enough away to idle."
|
|
2524
|
+
],
|
|
2525
|
+
"donts": [
|
|
2526
|
+
"Don't treat `quality` as meaningful while `mounted` is false. It is null then, which is the point.",
|
|
2527
|
+
"Don't reduce quality for anything except rendering being slow. The gate already applies that rule; if you add your own, apply it to `cause === \"render-bound\"` only.",
|
|
2528
|
+
"Don't declare a small canvas as `heavy`. On a throttled machine the gate will correctly refuse to mount it for a very long time.",
|
|
2529
|
+
"Don't gate content the visitor came for. This is for decoration and spectacle, never for the thing being read."
|
|
2530
|
+
],
|
|
2531
|
+
"whenNotToUse": [
|
|
2532
|
+
{
|
|
2533
|
+
"when": "You only need to know whether an element is on screen.",
|
|
2534
|
+
"instead": "An IntersectionObserver, directly. The gate earns its place when affordability, quality and context loss matter too."
|
|
2535
|
+
},
|
|
2536
|
+
{
|
|
2537
|
+
"when": "The component is expensive but is not a scene, and is already on screen.",
|
|
2538
|
+
"instead": "`useSafeToMount`, which answers the affordability question alone and returns a boolean."
|
|
2539
|
+
},
|
|
2540
|
+
{
|
|
2541
|
+
"when": "The scene is already running and you only want to scale its detail.",
|
|
2542
|
+
"instead": "`useAdaptiveQuality`. You do not need the mount decision to decide whether to drop a particle layer."
|
|
2543
|
+
}
|
|
2544
|
+
],
|
|
2545
|
+
"guardrails": [
|
|
2546
|
+
"practices/adaptive-performance.md",
|
|
2547
|
+
"practices/react-motion-rules.md"
|
|
2548
|
+
],
|
|
2549
|
+
"docsUrl": "https://vectorvesper.dev/runtime/scene-gate",
|
|
2550
|
+
"labUrl": "https://vectorvesper.dev/runtime/scene-gate",
|
|
2551
|
+
"disclosure": {
|
|
2552
|
+
"exposeImplementation": false,
|
|
2553
|
+
"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.",
|
|
2554
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
2555
|
+
"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."
|
|
2556
|
+
}
|
|
2557
|
+
},
|
|
2558
|
+
{
|
|
2559
|
+
"name": "useSensorBus",
|
|
2560
|
+
"importFrom": "@vectorvesper/motion/react",
|
|
2561
|
+
"packageName": "@vectorvesper/motion",
|
|
2562
|
+
"since": "0.1.0",
|
|
2563
|
+
"category": "sensor",
|
|
1807
2564
|
"tier": "free",
|
|
1808
2565
|
"aliases": [
|
|
1809
2566
|
"mouse position",
|
|
@@ -1897,8 +2654,158 @@
|
|
|
1897
2654
|
"practices/layout-read-discipline.md",
|
|
1898
2655
|
"practices/react-motion-rules.md"
|
|
1899
2656
|
],
|
|
1900
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
1901
|
-
"labUrl": "https://vectorvesper.dev/
|
|
2657
|
+
"docsUrl": "https://vectorvesper.dev/runtime/sensor-bus",
|
|
2658
|
+
"labUrl": "https://vectorvesper.dev/runtime/sensor-bus",
|
|
2659
|
+
"disclosure": {
|
|
2660
|
+
"exposeImplementation": false,
|
|
2661
|
+
"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.",
|
|
2662
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
2663
|
+
"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."
|
|
2664
|
+
}
|
|
2665
|
+
},
|
|
2666
|
+
{
|
|
2667
|
+
"name": "useTick",
|
|
2668
|
+
"importFrom": "@vectorvesper/motion/react",
|
|
2669
|
+
"packageName": "@vectorvesper/motion",
|
|
2670
|
+
"since": "2.0.0",
|
|
2671
|
+
"category": "effect",
|
|
2672
|
+
"tier": "free",
|
|
2673
|
+
"aliases": [
|
|
2674
|
+
"use raf",
|
|
2675
|
+
"animation frame hook",
|
|
2676
|
+
"run every frame",
|
|
2677
|
+
"frame loop react",
|
|
2678
|
+
"requestanimationframe hook",
|
|
2679
|
+
"custom effect",
|
|
2680
|
+
"subscribe to conductor"
|
|
2681
|
+
],
|
|
2682
|
+
"tagline": "Run a callback every frame from a React component, on the shared loop.",
|
|
2683
|
+
"problem": "The obvious way to animate from a component is a requestAnimationFrame loop inside an effect. Five components doing that is five loops the browser must run, five interleavings of reads and writes, and no way for any of them to yield when the frame is already full.",
|
|
2684
|
+
"summary": "Subscribes a callback to one lane of the shared FrameConductor for the lifetime of the component and unsubscribes on unmount. Automatically joins the nearest InteractionScope, so work inside the region a visitor is using keeps its frames while ambient work outside yields first.",
|
|
2685
|
+
"mechanism": "A thin React binding over the conductor's subscribe, with two details that matter.\n\nThe callback is held in a ref and swapped behind a stable function. Re-subscribing on every render would churn the lane arrays and reset the subscriber's cost average, so the latest closure is installed without the subscription itself changing. That means the callback always sees fresh props without the loop noticing.\n\nScope membership is read from React context rather than passed as an argument. Anything rendered inside an InteractionScope joins it automatically, which is what makes the protection work without per-child configuration: while that region holds the foreground lease, subscribers belonging to other regions or to none are tested against a stricter shedding threshold. Passing `background: true` opts a subscriber out, for work that is ambient even though it happens to be rendered inside a region.\n\nWorth knowing about shedding generally: the threshold is measured against how much of the frame budget this runtime has already spent when a subscriber's turn arrives, and the check happens before it runs. A subscriber that runs first in its lane therefore never sheds, however overloaded the page is. Priority sets the threshold; position decides whether the threshold is ever reached.",
|
|
2686
|
+
"signature": "useTick(lane: ConductorLane, fn: FrameFn, options?: UseTickOptions): void",
|
|
2687
|
+
"options": [
|
|
2688
|
+
{
|
|
2689
|
+
"name": "lane",
|
|
2690
|
+
"type": "\"input\" | \"update\" | \"render\"",
|
|
2691
|
+
"default": "(required)",
|
|
2692
|
+
"required": true,
|
|
2693
|
+
"description": "Which phase to run in. Read the DOM and sensors in `input`, do maths in `update`, write to the DOM or canvas in `render`. Every read across the page finishes before any write begins, which is what keeps layout to one calculation per frame."
|
|
2694
|
+
},
|
|
2695
|
+
{
|
|
2696
|
+
"name": "fn",
|
|
2697
|
+
"type": "(dt: number, time: number) => void",
|
|
2698
|
+
"default": "(required)",
|
|
2699
|
+
"required": true,
|
|
2700
|
+
"description": "Called once per frame. `dt` is seconds since this subscriber last ran, already clamped so a backgrounded tab waking up cannot feed a huge step into spring maths. `time` is the shared clock, also in seconds."
|
|
2701
|
+
},
|
|
2702
|
+
{
|
|
2703
|
+
"name": "priority",
|
|
2704
|
+
"type": "\"essential\" | \"enhanced\" | \"decorative\"",
|
|
2705
|
+
"default": "\"enhanced\"",
|
|
2706
|
+
"required": false,
|
|
2707
|
+
"description": "Shed order when a frame runs long. Reserve `essential` for sensors, governors and direct manipulation, because a scrub that stutters is a broken scrub. `decorative` yields first."
|
|
2708
|
+
},
|
|
2709
|
+
{
|
|
2710
|
+
"name": "hz",
|
|
2711
|
+
"type": "number",
|
|
2712
|
+
"default": "0",
|
|
2713
|
+
"required": false,
|
|
2714
|
+
"description": "Cap the cadence, in runs per second. The accumulated dt is passed through, so damping stays correct. Declare it for any decorative work whose position the eye follows: shedding is regular but lands near 12fps, which reads as broken for tracked motion, while `hz: 30` looks fine and does less total work."
|
|
2715
|
+
},
|
|
2716
|
+
{
|
|
2717
|
+
"name": "label",
|
|
2718
|
+
"type": "string",
|
|
2719
|
+
"default": "\"anonymous\"",
|
|
2720
|
+
"required": false,
|
|
2721
|
+
"description": "Name shown in devtools and in slow-subscriber warnings. Worth setting every time."
|
|
2722
|
+
},
|
|
2723
|
+
{
|
|
2724
|
+
"name": "enabled",
|
|
2725
|
+
"type": "boolean",
|
|
2726
|
+
"default": "true",
|
|
2727
|
+
"required": false,
|
|
2728
|
+
"description": "Set false to unsubscribe without unmounting the component."
|
|
2729
|
+
},
|
|
2730
|
+
{
|
|
2731
|
+
"name": "background",
|
|
2732
|
+
"type": "boolean",
|
|
2733
|
+
"default": "false",
|
|
2734
|
+
"required": false,
|
|
2735
|
+
"description": "Opt out of the surrounding InteractionScope. Use it for ambient work that happens to be rendered inside a region a visitor interacts with."
|
|
2736
|
+
}
|
|
2737
|
+
],
|
|
2738
|
+
"returns": [],
|
|
2739
|
+
"runtime": {
|
|
2740
|
+
"lane": null,
|
|
2741
|
+
"priority": "enhanced",
|
|
2742
|
+
"requiresClient": true,
|
|
2743
|
+
"ssrSafeImport": true,
|
|
2744
|
+
"respectsReducedMotion": false,
|
|
2745
|
+
"reRendersPerFrame": 0,
|
|
2746
|
+
"usesPointer": false,
|
|
2747
|
+
"usesScroll": false,
|
|
2748
|
+
"usesWebGL": false,
|
|
2749
|
+
"ownsTransform": false,
|
|
2750
|
+
"sharedSingletons": [
|
|
2751
|
+
"FrameConductor"
|
|
2752
|
+
],
|
|
2753
|
+
"conflictsWith": [],
|
|
2754
|
+
"pairsWith": [
|
|
2755
|
+
"useSensorBus",
|
|
2756
|
+
"InteractionScope",
|
|
2757
|
+
"damp"
|
|
2758
|
+
]
|
|
2759
|
+
},
|
|
2760
|
+
"quickStart": "\"use client\";\nimport { useRef } from \"react\";\nimport { useTick } from \"@vectorvesper/motion/react\";\n\nexport function Pulse() {\n const ref = useRef<HTMLDivElement>(null);\n\n useTick(\"render\", (_dt, time) => {\n if (!ref.current) return;\n ref.current.style.opacity = String(0.5 + Math.sin(time * 2) * 0.5);\n }, { label: \"pulse\" });\n\n return <div ref={ref} />;\n}",
|
|
2761
|
+
"recipes": [
|
|
2762
|
+
{
|
|
2763
|
+
"name": "Follow the pointer, smoothly",
|
|
2764
|
+
"blurb": "Read sensors in `input`, write transforms in `render`, and smooth with damp so it feels the same at 60Hz and 120Hz.",
|
|
2765
|
+
"code": "\"use client\";\nimport { useRef } from \"react\";\nimport { useTick, useSensorBus, damp } from \"@vectorvesper/motion/react\";\n\nexport function Follower() {\n const ref = useRef<HTMLDivElement>(null);\n const pos = useRef({ x: 0, y: 0 });\n const bus = useSensorBus();\n\n useTick(\"render\", (dt) => {\n const el = ref.current;\n if (!el) return;\n const { x, y } = bus.state.pointer;\n pos.current.x = damp(pos.current.x, x, 8, dt);\n pos.current.y = damp(pos.current.y, y, 8, dt);\n el.style.transform = `translate3d(${pos.current.x}px, ${pos.current.y}px, 0)`;\n }, { label: \"follower\" });\n\n return <div ref={ref} />;\n}"
|
|
2766
|
+
},
|
|
2767
|
+
{
|
|
2768
|
+
"name": "Ambient work that yields",
|
|
2769
|
+
"blurb": "Decorative, throttled, and marked as background so a drag elsewhere takes priority.",
|
|
2770
|
+
"code": "\"use client\";\nimport { useTick } from \"@vectorvesper/motion/react\";\n\nuseTick(\"render\", (dt) => drift(dt), {\n priority: \"decorative\",\n hz: 30,\n background: true,\n label: \"ambient drift\",\n});"
|
|
2771
|
+
},
|
|
2772
|
+
{
|
|
2773
|
+
"name": "Turning it off without unmounting",
|
|
2774
|
+
"blurb": "A paused scene should stop costing frames while keeping everything it allocated.",
|
|
2775
|
+
"code": "\"use client\";\nimport { useTick, useSceneGate } from \"@vectorvesper/motion/react\";\n\nexport function Viz() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"viz\" });\n\n useTick(\"render\", draw, {\n enabled: scene.mounted && scene.state !== \"idle\",\n label: \"viz draw\",\n });\n\n return <div ref={scene.ref} />;\n}"
|
|
2776
|
+
}
|
|
2777
|
+
],
|
|
2778
|
+
"dos": [
|
|
2779
|
+
"Set a `label`. It is the difference between a readable devtools row and `anonymous`.",
|
|
2780
|
+
"Write to the DOM through a ref. Calling setState from a frame callback re-renders sixty times a second, which is the cost this runtime exists to remove.",
|
|
2781
|
+
"Read in `input`, compute in `update`, write in `render`. Mixing them reintroduces the layout thrashing the lanes prevent.",
|
|
2782
|
+
"Use `hz` for decorative motion whose position the eye tracks, rather than leaving it to shedding."
|
|
2783
|
+
],
|
|
2784
|
+
"donts": [
|
|
2785
|
+
"Don't start a requestAnimationFrame loop next to this. A second loop is invisible to the budget, is billed to main-thread by the pressure classifier, and cannot be shed.",
|
|
2786
|
+
"Don't allocate inside the callback. Sixty allocations a second is a garbage collection pause with a delay on it.",
|
|
2787
|
+
"Don't read `getConductor().state` every frame. It allocates a snapshot; read it a few times a second instead."
|
|
2788
|
+
],
|
|
2789
|
+
"whenNotToUse": [
|
|
2790
|
+
{
|
|
2791
|
+
"when": "You are animating a single element from A to B on an event.",
|
|
2792
|
+
"instead": "A CSS transition or the Web Animations API. Both run off the main thread and cost you nothing per frame."
|
|
2793
|
+
},
|
|
2794
|
+
{
|
|
2795
|
+
"when": "The work is not per-frame.",
|
|
2796
|
+
"instead": "An ordinary effect, a timer, or an event handler. A frame loop is for things that need to be recomputed continuously."
|
|
2797
|
+
},
|
|
2798
|
+
{
|
|
2799
|
+
"when": "You are outside React.",
|
|
2800
|
+
"instead": "`getConductor().subscribe(lane, fn, options)`, which is what this wraps. It takes an explicit `scope` where this reads one from context."
|
|
2801
|
+
}
|
|
2802
|
+
],
|
|
2803
|
+
"guardrails": [
|
|
2804
|
+
"practices/react-motion-rules.md",
|
|
2805
|
+
"practices/adaptive-performance.md"
|
|
2806
|
+
],
|
|
2807
|
+
"docsUrl": "https://vectorvesper.dev/runtime/frame-conductor",
|
|
2808
|
+
"labUrl": "https://vectorvesper.dev/runtime/frame-conductor",
|
|
1902
2809
|
"disclosure": {
|
|
1903
2810
|
"exposeImplementation": false,
|
|
1904
2811
|
"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.",
|
|
@@ -1942,11 +2849,11 @@
|
|
|
1942
2849
|
"description": "\"pin\" for tall sticky tracks (the Apple pattern), \"cross\" for a track passing through the viewport, \"auto\" picks pin above ~1.2 viewports."
|
|
1943
2850
|
},
|
|
1944
2851
|
{
|
|
1945
|
-
"name": "
|
|
2852
|
+
"name": "speed",
|
|
1946
2853
|
"type": "number",
|
|
1947
2854
|
"default": "8",
|
|
1948
2855
|
"required": false,
|
|
1949
|
-
"description": "
|
|
2856
|
+
"description": "How quickly the video catches up to the scrub position. Higher arrives sooner, and 0 is instant with no trailing. Forced to 0 under reduced motion. Renamed from `smooth` in 3.0, because that name read backwards: a lower number produced more smoothing."
|
|
1950
2857
|
},
|
|
1951
2858
|
{
|
|
1952
2859
|
"name": "pointerAxis",
|
|
@@ -1970,7 +2877,7 @@
|
|
|
1970
2877
|
"description": "Attach to the `<video>` element."
|
|
1971
2878
|
},
|
|
1972
2879
|
{
|
|
1973
|
-
"name": "
|
|
2880
|
+
"name": "ref",
|
|
1974
2881
|
"type": "RefObject<TTrack | null>",
|
|
1975
2882
|
"description": "Attach to the element whose geometry defines progress. Defaults to the video's parent."
|
|
1976
2883
|
},
|
|
@@ -2005,26 +2912,26 @@
|
|
|
2005
2912
|
"Smooth-scroll libraries that lie about scroll position (verify the mapping if you use one)."
|
|
2006
2913
|
],
|
|
2007
2914
|
"pairsWith": [
|
|
2008
|
-
"
|
|
2915
|
+
"useSceneGate",
|
|
2009
2916
|
"useSensorBus"
|
|
2010
2917
|
]
|
|
2011
2918
|
},
|
|
2012
|
-
"quickStart": "\"use client\";\nimport { useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nexport function ScrollScrub() {\n const { videoRef,
|
|
2919
|
+
"quickStart": "\"use client\";\nimport { useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nexport function ScrollScrub() {\n const { videoRef, ref } = useVideoScrubber<HTMLDivElement>();\n return (\n <div ref={ref} 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
2920
|
"recipes": [
|
|
2014
2921
|
{
|
|
2015
2922
|
"name": "Product Scrollytelling",
|
|
2016
2923
|
"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,
|
|
2924
|
+
"code": "\"use client\";\nimport { useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nexport function ProductReveal() {\n const { videoRef, ref, progressRef } = useVideoScrubber<HTMLDivElement>({\n driver: \"scroll\",\n mapping: \"pin\",\n speed: 10,\n });\n\n return (\n <section ref={ref} 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
2925
|
},
|
|
2019
2926
|
{
|
|
2020
2927
|
"name": "Hover-scrub thumbnail",
|
|
2021
2928
|
"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,
|
|
2929
|
+
"code": "\"use client\";\nimport { useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nexport function SpinCard({ src }: { src: string }) {\n const { videoRef, ref } = useVideoScrubber<HTMLDivElement>({\n driver: \"pointer\",\n pointerAxis: \"x\",\n speed: 14,\n });\n return (\n <div ref={ref} className=\"aspect-square cursor-ew-resize\">\n <video ref={videoRef} src={src} muted playsInline className=\"size-full object-cover\" />\n </div>\n );\n}"
|
|
2023
2930
|
},
|
|
2024
2931
|
{
|
|
2025
2932
|
"name": "Manual driver",
|
|
2026
2933
|
"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,
|
|
2934
|
+
"code": "\"use client\";\nimport { useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nexport function SliderScrub() {\n const { videoRef, ref, scrubberRef } = useVideoScrubber<HTMLDivElement>({ driver: \"manual\" });\n return (\n <div ref={ref}>\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
2935
|
}
|
|
2029
2936
|
],
|
|
2030
2937
|
"dos": [
|
|
@@ -2058,8 +2965,8 @@
|
|
|
2058
2965
|
"practices/layout-read-discipline.md",
|
|
2059
2966
|
"practices/damping-and-inertia.md"
|
|
2060
2967
|
],
|
|
2061
|
-
"docsUrl": "https://vectorvesper.dev/
|
|
2062
|
-
"labUrl": "https://vectorvesper.dev/
|
|
2968
|
+
"docsUrl": "https://vectorvesper.dev/runtime/video-scrubber",
|
|
2969
|
+
"labUrl": "https://vectorvesper.dev/runtime/video-scrubber",
|
|
2063
2970
|
"disclosure": {
|
|
2064
2971
|
"exposeImplementation": false,
|
|
2065
2972
|
"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.",
|
|
@@ -2085,7 +2992,7 @@
|
|
|
2085
2992
|
"Every rendition shares the same layout box, so upgrades swap in place without reflow.",
|
|
2086
2993
|
"Inside the mounted scene, keep consuming the tier (poll getAdaptiveQuality().state in frame work) to shed internal detail instead of unmounting."
|
|
2087
2994
|
],
|
|
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({
|
|
2995
|
+
"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({ cost: \"heavy\" });\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
2996
|
"pitfalls": [
|
|
2090
2997
|
{
|
|
2091
2998
|
"mistake": "Importing the shader component statically.",
|
|
@@ -2149,9 +3056,9 @@
|
|
|
2149
3056
|
"rule": "orphan-raf"
|
|
2150
3057
|
},
|
|
2151
3058
|
{
|
|
2152
|
-
"mistake": "
|
|
2153
|
-
"consequence": "The subscriber outlives the component
|
|
2154
|
-
"rule": "
|
|
3059
|
+
"mistake": "Calling subscribe without keeping the unsubscribe it hands back.",
|
|
3060
|
+
"consequence": "The returned function is the only handle on the subscription. Dropped, the subscriber outlives the component, the loop never sleeps, the closure leaks, and every remount adds another copy.",
|
|
3061
|
+
"rule": "orphan-subscription"
|
|
2155
3062
|
},
|
|
2156
3063
|
{
|
|
2157
3064
|
"mistake": "Mirroring a per-frame value into React state to render it.",
|
|
@@ -2160,7 +3067,8 @@
|
|
|
2160
3067
|
},
|
|
2161
3068
|
{
|
|
2162
3069
|
"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."
|
|
3070
|
+
"consequence": "Forced synchronous layout every frame. Measure in the input lane; write in render.",
|
|
3071
|
+
"rule": "lane-discipline"
|
|
2164
3072
|
},
|
|
2165
3073
|
{
|
|
2166
3074
|
"mistake": "Autonomous motion with no prefers-reduced-motion guard.",
|
|
@@ -2235,25 +3143,41 @@
|
|
|
2235
3143
|
"name": "lazy-3d-section",
|
|
2236
3144
|
"title": "Lazy 3D section below the fold",
|
|
2237
3145
|
"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.
|
|
3146
|
+
"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. Four decisions hide in that sentence: whether to exist, whether to draw, how rich to be, and what to do when the browser takes the graphics context away.",
|
|
2239
3147
|
"uses": [
|
|
2240
|
-
"
|
|
2241
|
-
"
|
|
3148
|
+
"useSceneGate",
|
|
3149
|
+
"useRenderQuality"
|
|
2242
3150
|
],
|
|
2243
3151
|
"wiring": [
|
|
2244
|
-
"
|
|
2245
|
-
"Load the scene component via dynamic(..., { ssr: false }) so its code and its DOM both stay deferred.",
|
|
2246
|
-
"
|
|
2247
|
-
"
|
|
2248
|
-
"
|
|
2249
|
-
|
|
2250
|
-
|
|
3152
|
+
"useSceneGate answers all four. It reports `mounted` (should the scene exist), `state` (idle means keep it and stop drawing), `quality` (how much to build) and `generation` (bump the canvas key after a lost context).",
|
|
3153
|
+
"Load the scene component via dynamic(..., { ssr: false }) so its code and its DOM both stay deferred. The gate's ref is a hybrid, so attaching it on a later commit is safe.",
|
|
3154
|
+
"Call useRenderQuality beside the gate and spread its result onto <Canvas>. It owns pixel ratio, shadows, whether the render loop turns, and context-loss reporting.",
|
|
3155
|
+
"Instance counts and geometry detail stay yours, keyed off `quality`. Only the component knows what its own detail is made of.",
|
|
3156
|
+
"The placeholder keeps the scene's final dimensions and meaningful content, so nothing shifts and nothing is lost without a heavy render.",
|
|
3157
|
+
"Mount once and keep it mounted. Scrolling past gives you `idle`, which pauses drawing while keeping the context and every texture on it."
|
|
3158
|
+
],
|
|
3159
|
+
"code": "\"use client\";\n\nimport dynamic from \"next/dynamic\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\n\nconst ExplodedProduct = dynamic(() => import(\"./ExplodedProduct\"), { ssr: false });\n\nexport function ProductBreakdown() {\n const scene = useSceneGate<HTMLDivElement>({\n label: \"product breakdown\",\n cost: \"heavy\",\n preload: 400,\n });\n\n return (\n <div ref={scene.ref} style={{ height: \"100vh\", position: \"relative\" }}>\n {scene.mounted ? (\n <ExplodedProduct\n generation={scene.generation}\n state={scene.state}\n quality={scene.quality}\n />\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 { Canvas } from \"@react-three/fiber\";\n// import { useRenderQuality } from \"@vectorvesper/motion/r3f\";\n// import type { SceneState } from \"@vectorvesper/motion/react\";\n//\n// export default function ExplodedProduct({ generation, state, quality }: {\n// generation: number; state: SceneState; quality: \"full\" | \"reduced\" | null;\n// }) {\n// const canvas = useRenderQuality(state, {\n// full: { dpr: 2, shadows: true },\n// reduced: { dpr: 1, shadows: false },\n// });\n//\n// // Do NOT also set dpr, frameloop or shadows on <Canvas>. R3F reconciles\n// // all three from props on every render, so a second owner silently wins.\n// return (\n// <Canvas key={generation} {...canvas}>\n// <Model particles={quality === \"full\" ? 2000 : 500} />\n// </Canvas>\n// );\n// }",
|
|
2251
3160
|
"pitfalls": [
|
|
2252
3161
|
{
|
|
2253
3162
|
"mistake": "Importing the scene statically and only gating its render.",
|
|
2254
3163
|
"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
3164
|
"rule": "ssr-module-scope"
|
|
2256
3165
|
},
|
|
3166
|
+
{
|
|
3167
|
+
"mistake": "Passing dpr, frameloop or shadows to <Canvas> alongside the adapter's spread.",
|
|
3168
|
+
"consequence": "React Three Fiber reconciles all three from props on every render of the canvas, so the prop written by hand wins and the adapter's decision is discarded. Nothing errors, nothing warns, and the scene simply never adapts.",
|
|
3169
|
+
"rule": "canvas-prop-override"
|
|
3170
|
+
},
|
|
3171
|
+
{
|
|
3172
|
+
"mistake": "Gating the scene on an IntersectionObserver and stopping there.",
|
|
3173
|
+
"consequence": "Viewport proximity alone mounts a heavy scene mid-flick, on a saturated thread, at full quality, on hardware that cannot hold it — and keeps rendering once it leaves the viewport. The gate is the cheap third of the job.",
|
|
3174
|
+
"rule": "hand-rolled-scene-gate"
|
|
3175
|
+
},
|
|
3176
|
+
{
|
|
3177
|
+
"mistake": "Leaving a lost graphics context unhandled.",
|
|
3178
|
+
"consequence": "The browser can take a WebGL context away at any time. Without a listener that calls preventDefault and a key that moves afterwards, the visitor is left with a permanently black rectangle and a completely clean console.",
|
|
3179
|
+
"rule": "webgl-context-loss"
|
|
3180
|
+
},
|
|
2257
3181
|
{
|
|
2258
3182
|
"mistake": "A zero-height placeholder host.",
|
|
2259
3183
|
"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."
|
|
@@ -2297,7 +3221,7 @@
|
|
|
2297
3221
|
"In onIntentChange, prefetch the route (router.prefetch) — idempotent and cheap, so overlapping zones on nearby CTAs are fine.",
|
|
2298
3222
|
"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
3223
|
],
|
|
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
|
|
3224
|
+
"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 sensitivity: \"high\",\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
3225
|
"pitfalls": [
|
|
2302
3226
|
{
|
|
2303
3227
|
"mistake": "Putting both refs on the same element.",
|
|
@@ -2402,7 +3326,7 @@
|
|
|
2402
3326
|
"The card element's transform belongs to this subscription alone.",
|
|
2403
3327
|
"Show the decoded full-res only once ready — the reveal is then a cache hit, not a decode."
|
|
2404
3328
|
],
|
|
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>({
|
|
3329
|
+
"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>({ sensitivity: \"high\" });\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
3330
|
"pitfalls": [
|
|
2407
3331
|
{
|
|
2408
3332
|
"mistake": "Rendering confidence into JSX ({confidenceRef.current}) or mirroring it into state.",
|
|
@@ -2443,17 +3367,17 @@
|
|
|
2443
3367
|
"tagline": "A sticky film scrubbed by scroll, deferred until it approaches the viewport.",
|
|
2444
3368
|
"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
3369
|
"uses": [
|
|
2446
|
-
"
|
|
3370
|
+
"useSceneGate",
|
|
2447
3371
|
"useVideoScrubber"
|
|
2448
3372
|
],
|
|
2449
3373
|
"wiring": [
|
|
2450
|
-
"Outer component:
|
|
2451
|
-
"Render a poster
|
|
2452
|
-
"Inner component
|
|
3374
|
+
"Outer component: useSceneGate with a generous preload (400 to 600px), so the video element does not exist until the section approaches. Only `mounted` matters here; the quality and generation fields are for scenes that draw.",
|
|
3375
|
+
"Render a poster while it is unmounted with the SAME track height, so mounting the scrubber never shifts layout.",
|
|
3376
|
+
"Inner component, which mounts only once the gate opens: useVideoScrubber with driver \"scroll\" and mapping \"pin\". Its `ref` goes on the tall section and `videoRef` on the video inside a sticky container.",
|
|
2453
3377
|
"Track height 250–400vh — that is the scrub resolution; each second of film needs scroll distance.",
|
|
2454
3378
|
"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
3379
|
],
|
|
2456
|
-
"code": "\"use client\";\n\nimport {
|
|
3380
|
+
"code": "\"use client\";\n\nimport { useSceneGate, useVideoScrubber } from \"@vectorvesper/motion/react\";\n\nconst TRACK_HEIGHT = \"300vh\";\n\nexport function FilmSection() {\n // A video is not a drawing scene, so only \"mounted\" is used here. The gate\n // still earns its place: it waits for the page to stop scrolling before\n // mounting, and mounting mid-scroll is what a reader feels as a stutter.\n const scene = useSceneGate<HTMLDivElement>({ label: \"film\", cost: \"normal\", preload: 500 });\n\n return (\n <div ref={scene.ref}>\n {scene.mounted ? (\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, ref } = useVideoScrubber<HTMLElement>({\n driver: \"scroll\",\n mapping: \"pin\",\n speed: 8,\n });\n\n return (\n <section ref={ref} 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
3381
|
"pitfalls": [
|
|
2458
3382
|
{
|
|
2459
3383
|
"mistake": "Mounting the video unconditionally and only lazy-loading the src.",
|
|
@@ -2540,5 +3464,1151 @@
|
|
|
2540
3464
|
"logo wall"
|
|
2541
3465
|
]
|
|
2542
3466
|
}
|
|
3467
|
+
],
|
|
3468
|
+
"advice": [
|
|
3469
|
+
{
|
|
3470
|
+
"id": "scrollytelling",
|
|
3471
|
+
"when": [
|
|
3472
|
+
"scrollytelling",
|
|
3473
|
+
"scroll story",
|
|
3474
|
+
"scroll narrative",
|
|
3475
|
+
"story scroll",
|
|
3476
|
+
"scroll driven story"
|
|
3477
|
+
],
|
|
3478
|
+
"allOf": [],
|
|
3479
|
+
"unless": [],
|
|
3480
|
+
"minHits": 1,
|
|
3481
|
+
"priority": 640,
|
|
3482
|
+
"verdict": "composition",
|
|
3483
|
+
"headline": "A scrubbed video under pinned copy, gated so it only decodes when it is on screen.",
|
|
3484
|
+
"because": "The three parts fail in different ways alone. The scrubber without a gate decodes video for a section nobody has scrolled to. The gate without seek discipline issues a seek per frame and the decoder falls behind. And the copy pinned over it must not animate the same transform the scrubber's host owns, or the two fight every frame.",
|
|
3485
|
+
"use": [
|
|
3486
|
+
"useSceneGate",
|
|
3487
|
+
"useVideoScrubber"
|
|
3488
|
+
],
|
|
3489
|
+
"pattern": "scrollytelling-video",
|
|
3490
|
+
"components": [
|
|
3491
|
+
"playpause"
|
|
3492
|
+
],
|
|
3493
|
+
"instead": "A scroll listener assigning `currentTime`, with the video always mounted.",
|
|
3494
|
+
"watchFor": [
|
|
3495
|
+
"transform-conflict",
|
|
3496
|
+
"setstate-per-frame"
|
|
3497
|
+
],
|
|
3498
|
+
"notWhen": "The story is a sequence of discrete steps rather than a continuous timeline. Steps are IntersectionObserver crossings and CSS, with no per-frame work at all."
|
|
3499
|
+
},
|
|
3500
|
+
{
|
|
3501
|
+
"id": "pointer-parallax",
|
|
3502
|
+
"when": [
|
|
3503
|
+
"parallax",
|
|
3504
|
+
"layers move with the mouse",
|
|
3505
|
+
"depth effect",
|
|
3506
|
+
"tilt with cursor",
|
|
3507
|
+
"parallax scene"
|
|
3508
|
+
],
|
|
3509
|
+
"allOf": [],
|
|
3510
|
+
"unless": [],
|
|
3511
|
+
"minHits": 1,
|
|
3512
|
+
"priority": 630,
|
|
3513
|
+
"verdict": "composition",
|
|
3514
|
+
"headline": "One sensor read, one damped value per layer, all written in the render lane.",
|
|
3515
|
+
"because": "The naive version attaches a `pointermove` listener per layer and writes a transform inside each — so a five-layer scene does five listener invocations and five interleaved reads and writes per pointer event. Reading the bus once per frame and writing every layer in one pass makes the cost independent of the layer count, and damping is what turns raw pointer values into motion that does not judder on a busy frame.",
|
|
3516
|
+
"use": [
|
|
3517
|
+
"useSensorBus",
|
|
3518
|
+
"useTick",
|
|
3519
|
+
"damp"
|
|
3520
|
+
],
|
|
3521
|
+
"pattern": "pointer-parallax-scene",
|
|
3522
|
+
"components": [
|
|
3523
|
+
"lookbook",
|
|
3524
|
+
"glass-gallery"
|
|
3525
|
+
],
|
|
3526
|
+
"instead": "A `pointermove` handler per layer, each writing its own inline transform.",
|
|
3527
|
+
"watchFor": [
|
|
3528
|
+
"transform-conflict",
|
|
3529
|
+
"setstate-per-frame",
|
|
3530
|
+
"lane-discipline"
|
|
3531
|
+
],
|
|
3532
|
+
"notWhen": "There is no pointer. Parallax keyed to the pointer is inert on touch, so either drive it from scroll instead or accept that half your visitors see a static scene and design for that."
|
|
3533
|
+
},
|
|
3534
|
+
{
|
|
3535
|
+
"id": "velocity-marquee",
|
|
3536
|
+
"when": [
|
|
3537
|
+
"marquee",
|
|
3538
|
+
"ticker tape",
|
|
3539
|
+
"logo strip",
|
|
3540
|
+
"infinite scroll text",
|
|
3541
|
+
"scrolling banner"
|
|
3542
|
+
],
|
|
3543
|
+
"allOf": [],
|
|
3544
|
+
"unless": [],
|
|
3545
|
+
"minHits": 1,
|
|
3546
|
+
"priority": 620,
|
|
3547
|
+
"verdict": "composition",
|
|
3548
|
+
"headline": "A conductor-driven strip whose speed reads scroll velocity, with a reduced-motion stop.",
|
|
3549
|
+
"because": "A marquee is autonomous motion, which makes it the clearest case in the catalogue for a reduced-motion guard — continuous horizontal drift is exactly what the preference exists to stop. Driving it from the conductor rather than a CSS animation buys the thing CSS cannot do: the strip reacts to scroll velocity, which is what makes it feel connected to the page rather than pasted on top.",
|
|
3550
|
+
"use": [
|
|
3551
|
+
"useSensorBus",
|
|
3552
|
+
"useTick",
|
|
3553
|
+
"damp"
|
|
3554
|
+
],
|
|
3555
|
+
"pattern": "velocity-marquee",
|
|
3556
|
+
"components": [
|
|
3557
|
+
"image-ticker"
|
|
3558
|
+
],
|
|
3559
|
+
"instead": "A CSS `animation: scroll linear infinite`, which cannot react to anything.",
|
|
3560
|
+
"watchFor": [
|
|
3561
|
+
"no-reduced-motion",
|
|
3562
|
+
"orphan-raf"
|
|
3563
|
+
],
|
|
3564
|
+
"notWhen": "The strip does not need to react. A plain CSS animation with a reduced-motion guard runs on the compositor and costs no main-thread time at all — take it."
|
|
3565
|
+
},
|
|
3566
|
+
{
|
|
3567
|
+
"id": "live-dashboard-numbers",
|
|
3568
|
+
"when": [
|
|
3569
|
+
"dashboard",
|
|
3570
|
+
"live numbers",
|
|
3571
|
+
"realtime metrics",
|
|
3572
|
+
"kpi",
|
|
3573
|
+
"stat cards",
|
|
3574
|
+
"updating figures"
|
|
3575
|
+
],
|
|
3576
|
+
"allOf": [],
|
|
3577
|
+
"unless": [],
|
|
3578
|
+
"minHits": 1,
|
|
3579
|
+
"priority": 610,
|
|
3580
|
+
"verdict": "composition",
|
|
3581
|
+
"headline": "Tickers writing text nodes directly, with the expensive chart held behind a mount gate.",
|
|
3582
|
+
"because": "A dashboard's problem is that everything arrives at once: hydration, a chart mount, and a stream of figures each wanting to animate. Tickers write to DOM nodes so React is not re-rendering per frame for a string, and the chart waits for measured headroom so its mount does not land on top of hydration.",
|
|
3583
|
+
"use": [
|
|
3584
|
+
"useNumberTicker",
|
|
3585
|
+
"useSafeToMount"
|
|
3586
|
+
],
|
|
3587
|
+
"pattern": "dashboard-live-numbers",
|
|
3588
|
+
"components": [],
|
|
3589
|
+
"instead": "State-driven counters re-rendering the tree, and a chart mounted immediately.",
|
|
3590
|
+
"watchFor": [
|
|
3591
|
+
"setstate-per-frame",
|
|
3592
|
+
"client-boundary"
|
|
3593
|
+
],
|
|
3594
|
+
"notWhen": "The figures change rarely. A number that updates once a minute should just change — animating it draws the eye to noise, and the roll costs a re-layout of everything on the same line."
|
|
3595
|
+
},
|
|
3596
|
+
{
|
|
3597
|
+
"id": "predictive-media-card",
|
|
3598
|
+
"when": [
|
|
3599
|
+
"hover preview",
|
|
3600
|
+
"video preview on hover",
|
|
3601
|
+
"media card",
|
|
3602
|
+
"card grid preview",
|
|
3603
|
+
"preview on hover"
|
|
3604
|
+
],
|
|
3605
|
+
"allOf": [],
|
|
3606
|
+
"unless": [],
|
|
3607
|
+
"minHits": 1,
|
|
3608
|
+
"priority": 600,
|
|
3609
|
+
"verdict": "composition",
|
|
3610
|
+
"headline": "Predict the approach, warm the media, and let the poster stay until it can actually play.",
|
|
3611
|
+
"because": "Starting a decode on hover means the first frames arrive after the visitor is already looking, so the card flashes. Predicting the approach moves the work earlier by the time it takes the pointer to arrive, which is usually enough. The discipline that makes it feel right is holding the poster until playback is genuinely ready rather than swapping on the intent signal.",
|
|
3612
|
+
"use": [
|
|
3613
|
+
"usePointerIntent"
|
|
3614
|
+
],
|
|
3615
|
+
"pattern": "predictive-media-card",
|
|
3616
|
+
"components": [
|
|
3617
|
+
"video-card",
|
|
3618
|
+
"lookbook"
|
|
3619
|
+
],
|
|
3620
|
+
"instead": "`onMouseEnter` starting a decode the visitor waits through.",
|
|
3621
|
+
"watchFor": [
|
|
3622
|
+
"setstate-per-frame",
|
|
3623
|
+
"transform-conflict"
|
|
3624
|
+
],
|
|
3625
|
+
"notWhen": "The grid is dense and the media is heavy. Prediction fires on approach, and on a tight grid that means warming several neighbours at once — lower the sensitivity or gate on proximity."
|
|
3626
|
+
},
|
|
3627
|
+
{
|
|
3628
|
+
"id": "not-react",
|
|
3629
|
+
"when": [
|
|
3630
|
+
"vue",
|
|
3631
|
+
"svelte",
|
|
3632
|
+
"solid",
|
|
3633
|
+
"angular",
|
|
3634
|
+
"astro",
|
|
3635
|
+
"vanilla",
|
|
3636
|
+
"no react",
|
|
3637
|
+
"without react",
|
|
3638
|
+
"plain js"
|
|
3639
|
+
],
|
|
3640
|
+
"allOf": [],
|
|
3641
|
+
"unless": [],
|
|
3642
|
+
"minHits": 1,
|
|
3643
|
+
"priority": 820,
|
|
3644
|
+
"verdict": "single",
|
|
3645
|
+
"headline": "The engine classes and the `get*` accessors. Everything works; only the binding is React-specific.",
|
|
3646
|
+
"because": "The conductor, the sensor bus, the governors and the maths are framework-agnostic singletons reached through accessors — nothing about them needs React. The effects are classes with one lifecycle: construct with an element and options, `update` to change options live, `destroy` to release. The React hooks are thin wrappers over exactly those, so mixing a Vue page with a React island costs nothing extra: both retain the same page-wide loop.",
|
|
3647
|
+
"use": [
|
|
3648
|
+
"EffectEngines",
|
|
3649
|
+
"FrameConductor",
|
|
3650
|
+
"SensorBus"
|
|
3651
|
+
],
|
|
3652
|
+
"components": [],
|
|
3653
|
+
"instead": "Reimplementing the effects for the framework, each with its own frame loop.",
|
|
3654
|
+
"watchFor": [
|
|
3655
|
+
"orphan-subscription",
|
|
3656
|
+
"ssr-module-scope"
|
|
3657
|
+
],
|
|
3658
|
+
"notWhen": "You are in React after all. The hooks handle construction, the ref swap and teardown, and `useTick` additionally joins the surrounding interaction scope, which the raw subscribe does not do on its own."
|
|
3659
|
+
},
|
|
3660
|
+
{
|
|
3661
|
+
"id": "custom-effect-fallback",
|
|
3662
|
+
"when": [
|
|
3663
|
+
"animate",
|
|
3664
|
+
"animation",
|
|
3665
|
+
"motion",
|
|
3666
|
+
"effect",
|
|
3667
|
+
"interactive",
|
|
3668
|
+
"canvas",
|
|
3669
|
+
"transform",
|
|
3670
|
+
"follow",
|
|
3671
|
+
"physics",
|
|
3672
|
+
"simulation"
|
|
3673
|
+
],
|
|
3674
|
+
"allOf": [],
|
|
3675
|
+
"unless": [],
|
|
3676
|
+
"minHits": 1,
|
|
3677
|
+
"priority": 100,
|
|
3678
|
+
"verdict": "custom",
|
|
3679
|
+
"headline": "Nothing here covers it exactly. Build it on the conductor, not on your own loop.",
|
|
3680
|
+
"because": "No primitive matches what you described closely enough to name one honestly. That is a normal outcome and it does not change the scheduling answer: the effect still has to share the page's frame budget, still has to read before it writes, and still has to be visible to the shedding scheduler and to devtools. `custom-frame-effect` is the skeleton for exactly this — subscribe, read in `input`, compute in `update`, write in `render`, return the unsubscribe.",
|
|
3681
|
+
"use": [
|
|
3682
|
+
"useTick",
|
|
3683
|
+
"useSensorBus",
|
|
3684
|
+
"damp"
|
|
3685
|
+
],
|
|
3686
|
+
"pattern": "custom-frame-effect",
|
|
3687
|
+
"components": [],
|
|
3688
|
+
"instead": "A `requestAnimationFrame` loop in a `useEffect`, which is what training data suggests by default.",
|
|
3689
|
+
"watchFor": [
|
|
3690
|
+
"orphan-raf",
|
|
3691
|
+
"orphan-subscription",
|
|
3692
|
+
"lane-discipline",
|
|
3693
|
+
"setstate-per-frame",
|
|
3694
|
+
"no-reduced-motion"
|
|
3695
|
+
],
|
|
3696
|
+
"notWhen": "The effect is a transition between two states, a hover, or a one-time reveal. Those are CSS, and a frame subscription for them is main-thread time spent reproducing something the compositor does for free."
|
|
3697
|
+
},
|
|
3698
|
+
{
|
|
3699
|
+
"id": "css-enter-exit",
|
|
3700
|
+
"when": [
|
|
3701
|
+
"fade in",
|
|
3702
|
+
"fade out",
|
|
3703
|
+
"slide in",
|
|
3704
|
+
"slide up",
|
|
3705
|
+
"entrance",
|
|
3706
|
+
"enter animation",
|
|
3707
|
+
"exit animation",
|
|
3708
|
+
"appear",
|
|
3709
|
+
"on mount",
|
|
3710
|
+
"mount animation",
|
|
3711
|
+
"transition in",
|
|
3712
|
+
"fade",
|
|
3713
|
+
"slide",
|
|
3714
|
+
"dissolve",
|
|
3715
|
+
"modal",
|
|
3716
|
+
"dropdown",
|
|
3717
|
+
"tooltip",
|
|
3718
|
+
"accordion"
|
|
3719
|
+
],
|
|
3720
|
+
"allOf": [],
|
|
3721
|
+
"unless": [
|
|
3722
|
+
"every frame",
|
|
3723
|
+
"per frame",
|
|
3724
|
+
"scroll",
|
|
3725
|
+
"pointer",
|
|
3726
|
+
"cursor",
|
|
3727
|
+
"3d",
|
|
3728
|
+
"shader",
|
|
3729
|
+
"webgl",
|
|
3730
|
+
"scrub"
|
|
3731
|
+
],
|
|
3732
|
+
"minHits": 1,
|
|
3733
|
+
"priority": 950,
|
|
3734
|
+
"verdict": "no-runtime",
|
|
3735
|
+
"headline": "This is a CSS transition. No runtime primitive belongs here.",
|
|
3736
|
+
"because": "An element moving from one state to another on mount or on a class change is what CSS transitions and the Web Animations API were built for. Both run on the compositor, off the main thread, so they keep animating while JavaScript is busy — which is exactly when a frame-loop version starts stuttering. A per-frame implementation of a fade costs main-thread time to produce a worse result.",
|
|
3737
|
+
"use": [],
|
|
3738
|
+
"components": [],
|
|
3739
|
+
"instead": "A CSS transition, a keyframe animation, or `element.animate()`. Wrap the state change in `startViewTransition()` when the element also moves between layouts.",
|
|
3740
|
+
"watchFor": [
|
|
3741
|
+
"no-reduced-motion"
|
|
3742
|
+
],
|
|
3743
|
+
"notWhen": "The motion has to keep tracking something that changes continuously — the pointer, scroll position, a physics value. A transition runs from A to B on its own clock and cannot follow a moving target; that is when a frame subscription starts earning its cost."
|
|
3744
|
+
},
|
|
3745
|
+
{
|
|
3746
|
+
"id": "css-hover",
|
|
3747
|
+
"when": [
|
|
3748
|
+
"hover",
|
|
3749
|
+
"hover state",
|
|
3750
|
+
"hover effect",
|
|
3751
|
+
"mouse over",
|
|
3752
|
+
"on hover"
|
|
3753
|
+
],
|
|
3754
|
+
"allOf": [],
|
|
3755
|
+
"unless": [
|
|
3756
|
+
"magnetic",
|
|
3757
|
+
"predict",
|
|
3758
|
+
"prefetch",
|
|
3759
|
+
"before the cursor",
|
|
3760
|
+
"trail",
|
|
3761
|
+
"follow the cursor",
|
|
3762
|
+
"proximity",
|
|
3763
|
+
"3d",
|
|
3764
|
+
"shader",
|
|
3765
|
+
"webgl",
|
|
3766
|
+
"warm"
|
|
3767
|
+
],
|
|
3768
|
+
"minHits": 1,
|
|
3769
|
+
"priority": 940,
|
|
3770
|
+
"verdict": "no-runtime",
|
|
3771
|
+
"headline": "`:hover` already does this, and it does it while the thread is blocked.",
|
|
3772
|
+
"because": "A colour, scale, shadow or opacity change on hover is a pseudo-class and a transition. There is no measurement to do and nothing to recompute per frame, so a JavaScript version adds a listener, a state write and a re-render to reproduce two lines of CSS.",
|
|
3773
|
+
"use": [],
|
|
3774
|
+
"components": [],
|
|
3775
|
+
"instead": "`:hover` with a `transition` on the properties that change.",
|
|
3776
|
+
"watchFor": [],
|
|
3777
|
+
"notWhen": "You need to know the pointer is coming BEFORE it arrives — to prefetch a route, warm a video, or compile a shader. `:hover` fires on contact, which is already too late for work that takes time. That is `usePointerIntent`."
|
|
3778
|
+
},
|
|
3779
|
+
{
|
|
3780
|
+
"id": "css-loading-state",
|
|
3781
|
+
"when": [
|
|
3782
|
+
"spinner",
|
|
3783
|
+
"loading indicator",
|
|
3784
|
+
"loading animation",
|
|
3785
|
+
"skeleton",
|
|
3786
|
+
"shimmer",
|
|
3787
|
+
"progress bar"
|
|
3788
|
+
],
|
|
3789
|
+
"allOf": [],
|
|
3790
|
+
"unless": [
|
|
3791
|
+
"scroll",
|
|
3792
|
+
"3d",
|
|
3793
|
+
"shader",
|
|
3794
|
+
"webgl",
|
|
3795
|
+
"reading progress",
|
|
3796
|
+
"pointer"
|
|
3797
|
+
],
|
|
3798
|
+
"minHits": 1,
|
|
3799
|
+
"priority": 930,
|
|
3800
|
+
"verdict": "no-runtime",
|
|
3801
|
+
"headline": "A CSS keyframe animation, plus a reduced-motion guard.",
|
|
3802
|
+
"because": "A spinner or shimmer repeats forever on a fixed clock with no input. That is the one case where `animation: … infinite` is exactly right, and it keeps spinning during the very main-thread work the spinner exists to cover — which a frame-loop version, by definition, would not.",
|
|
3803
|
+
"use": [],
|
|
3804
|
+
"components": [],
|
|
3805
|
+
"instead": "`@keyframes` with `animation: spin 1s linear infinite`, inside a `@media (prefers-reduced-motion: no-preference)` block so the motion is opt-out by default.",
|
|
3806
|
+
"watchFor": [
|
|
3807
|
+
"no-reduced-motion"
|
|
3808
|
+
],
|
|
3809
|
+
"notWhen": "The indicator reflects real progress that something else is measuring. Then it is driven by a value, not a clock, and `damp` is worth having so the bar eases toward each new reading instead of jumping."
|
|
3810
|
+
},
|
|
3811
|
+
{
|
|
3812
|
+
"id": "reveal-on-scroll-once",
|
|
3813
|
+
"when": [
|
|
3814
|
+
"reveal on scroll",
|
|
3815
|
+
"scroll reveal",
|
|
3816
|
+
"animate on scroll",
|
|
3817
|
+
"scroll into view"
|
|
3818
|
+
],
|
|
3819
|
+
"allOf": [
|
|
3820
|
+
[
|
|
3821
|
+
"reveal",
|
|
3822
|
+
"fade in",
|
|
3823
|
+
"fade",
|
|
3824
|
+
"appear",
|
|
3825
|
+
"animate",
|
|
3826
|
+
"slide in",
|
|
3827
|
+
"slide up",
|
|
3828
|
+
"show up"
|
|
3829
|
+
],
|
|
3830
|
+
[
|
|
3831
|
+
"scroll",
|
|
3832
|
+
"into view",
|
|
3833
|
+
"in view",
|
|
3834
|
+
"viewport",
|
|
3835
|
+
"visible",
|
|
3836
|
+
"on screen",
|
|
3837
|
+
"below the fold"
|
|
3838
|
+
]
|
|
3839
|
+
],
|
|
3840
|
+
"unless": [
|
|
3841
|
+
"parallax",
|
|
3842
|
+
"scrub",
|
|
3843
|
+
"3d",
|
|
3844
|
+
"webgl",
|
|
3845
|
+
"shader",
|
|
3846
|
+
"smooth",
|
|
3847
|
+
"progress",
|
|
3848
|
+
"continuous",
|
|
3849
|
+
"video"
|
|
3850
|
+
],
|
|
3851
|
+
"minHits": 1,
|
|
3852
|
+
"priority": 920,
|
|
3853
|
+
"verdict": "no-runtime",
|
|
3854
|
+
"headline": "One IntersectionObserver and one class. No frame loop, no scroll listener.",
|
|
3855
|
+
"because": "A reveal fires once, at a threshold, and then never again. IntersectionObserver was added to the platform precisely so this would stop being a scroll handler: it is asynchronous, it does not force layout, and it costs nothing between crossings. Reading scroll position every frame to decide whether an element has crossed a line is the expensive way to ask a question the browser answers for free.",
|
|
3856
|
+
"use": [],
|
|
3857
|
+
"components": [],
|
|
3858
|
+
"instead": "`new IntersectionObserver(([e]) => e.isIntersecting && el.classList.add('in-view'))`, with the animation itself in CSS. Disconnect after the first crossing if it should not repeat.",
|
|
3859
|
+
"watchFor": [
|
|
3860
|
+
"missing-cleanup",
|
|
3861
|
+
"no-reduced-motion"
|
|
3862
|
+
],
|
|
3863
|
+
"notWhen": "The motion is continuous across the scroll rather than triggered by it — a parallax offset, a scrubbed video, a progress value. Those need a position every frame, which is `useSensorBus` with `useTick`."
|
|
3864
|
+
},
|
|
3865
|
+
{
|
|
3866
|
+
"id": "layout-and-route-transitions",
|
|
3867
|
+
"when": [
|
|
3868
|
+
"layout animation",
|
|
3869
|
+
"shared element",
|
|
3870
|
+
"page transition",
|
|
3871
|
+
"route transition",
|
|
3872
|
+
"reorder",
|
|
3873
|
+
"list animation",
|
|
3874
|
+
"view transition"
|
|
3875
|
+
],
|
|
3876
|
+
"allOf": [],
|
|
3877
|
+
"unless": [
|
|
3878
|
+
"3d",
|
|
3879
|
+
"shader",
|
|
3880
|
+
"webgl",
|
|
3881
|
+
"scroll",
|
|
3882
|
+
"pointer"
|
|
3883
|
+
],
|
|
3884
|
+
"minHits": 1,
|
|
3885
|
+
"priority": 910,
|
|
3886
|
+
"verdict": "no-runtime",
|
|
3887
|
+
"headline": "View Transitions, or whatever layout engine your framework already ships.",
|
|
3888
|
+
"because": "Animating between two layouts means knowing where every element was and where it landed. The browser knows both, and `startViewTransition()` hands you the crossfade without any measurement at all. Reimplementing FLIP over a frame loop means reading layout for every participating element, which is the single most expensive thing you can do per frame.",
|
|
3889
|
+
"use": [],
|
|
3890
|
+
"components": [],
|
|
3891
|
+
"instead": "`document.startViewTransition()`, or your framework's layout animation (Framer Motion's `layout` prop, Next's view transitions).",
|
|
3892
|
+
"watchFor": [
|
|
3893
|
+
"lane-discipline"
|
|
3894
|
+
],
|
|
3895
|
+
"notWhen": "The thing being transitioned is a WebGL scene. Contexts do not crossfade, and the answer there is a scene gate holding the old one alive until the new one has drawn a frame."
|
|
3896
|
+
},
|
|
3897
|
+
{
|
|
3898
|
+
"id": "native-video-playback",
|
|
3899
|
+
"when": [
|
|
3900
|
+
"video player",
|
|
3901
|
+
"video controls",
|
|
3902
|
+
"play button",
|
|
3903
|
+
"autoplay",
|
|
3904
|
+
"background video"
|
|
3905
|
+
],
|
|
3906
|
+
"allOf": [],
|
|
3907
|
+
"unless": [
|
|
3908
|
+
"scrub",
|
|
3909
|
+
"scroll",
|
|
3910
|
+
"timeline",
|
|
3911
|
+
"frame by frame",
|
|
3912
|
+
"scrollytelling",
|
|
3913
|
+
"shader"
|
|
3914
|
+
],
|
|
3915
|
+
"minHits": 1,
|
|
3916
|
+
"priority": 900,
|
|
3917
|
+
"verdict": "no-runtime",
|
|
3918
|
+
"headline": "The `<video>` element. Nothing here improves on it.",
|
|
3919
|
+
"because": "Playback, buffering, codec selection and picture-in-picture are the element's job, and it does them on a media thread you cannot reach from JavaScript. There is no per-frame work to coordinate, so there is nothing for a frame budget to govern.",
|
|
3920
|
+
"use": [],
|
|
3921
|
+
"components": [],
|
|
3922
|
+
"instead": "`<video autoPlay muted loop playsInline />`, with `poster` for the first paint.",
|
|
3923
|
+
"watchFor": [],
|
|
3924
|
+
"notWhen": "The timeline is driven by something other than the clock — scroll position, a drag, a progress value. Seeking a video every frame needs seek discipline that the element does not provide, and that is `useVideoScrubber`."
|
|
3925
|
+
},
|
|
3926
|
+
{
|
|
3927
|
+
"id": "gpu-resource-disposal",
|
|
3928
|
+
"when": [
|
|
3929
|
+
"dispose",
|
|
3930
|
+
"texture leak",
|
|
3931
|
+
"gpu memory",
|
|
3932
|
+
"memory leak",
|
|
3933
|
+
"out of memory",
|
|
3934
|
+
"leaking textures"
|
|
3935
|
+
],
|
|
3936
|
+
"allOf": [],
|
|
3937
|
+
"unless": [],
|
|
3938
|
+
"minHits": 1,
|
|
3939
|
+
"priority": 890,
|
|
3940
|
+
"verdict": "no-runtime",
|
|
3941
|
+
"headline": "No primitive owns this. It is disposal discipline, and it has to be yours.",
|
|
3942
|
+
"because": "Geometries, materials, textures and render targets hold GPU memory that garbage collection cannot reclaim — the JavaScript object is small and the allocation behind it is not. Nothing in this runtime allocates them, so nothing in it can free them. React Three Fiber disposes what it created declaratively; anything constructed by hand in an effect is yours to release.",
|
|
3943
|
+
"use": [],
|
|
3944
|
+
"components": [],
|
|
3945
|
+
"instead": "Dispose every hand-constructed resource in the same effect's cleanup, and never dispose one created at module scope — that is shared across every instance on the page, and freeing it breaks the second copy rather than the first.",
|
|
3946
|
+
"watchFor": [
|
|
3947
|
+
"missing-cleanup"
|
|
3948
|
+
],
|
|
3949
|
+
"notWhen": "The problem is not memory but frames. If the scene is janky rather than crashing, start with `useFramePressure` to find out what is actually eating the frame before optimising anything."
|
|
3950
|
+
},
|
|
3951
|
+
{
|
|
3952
|
+
"id": "per-frame-work",
|
|
3953
|
+
"when": [
|
|
3954
|
+
"every frame",
|
|
3955
|
+
"per frame",
|
|
3956
|
+
"requestanimationframe",
|
|
3957
|
+
"raf loop",
|
|
3958
|
+
"animation loop",
|
|
3959
|
+
"game loop",
|
|
3960
|
+
"ticker",
|
|
3961
|
+
"frame loop",
|
|
3962
|
+
"custom effect"
|
|
3963
|
+
],
|
|
3964
|
+
"allOf": [],
|
|
3965
|
+
"unless": [],
|
|
3966
|
+
"minHits": 1,
|
|
3967
|
+
"priority": 480,
|
|
3968
|
+
"verdict": "single",
|
|
3969
|
+
"headline": "`useTick` — the shared frame loop, with the subscription lifecycle handled.",
|
|
3970
|
+
"because": "Per-frame work is the thing this runtime exists to coordinate. One loop for the page means the scheduler can order reads before writes, measure what each subscriber costs, and shed decorative work when a frame runs long. A second loop is invisible to all three: it cannot be shed, it does not appear in devtools, and the pressure classifier bills its time to \"main-thread\" — so the page cannot even tell the cost is its own.",
|
|
3971
|
+
"use": [
|
|
3972
|
+
"useTick"
|
|
3973
|
+
],
|
|
3974
|
+
"components": [],
|
|
3975
|
+
"instead": "A `requestAnimationFrame` loop inside a `useEffect`, plus the cancel you have to remember.",
|
|
3976
|
+
"watchFor": [
|
|
3977
|
+
"orphan-raf",
|
|
3978
|
+
"lane-discipline",
|
|
3979
|
+
"setstate-per-frame"
|
|
3980
|
+
],
|
|
3981
|
+
"notWhen": "The work is not actually per-frame. Something that recomputes on an event, on a timer, or once on mount belongs in a handler or an effect; a frame subscription for it runs sixty times a second to discover nothing changed."
|
|
3982
|
+
},
|
|
3983
|
+
{
|
|
3984
|
+
"id": "pointer-and-scroll-telemetry",
|
|
3985
|
+
"when": [
|
|
3986
|
+
"pointer position",
|
|
3987
|
+
"mouse position",
|
|
3988
|
+
"cursor position",
|
|
3989
|
+
"scroll position",
|
|
3990
|
+
"scroll velocity",
|
|
3991
|
+
"pointer velocity",
|
|
3992
|
+
"viewport size",
|
|
3993
|
+
"where is the cursor",
|
|
3994
|
+
"follow the cursor",
|
|
3995
|
+
"follow the mouse",
|
|
3996
|
+
"track the cursor",
|
|
3997
|
+
"track the mouse",
|
|
3998
|
+
"chase the cursor"
|
|
3999
|
+
],
|
|
4000
|
+
"allOf": [],
|
|
4001
|
+
"unless": [
|
|
4002
|
+
"trail",
|
|
4003
|
+
"magnetic",
|
|
4004
|
+
"parallax"
|
|
4005
|
+
],
|
|
4006
|
+
"minHits": 1,
|
|
4007
|
+
"priority": 470,
|
|
4008
|
+
"verdict": "single",
|
|
4009
|
+
"headline": "`useSensorBus` — one set of listeners for the page, read as plain fields.",
|
|
4010
|
+
"because": "Pointer, scroll and viewport are read by almost every effect, and the naive version attaches a listener per consumer. Ten components tracking the pointer means ten `pointermove` handlers firing on one event. The bus attaches once, is reference-counted, and exposes live fields you read inside a frame callback — no subscription per reader, no allocation per frame.",
|
|
4011
|
+
"use": [
|
|
4012
|
+
"useSensorBus",
|
|
4013
|
+
"useTick"
|
|
4014
|
+
],
|
|
4015
|
+
"components": [],
|
|
4016
|
+
"instead": "A `pointermove` or `scroll` listener per component, each with its own state and its own removal.",
|
|
4017
|
+
"watchFor": [
|
|
4018
|
+
"setstate-per-frame",
|
|
4019
|
+
"missing-cleanup"
|
|
4020
|
+
],
|
|
4021
|
+
"notWhen": "You need one value, once, on one event — a click coordinate, say. An ordinary handler is cheaper than retaining a page-wide sensor for a single reading."
|
|
4022
|
+
},
|
|
4023
|
+
{
|
|
4024
|
+
"id": "smoothing-a-value",
|
|
4025
|
+
"when": [
|
|
4026
|
+
"smooth",
|
|
4027
|
+
"damping",
|
|
4028
|
+
"lerp",
|
|
4029
|
+
"ease toward",
|
|
4030
|
+
"inertia",
|
|
4031
|
+
"spring",
|
|
4032
|
+
"follow smoothly",
|
|
4033
|
+
"easing"
|
|
4034
|
+
],
|
|
4035
|
+
"allOf": [],
|
|
4036
|
+
"unless": [],
|
|
4037
|
+
"minHits": 1,
|
|
4038
|
+
"priority": 460,
|
|
4039
|
+
"verdict": "single",
|
|
4040
|
+
"headline": "`damp` — frame-rate-independent smoothing, one line inside your frame callback.",
|
|
4041
|
+
"because": "The usual `current += (target - current) * 0.1` is a lie about time: it moves ten percent per FRAME, so the same code is twice as fast on a 120Hz display and slows to a crawl when the page is busy. `damp` takes `dt` and converges at a rate per SECOND, which is why motion written with it feels identical everywhere.",
|
|
4042
|
+
"use": [
|
|
4043
|
+
"damp"
|
|
4044
|
+
],
|
|
4045
|
+
"components": [],
|
|
4046
|
+
"instead": "A hand-written lerp with a magic constant that behaves differently on every display.",
|
|
4047
|
+
"watchFor": [],
|
|
4048
|
+
"notWhen": "The value should arrive at a fixed time — a 300ms transition. Damping is asymptotic and never quite lands; use an eased transition when the duration is the requirement."
|
|
4049
|
+
},
|
|
4050
|
+
{
|
|
4051
|
+
"id": "defer-an-expensive-mount",
|
|
4052
|
+
"when": [
|
|
4053
|
+
"defer mount",
|
|
4054
|
+
"mount later",
|
|
4055
|
+
"expensive component",
|
|
4056
|
+
"heavy component",
|
|
4057
|
+
"wait until idle",
|
|
4058
|
+
"hydration",
|
|
4059
|
+
"slow first load",
|
|
4060
|
+
"mount when calm"
|
|
4061
|
+
],
|
|
4062
|
+
"allOf": [],
|
|
4063
|
+
"unless": [
|
|
4064
|
+
"3d",
|
|
4065
|
+
"webgl",
|
|
4066
|
+
"shader",
|
|
4067
|
+
"scroll",
|
|
4068
|
+
"in view",
|
|
4069
|
+
"below the fold"
|
|
4070
|
+
],
|
|
4071
|
+
"minHits": 1,
|
|
4072
|
+
"priority": 450,
|
|
4073
|
+
"verdict": "single",
|
|
4074
|
+
"headline": "`useSafeToMount({ cost })` — hold the mount until the thread can absorb it.",
|
|
4075
|
+
"because": "An expensive component mounting during hydration lands its cost on the busiest moment of the page's life, which is also the moment a visitor is most likely to try to interact. The gate watches real frame headroom and opens once the page has some to spare. You describe how expensive the thing is and the thresholds follow from that.",
|
|
4076
|
+
"use": [
|
|
4077
|
+
"useSafeToMount"
|
|
4078
|
+
],
|
|
4079
|
+
"components": [
|
|
4080
|
+
"heart-stream",
|
|
4081
|
+
"jigsaw-puzzle"
|
|
4082
|
+
],
|
|
4083
|
+
"instead": "A `setTimeout` guess, or mounting immediately and hoping.",
|
|
4084
|
+
"watchFor": [
|
|
4085
|
+
"client-boundary"
|
|
4086
|
+
],
|
|
4087
|
+
"notWhen": "The component is below the fold. Frame headroom alone will happily mount something nobody can see — `useSceneGate` adds viewport proximity, a quality verdict and context recovery on top."
|
|
4088
|
+
},
|
|
4089
|
+
{
|
|
4090
|
+
"id": "device-capability-tier",
|
|
4091
|
+
"when": [
|
|
4092
|
+
"device tier",
|
|
4093
|
+
"low end device",
|
|
4094
|
+
"weak device",
|
|
4095
|
+
"capability",
|
|
4096
|
+
"quality setting",
|
|
4097
|
+
"detect gpu",
|
|
4098
|
+
"how powerful",
|
|
4099
|
+
"adapt quality"
|
|
4100
|
+
],
|
|
4101
|
+
"allOf": [],
|
|
4102
|
+
"unless": [
|
|
4103
|
+
"scene",
|
|
4104
|
+
"canvas",
|
|
4105
|
+
"r3f",
|
|
4106
|
+
"react three fiber"
|
|
4107
|
+
],
|
|
4108
|
+
"minHits": 1,
|
|
4109
|
+
"priority": 440,
|
|
4110
|
+
"verdict": "single",
|
|
4111
|
+
"headline": "`useAdaptiveQuality` — one tier, fused from hardware, live frames and the motion preference.",
|
|
4112
|
+
"because": "Hardware probing alone dates instantly and reads badly on the devices that matter; frame measurement alone cannot tell you anything before the first frame. The hook fuses both, plus `prefers-reduced-motion`, and tells you WHY it landed where it did — so a scene can explain itself instead of silently rendering at a third of its detail.",
|
|
4113
|
+
"use": [
|
|
4114
|
+
"useAdaptiveQuality"
|
|
4115
|
+
],
|
|
4116
|
+
"components": [],
|
|
4117
|
+
"instead": "Sniffing the user agent, or branching on `navigator.hardwareConcurrency` alone.",
|
|
4118
|
+
"watchFor": [
|
|
4119
|
+
"no-reduced-motion"
|
|
4120
|
+
],
|
|
4121
|
+
"notWhen": "You are picking renderer settings for a `<Canvas>`. Then the tier is an input to `useSceneGate`, which already consumes it, and `useRenderQuality` turns the verdict into props."
|
|
4122
|
+
},
|
|
4123
|
+
{
|
|
4124
|
+
"id": "is-the-page-struggling",
|
|
4125
|
+
"when": [
|
|
4126
|
+
"frame budget",
|
|
4127
|
+
"dropping frames",
|
|
4128
|
+
"degrade gracefully",
|
|
4129
|
+
"is the page struggling",
|
|
4130
|
+
"under load"
|
|
4131
|
+
],
|
|
4132
|
+
"allOf": [],
|
|
4133
|
+
"unless": [],
|
|
4134
|
+
"minHits": 1,
|
|
4135
|
+
"priority": 430,
|
|
4136
|
+
"verdict": "single",
|
|
4137
|
+
"headline": "`useAnimationBudget` — the live health of the frame, as a tier you can branch on.",
|
|
4138
|
+
"because": "It reports what the page is actually managing right now, measured against the display's real refresh interval rather than an assumed sixty. That distinction matters: a 120Hz panel has half the frame to work with, and a threshold expressed in milliseconds means something different on each.",
|
|
4139
|
+
"use": [
|
|
4140
|
+
"useAnimationBudget"
|
|
4141
|
+
],
|
|
4142
|
+
"components": [],
|
|
4143
|
+
"instead": "Timing frames by hand and picking a threshold that is wrong on half of all displays.",
|
|
4144
|
+
"watchFor": [],
|
|
4145
|
+
"notWhen": "You want to know what is CAUSING the trouble rather than that there is trouble. The budget says the frame is full; `useFramePressure` says what filled it."
|
|
4146
|
+
},
|
|
4147
|
+
{
|
|
4148
|
+
"id": "diagnose-jank",
|
|
4149
|
+
"when": [
|
|
4150
|
+
"why is it janky",
|
|
4151
|
+
"what is slow",
|
|
4152
|
+
"diagnose",
|
|
4153
|
+
"debug performance",
|
|
4154
|
+
"profile",
|
|
4155
|
+
"stutter",
|
|
4156
|
+
"find the bottleneck",
|
|
4157
|
+
"what is eating the frame"
|
|
4158
|
+
],
|
|
4159
|
+
"allOf": [],
|
|
4160
|
+
"unless": [],
|
|
4161
|
+
"minHits": 1,
|
|
4162
|
+
"priority": 420,
|
|
4163
|
+
"verdict": "composition",
|
|
4164
|
+
"headline": "`useFramePressure` to name the culprit, then `mountDevtools` to name the subscriber.",
|
|
4165
|
+
"because": "They answer different questions and the order matters. The pressure classifier splits the frame into this runtime's own subscribers, other main-thread work, and an unattributed remainder — which tells you whether the problem is even yours. Only if it is does the devtools overlay help, and then it names the exact subscriber and what it costs.",
|
|
4166
|
+
"use": [
|
|
4167
|
+
"useFramePressure",
|
|
4168
|
+
"mountDevtools"
|
|
4169
|
+
],
|
|
4170
|
+
"components": [],
|
|
4171
|
+
"instead": "Commenting things out until it gets faster.",
|
|
4172
|
+
"watchFor": [],
|
|
4173
|
+
"notWhen": "Nothing is subscribed yet. Neither can see a private `requestAnimationFrame` loop as anything but anonymous main-thread time, so move the work onto the conductor first and the attribution arrives with it."
|
|
4174
|
+
},
|
|
4175
|
+
{
|
|
4176
|
+
"id": "prefetch-on-approach",
|
|
4177
|
+
"when": [
|
|
4178
|
+
"prefetch",
|
|
4179
|
+
"preload on hover",
|
|
4180
|
+
"predict",
|
|
4181
|
+
"before the cursor arrives",
|
|
4182
|
+
"warm",
|
|
4183
|
+
"anticipate",
|
|
4184
|
+
"intent"
|
|
4185
|
+
],
|
|
4186
|
+
"allOf": [],
|
|
4187
|
+
"unless": [
|
|
4188
|
+
"magnetic"
|
|
4189
|
+
],
|
|
4190
|
+
"minHits": 1,
|
|
4191
|
+
"priority": 410,
|
|
4192
|
+
"verdict": "single",
|
|
4193
|
+
"headline": "`usePointerIntent` — fires while the pointer is still on its way.",
|
|
4194
|
+
"because": "Hover fires on contact, which is already too late for anything that takes time: a route fetch, a video decode, a shader compile. Casting the pointer's velocity forward buys the hundred or so milliseconds that turn a visible wait into an instant response.",
|
|
4195
|
+
"use": [
|
|
4196
|
+
"usePointerIntent"
|
|
4197
|
+
],
|
|
4198
|
+
"pattern": "magnetic-prefetch-cta",
|
|
4199
|
+
"components": [
|
|
4200
|
+
"video-card"
|
|
4201
|
+
],
|
|
4202
|
+
"instead": "An `onMouseEnter` handler that starts the work a visitor is already waiting on.",
|
|
4203
|
+
"watchFor": [
|
|
4204
|
+
"setstate-per-frame"
|
|
4205
|
+
],
|
|
4206
|
+
"notWhen": "The device has no pointer. On touch there is no approach to predict, so treat the prediction as an optimisation and make sure the ordinary path still works without it."
|
|
4207
|
+
},
|
|
4208
|
+
{
|
|
4209
|
+
"id": "magnetic-attraction",
|
|
4210
|
+
"when": [
|
|
4211
|
+
"magnetic",
|
|
4212
|
+
"attract the cursor",
|
|
4213
|
+
"pulls toward",
|
|
4214
|
+
"snap to cursor",
|
|
4215
|
+
"sticky button"
|
|
4216
|
+
],
|
|
4217
|
+
"allOf": [],
|
|
4218
|
+
"unless": [],
|
|
4219
|
+
"minHits": 1,
|
|
4220
|
+
"priority": 400,
|
|
4221
|
+
"verdict": "single",
|
|
4222
|
+
"headline": "`useMagneticIntent` — it owns the element's transform, which is the thing to know.",
|
|
4223
|
+
"because": "The effect is proximity plus prediction plus damping, and the hook composes the same pointer intent engine rather than duplicating the maths. The contract that matters is ownership: it writes `transform` every frame, so anything else animating the transform of the same element is a fight the frame loop wins.",
|
|
4224
|
+
"use": [
|
|
4225
|
+
"useMagneticIntent"
|
|
4226
|
+
],
|
|
4227
|
+
"pattern": "magnetic-prefetch-cta",
|
|
4228
|
+
"components": [
|
|
4229
|
+
"physics-buttons"
|
|
4230
|
+
],
|
|
4231
|
+
"instead": "A `mousemove` handler doing trigonometry and writing inline styles per event.",
|
|
4232
|
+
"watchFor": [
|
|
4233
|
+
"transform-conflict"
|
|
4234
|
+
],
|
|
4235
|
+
"notWhen": "The element also needs a CSS transform for layout or a hover scale. Give the hook its own node and put the other transform on a wrapper or a child."
|
|
4236
|
+
},
|
|
4237
|
+
{
|
|
4238
|
+
"id": "scrub-a-video",
|
|
4239
|
+
"when": [
|
|
4240
|
+
"scrub",
|
|
4241
|
+
"scroll video",
|
|
4242
|
+
"video timeline",
|
|
4243
|
+
"frame by frame",
|
|
4244
|
+
"scroll through video"
|
|
4245
|
+
],
|
|
4246
|
+
"allOf": [
|
|
4247
|
+
[
|
|
4248
|
+
"scroll",
|
|
4249
|
+
"scrub",
|
|
4250
|
+
"drag",
|
|
4251
|
+
"timeline",
|
|
4252
|
+
"progress"
|
|
4253
|
+
],
|
|
4254
|
+
[
|
|
4255
|
+
"video",
|
|
4256
|
+
"footage",
|
|
4257
|
+
"clip",
|
|
4258
|
+
"playback",
|
|
4259
|
+
"currenttime"
|
|
4260
|
+
]
|
|
4261
|
+
],
|
|
4262
|
+
"unless": [],
|
|
4263
|
+
"minHits": 1,
|
|
4264
|
+
"priority": 390,
|
|
4265
|
+
"verdict": "single",
|
|
4266
|
+
"headline": "`useVideoScrubber` — seeking a video from a driver, with the seek discipline built in.",
|
|
4267
|
+
"because": "Naive scrubbing issues a seek per frame and the decoder falls behind, which reads as the video freezing and then jumping. The scrubber coalesces seeks against the decoder's actual readiness, and it restores `muted`, `playsInline` and `preload` on teardown because it had to overwrite them to work at all.",
|
|
4268
|
+
"use": [
|
|
4269
|
+
"useVideoScrubber"
|
|
4270
|
+
],
|
|
4271
|
+
"pattern": "scrollytelling-video",
|
|
4272
|
+
"components": [
|
|
4273
|
+
"playpause"
|
|
4274
|
+
],
|
|
4275
|
+
"instead": "Assigning `video.currentTime` inside a scroll handler.",
|
|
4276
|
+
"watchFor": [
|
|
4277
|
+
"setstate-per-frame",
|
|
4278
|
+
"missing-cleanup"
|
|
4279
|
+
],
|
|
4280
|
+
"notWhen": "You want ordinary playback. Nothing here improves on the `<video>` element's own clock."
|
|
4281
|
+
},
|
|
4282
|
+
{
|
|
4283
|
+
"id": "animated-number",
|
|
4284
|
+
"when": [
|
|
4285
|
+
"count up",
|
|
4286
|
+
"counter",
|
|
4287
|
+
"animated number",
|
|
4288
|
+
"odometer",
|
|
4289
|
+
"rolling number",
|
|
4290
|
+
"animate a stat"
|
|
4291
|
+
],
|
|
4292
|
+
"allOf": [],
|
|
4293
|
+
"unless": [],
|
|
4294
|
+
"minHits": 1,
|
|
4295
|
+
"priority": 380,
|
|
4296
|
+
"verdict": "single",
|
|
4297
|
+
"headline": "`useNumberTicker` — writes the text node directly, so React never re-renders for it.",
|
|
4298
|
+
"because": "The obvious version puts the number in state and re-renders the component sixty times a second to change a string. The hook writes to the DOM node through a ref instead, formats with `Intl.NumberFormat`, and chases whatever value you hand it rather than running a fixed animation — so a number that changes mid-flight redirects rather than restarting.",
|
|
4299
|
+
"use": [
|
|
4300
|
+
"useNumberTicker"
|
|
4301
|
+
],
|
|
4302
|
+
"pattern": "dashboard-live-numbers",
|
|
4303
|
+
"components": [],
|
|
4304
|
+
"instead": "A `setInterval` stepping a state value toward a target.",
|
|
4305
|
+
"watchFor": [
|
|
4306
|
+
"setstate-per-frame"
|
|
4307
|
+
],
|
|
4308
|
+
"notWhen": "The number is not worth animating. A value that changes once on load reads as noise when it rolls, and every rolled digit is a re-layout of everything beside it unless the font is tabular."
|
|
4309
|
+
},
|
|
4310
|
+
{
|
|
4311
|
+
"id": "cursor-image-trail",
|
|
4312
|
+
"when": [
|
|
4313
|
+
"image trail",
|
|
4314
|
+
"trailing images",
|
|
4315
|
+
"cursor trail",
|
|
4316
|
+
"images follow the cursor"
|
|
4317
|
+
],
|
|
4318
|
+
"allOf": [],
|
|
4319
|
+
"unless": [],
|
|
4320
|
+
"minHits": 1,
|
|
4321
|
+
"priority": 370,
|
|
4322
|
+
"verdict": "single",
|
|
4323
|
+
"headline": "`useImageTrail` — a fixed pool of nodes, recycled.",
|
|
4324
|
+
"because": "The naive version appends an element per pointer move and removes it on a timer, which means unbounded DOM growth exactly while the visitor is moving fastest. A recycled pool has a known ceiling and never allocates during the effect.",
|
|
4325
|
+
"use": [
|
|
4326
|
+
"useImageTrail"
|
|
4327
|
+
],
|
|
4328
|
+
"components": [
|
|
4329
|
+
"trail-collector",
|
|
4330
|
+
"text-trail"
|
|
4331
|
+
],
|
|
4332
|
+
"instead": "Appending and removing nodes per pointer event.",
|
|
4333
|
+
"watchFor": [
|
|
4334
|
+
"no-reduced-motion"
|
|
4335
|
+
],
|
|
4336
|
+
"notWhen": "The page is content-first. This is ambient decoration over an image field; on a text page it costs frames a reader will notice and gives nothing back."
|
|
4337
|
+
},
|
|
4338
|
+
{
|
|
4339
|
+
"id": "protect-an-interactive-region",
|
|
4340
|
+
"when": [
|
|
4341
|
+
"keep this smooth",
|
|
4342
|
+
"prioritise",
|
|
4343
|
+
"prioritize",
|
|
4344
|
+
"interactive region",
|
|
4345
|
+
"protect the interaction"
|
|
4346
|
+
],
|
|
4347
|
+
"allOf": [
|
|
4348
|
+
[
|
|
4349
|
+
"drag",
|
|
4350
|
+
"resize",
|
|
4351
|
+
"scrub",
|
|
4352
|
+
"editor",
|
|
4353
|
+
"canvas",
|
|
4354
|
+
"panel",
|
|
4355
|
+
"region",
|
|
4356
|
+
"section",
|
|
4357
|
+
"interact",
|
|
4358
|
+
"manipulat"
|
|
4359
|
+
],
|
|
4360
|
+
[
|
|
4361
|
+
"smooth",
|
|
4362
|
+
"stutter",
|
|
4363
|
+
"responsive",
|
|
4364
|
+
"priority",
|
|
4365
|
+
"never drop",
|
|
4366
|
+
"jank",
|
|
4367
|
+
"lag",
|
|
4368
|
+
"must not",
|
|
4369
|
+
"keep up"
|
|
4370
|
+
]
|
|
4371
|
+
],
|
|
4372
|
+
"unless": [],
|
|
4373
|
+
"minHits": 1,
|
|
4374
|
+
"priority": 425,
|
|
4375
|
+
"verdict": "single",
|
|
4376
|
+
"headline": "`useInteractionScope` — the region a visitor is using keeps its frames; ambient work yields.",
|
|
4377
|
+
"because": "Priority alone is page-wide, so a decorative background and a decorative flourish inside the thing being dragged are treated identically. A scope adds the missing axis: while the region holds the foreground lease, subscribers belonging elsewhere are tested against a stricter shedding threshold. Anything rendered inside joins automatically.",
|
|
4378
|
+
"use": [
|
|
4379
|
+
"useInteractionScope",
|
|
4380
|
+
"useTick"
|
|
4381
|
+
],
|
|
4382
|
+
"components": [],
|
|
4383
|
+
"instead": "Pausing every other animation by hand when a drag starts, and forgetting to resume one.",
|
|
4384
|
+
"watchFor": [
|
|
4385
|
+
"lane-discipline"
|
|
4386
|
+
],
|
|
4387
|
+
"notWhen": "There is only one interactive region on the page. With nothing to yield, the scope is bookkeeping that changes no decision."
|
|
4388
|
+
},
|
|
4389
|
+
{
|
|
4390
|
+
"id": "r3f-scene",
|
|
4391
|
+
"when": [
|
|
4392
|
+
"react three fiber",
|
|
4393
|
+
"r3f",
|
|
4394
|
+
"three.js",
|
|
4395
|
+
"threejs",
|
|
4396
|
+
"3d scene",
|
|
4397
|
+
"webgl scene",
|
|
4398
|
+
"3d model",
|
|
4399
|
+
"gltf",
|
|
4400
|
+
"canvas 3d"
|
|
4401
|
+
],
|
|
4402
|
+
"allOf": [],
|
|
4403
|
+
"unless": [
|
|
4404
|
+
"shader",
|
|
4405
|
+
"glsl",
|
|
4406
|
+
"uniform"
|
|
4407
|
+
],
|
|
4408
|
+
"minHits": 1,
|
|
4409
|
+
"priority": 760,
|
|
4410
|
+
"verdict": "composition",
|
|
4411
|
+
"headline": "`useSceneGate` decides, `useRenderQuality` applies, your component owns the detail.",
|
|
4412
|
+
"because": "A `<Canvas>` mounted plainly renders whether or not anyone is looking at it, at whatever pixel ratio the display reports, on every device, forever — and goes permanently black the first time the browser reclaims its context. The three-part split fixes each of those in the one place it can be fixed. The gate holds the mount until the scene is near the viewport, scrolling has settled and there is frame headroom, and it hands back a `generation` to key on. The adapter turns its verdict into `<Canvas>` props, including the `onCreated` that notices a lost context. Instance counts and geometry detail are not renderer settings, so they stay in your component, keyed off the gate's `quality`.",
|
|
4413
|
+
"use": [
|
|
4414
|
+
"useSceneGate",
|
|
4415
|
+
"useRenderQuality"
|
|
4416
|
+
],
|
|
4417
|
+
"pattern": "lazy-3d-section",
|
|
4418
|
+
"components": [
|
|
4419
|
+
"volumetric-light",
|
|
4420
|
+
"refractive-gallery"
|
|
4421
|
+
],
|
|
4422
|
+
"instead": "A `<Canvas>` rendered directly, with a dynamic import and no gate — which defers the bundle and nothing else.",
|
|
4423
|
+
"watchFor": [
|
|
4424
|
+
"canvas-prop-override",
|
|
4425
|
+
"webgl-context-loss",
|
|
4426
|
+
"hand-rolled-scene-gate",
|
|
4427
|
+
"ssr-module-scope"
|
|
4428
|
+
],
|
|
4429
|
+
"notWhen": "The scene is above the fold, always visible, and runs at one quality on every device. Then the gate has no decision to make, and ordinary `<Canvas>` props are honest. Attach a `webglcontextlost` listener even so."
|
|
4430
|
+
},
|
|
4431
|
+
{
|
|
4432
|
+
"id": "shader-uniforms-per-frame",
|
|
4433
|
+
"when": [
|
|
4434
|
+
"shader",
|
|
4435
|
+
"glsl",
|
|
4436
|
+
"uniform",
|
|
4437
|
+
"fragment shader",
|
|
4438
|
+
"vertex shader",
|
|
4439
|
+
"shadermaterial",
|
|
4440
|
+
"raymarch",
|
|
4441
|
+
"noise shader"
|
|
4442
|
+
],
|
|
4443
|
+
"allOf": [],
|
|
4444
|
+
"unless": [],
|
|
4445
|
+
"minHits": 1,
|
|
4446
|
+
"priority": 750,
|
|
4447
|
+
"verdict": "composition",
|
|
4448
|
+
"headline": "Write uniforms from a `useTick` callback through a ref. Never through React state.",
|
|
4449
|
+
"because": "A uniform is a value on a GPU program, not application state. Putting it in `useState` means a React render, a reconciliation pass and a commit for every frame of a smooth animation — to change one float that React never reads. Hold the material in a ref and assign `material.uniforms.uTime.value` inside the callback; React is then uninvolved in the animation entirely, which is the point.\n\nTwo details that decide whether the scene is smooth. Use the `time` the conductor passes rather than `performance.now()`, so every effect on the page shares one clock and nothing drifts apart. And take shader compilation seriously: it is a synchronous multi-hundred-millisecond stall on some drivers, which is why the mount belongs behind a gate rather than in the hydration burst.",
|
|
4450
|
+
"use": [
|
|
4451
|
+
"useTick",
|
|
4452
|
+
"useSceneGate"
|
|
4453
|
+
],
|
|
4454
|
+
"pattern": "custom-frame-effect",
|
|
4455
|
+
"components": [
|
|
4456
|
+
"media-shader",
|
|
4457
|
+
"acrylic-shader"
|
|
4458
|
+
],
|
|
4459
|
+
"instead": "A `useFrame` writing to state, or a private `requestAnimationFrame` incrementing a time uniform.",
|
|
4460
|
+
"watchFor": [
|
|
4461
|
+
"setstate-per-frame",
|
|
4462
|
+
"orphan-raf",
|
|
4463
|
+
"lane-discipline"
|
|
4464
|
+
],
|
|
4465
|
+
"notWhen": "The uniform changes on an event rather than per frame — a colour from a picker, a mode from a toggle. Assign it in the handler; a frame subscription to copy an unchanged value is sixty wasted assignments a second."
|
|
4466
|
+
},
|
|
4467
|
+
{
|
|
4468
|
+
"id": "particles-and-instancing",
|
|
4469
|
+
"when": [
|
|
4470
|
+
"particle",
|
|
4471
|
+
"particles",
|
|
4472
|
+
"instanced",
|
|
4473
|
+
"instancing",
|
|
4474
|
+
"instancedmesh",
|
|
4475
|
+
"point cloud",
|
|
4476
|
+
"gpgpu",
|
|
4477
|
+
"thousands of"
|
|
4478
|
+
],
|
|
4479
|
+
"allOf": [],
|
|
4480
|
+
"unless": [],
|
|
4481
|
+
"minHits": 1,
|
|
4482
|
+
"priority": 740,
|
|
4483
|
+
"verdict": "composition",
|
|
4484
|
+
"headline": "One instanced draw, and the count comes from the gate's `quality`.",
|
|
4485
|
+
"because": "Particle counts are the largest single lever on whether a scene holds its frame, and they are the one thing the render adapter deliberately does not own — a count is not a renderer setting, so putting it in a `RenderProfile` would be an option that looks applied and is not. Read `scene.quality` in your component and pick the count from it.\n\nThe other half is the draw itself: one `InstancedMesh` with a per-instance matrix, not a mesh per particle. A thousand meshes is a thousand draw calls, and no amount of frame scheduling rescues that. Update the instance matrices in a `useTick` callback and set `needsUpdate` once at the end, never per instance.",
|
|
4486
|
+
"use": [
|
|
4487
|
+
"useSceneGate",
|
|
4488
|
+
"useTick"
|
|
4489
|
+
],
|
|
4490
|
+
"pattern": "lazy-3d-section",
|
|
4491
|
+
"components": [
|
|
4492
|
+
"particle-morph-gallery",
|
|
4493
|
+
"magnetic-sand",
|
|
4494
|
+
"wind-scatter"
|
|
4495
|
+
],
|
|
4496
|
+
"instead": "A fixed particle count chosen on the machine it was authored on.",
|
|
4497
|
+
"watchFor": [
|
|
4498
|
+
"canvas-prop-override",
|
|
4499
|
+
"setstate-per-frame"
|
|
4500
|
+
],
|
|
4501
|
+
"notWhen": "The count is small enough that the cost is elsewhere. Below a few hundred instances the draw is not the bottleneck, and `useFramePressure` will tell you what is before you tune anything."
|
|
4502
|
+
},
|
|
4503
|
+
{
|
|
4504
|
+
"id": "postprocessing",
|
|
4505
|
+
"when": [
|
|
4506
|
+
"postprocessing",
|
|
4507
|
+
"post processing",
|
|
4508
|
+
"effectcomposer",
|
|
4509
|
+
"bloom",
|
|
4510
|
+
"depth of field",
|
|
4511
|
+
"ssao",
|
|
4512
|
+
"render pass"
|
|
4513
|
+
],
|
|
4514
|
+
"allOf": [],
|
|
4515
|
+
"unless": [],
|
|
4516
|
+
"minHits": 1,
|
|
4517
|
+
"priority": 730,
|
|
4518
|
+
"verdict": "composition",
|
|
4519
|
+
"headline": "Gate the passes on `quality`, and lower the pixel ratio before you drop an effect.",
|
|
4520
|
+
"because": "Every pass is another full-screen draw at the current resolution, so post-processing multiplies whatever the pixel ratio costs. That ordering is the useful part: going from a device pixel ratio of 3 to 1.5 quarters the pixels shaded and usually reads as softer, while removing bloom changes what the scene IS. Let the adapter lower the ratio first and drop passes only at the bottom.\n\nPasses are not renderer settings either, so they do not belong in a `RenderProfile`. Compose them in your scene from the gate's `quality`, the same way instance counts are chosen.",
|
|
4521
|
+
"use": [
|
|
4522
|
+
"useSceneGate",
|
|
4523
|
+
"useRenderQuality"
|
|
4524
|
+
],
|
|
4525
|
+
"pattern": "lazy-3d-section",
|
|
4526
|
+
"components": [],
|
|
4527
|
+
"instead": "A fixed composer stack that ships every pass to every device.",
|
|
4528
|
+
"watchFor": [
|
|
4529
|
+
"canvas-prop-override",
|
|
4530
|
+
"webgl-context-loss"
|
|
4531
|
+
],
|
|
4532
|
+
"notWhen": "The effect is achievable in CSS over the canvas — a vignette, a colour grade, a blur on a static overlay. A CSS filter on a sibling element costs no render passes at all."
|
|
4533
|
+
},
|
|
4534
|
+
{
|
|
4535
|
+
"id": "webgl-hero",
|
|
4536
|
+
"when": [
|
|
4537
|
+
"shader hero",
|
|
4538
|
+
"webgl hero",
|
|
4539
|
+
"3d hero",
|
|
4540
|
+
"hero background"
|
|
4541
|
+
],
|
|
4542
|
+
"allOf": [
|
|
4543
|
+
[
|
|
4544
|
+
"hero",
|
|
4545
|
+
"landing",
|
|
4546
|
+
"above the fold",
|
|
4547
|
+
"banner",
|
|
4548
|
+
"masthead",
|
|
4549
|
+
"splash"
|
|
4550
|
+
],
|
|
4551
|
+
[
|
|
4552
|
+
"shader",
|
|
4553
|
+
"glsl",
|
|
4554
|
+
"webgl",
|
|
4555
|
+
"3d",
|
|
4556
|
+
"three.js",
|
|
4557
|
+
"threejs",
|
|
4558
|
+
"canvas",
|
|
4559
|
+
"video loop",
|
|
4560
|
+
"particle"
|
|
4561
|
+
]
|
|
4562
|
+
],
|
|
4563
|
+
"unless": [],
|
|
4564
|
+
"minHits": 1,
|
|
4565
|
+
"priority": 780,
|
|
4566
|
+
"verdict": "composition",
|
|
4567
|
+
"headline": "Three renditions behind one layout box: shader, video loop, still.",
|
|
4568
|
+
"because": "A hero is the first thing rendered and the worst possible place for an unconditional WebGL mount — it competes with hydration on the slowest devices, and the visitors who suffer most are the ones deciding whether to stay. The rendition ladder gives every device something that looks deliberate. The device floor picks WHICH rendition; frame headroom decides WHEN the expensive one starts; `prefers-reduced-motion` overrides both and gets the still, because a capable machine can still be asking for less motion.",
|
|
4569
|
+
"use": [
|
|
4570
|
+
"useAdaptiveQuality",
|
|
4571
|
+
"useSafeToMount"
|
|
4572
|
+
],
|
|
4573
|
+
"pattern": "adaptive-shader-hero",
|
|
4574
|
+
"components": [
|
|
4575
|
+
"volumetric-light",
|
|
4576
|
+
"media-shader"
|
|
4577
|
+
],
|
|
4578
|
+
"instead": "One shader for everyone, dynamically imported, mounted as soon as the bundle lands.",
|
|
4579
|
+
"watchFor": [
|
|
4580
|
+
"ssr-module-scope",
|
|
4581
|
+
"no-reduced-motion"
|
|
4582
|
+
],
|
|
4583
|
+
"notWhen": "The hero is below the fold on most viewports, in which case it is an ordinary lazy scene and `useSceneGate` is the better gate — it adds viewport proximity, which the hero ladder does not need and a lower section does."
|
|
4584
|
+
},
|
|
4585
|
+
{
|
|
4586
|
+
"id": "lost-context",
|
|
4587
|
+
"when": [
|
|
4588
|
+
"context lost",
|
|
4589
|
+
"webglcontextlost",
|
|
4590
|
+
"canvas goes black",
|
|
4591
|
+
"canvas is black",
|
|
4592
|
+
"context restore"
|
|
4593
|
+
],
|
|
4594
|
+
"allOf": [],
|
|
4595
|
+
"unless": [],
|
|
4596
|
+
"minHits": 1,
|
|
4597
|
+
"priority": 710,
|
|
4598
|
+
"verdict": "single",
|
|
4599
|
+
"headline": "`getRendererHealth()` and a generation on the canvas key. Rebuild, do not revive.",
|
|
4600
|
+
"because": "The browser can take a context away at any moment — a driver reset, a backgrounded tab, too many live contexts on one page — and React Three Fiber has no handler for it, so the result is a permanently black rectangle and a completely clean console. Recovery works by rebuilding rather than restoring: the restore path is unreliable across drivers, and some refuse a replacement context on the page entirely if the loss event was not cancelled. So call `preventDefault()` on the loss, report it, and let a moving key throw the dead tree away and build a working one from the same declarative description.",
|
|
4601
|
+
"use": [
|
|
4602
|
+
"RendererHealth",
|
|
4603
|
+
"useRenderQuality"
|
|
4604
|
+
],
|
|
4605
|
+
"pattern": "lazy-3d-section",
|
|
4606
|
+
"components": [],
|
|
4607
|
+
"instead": "Nothing, which is what React Three Fiber does by default.",
|
|
4608
|
+
"watchFor": [
|
|
4609
|
+
"webgl-context-loss"
|
|
4610
|
+
],
|
|
4611
|
+
"notWhen": "You already spread `useRenderQuality`'s props onto the canvas. Its `onCreated` attaches the listener and reports for you, and a second listener moves the generation twice per loss."
|
|
4612
|
+
}
|
|
2543
4613
|
]
|
|
2544
4614
|
}
|