vectorvesper 2.1.0 → 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.
@@ -30,6 +30,33 @@ async function loadBabel() {
30
30
  var HOT_EVENTS = /* @__PURE__ */ new Set(["scroll", "wheel", "pointermove", "mousemove", "touchmove", "pointerdown"]);
31
31
  var BROWSER_GLOBALS = /* @__PURE__ */ new Set(["window", "document", "navigator", "localStorage", "sessionStorage"]);
32
32
  var OBSERVERS = /* @__PURE__ */ new Set(["IntersectionObserver", "ResizeObserver", "MutationObserver"]);
33
+ var VV_ACCESSORS = /* @__PURE__ */ new Set([
34
+ "getConductor",
35
+ "getSensorBus",
36
+ "getAnimationBudget",
37
+ "getAdaptiveQuality",
38
+ "getFramePressure",
39
+ "getRendererHealth"
40
+ ]);
41
+ var TEARDOWN_RETURNING = /* @__PURE__ */ new Set(["subscribe", "retain"]);
42
+ var LAYOUT_READ_PROPS = /* @__PURE__ */ new Set([
43
+ "offsetWidth",
44
+ "offsetHeight",
45
+ "offsetTop",
46
+ "offsetLeft",
47
+ "clientWidth",
48
+ "clientHeight",
49
+ "clientTop",
50
+ "clientLeft",
51
+ "scrollWidth",
52
+ "scrollHeight",
53
+ "scrollTop",
54
+ "scrollLeft"
55
+ ]);
56
+ var LAYOUT_READ_CALLS = /* @__PURE__ */ new Set(["getBoundingClientRect", "getComputedStyle", "getClientRects"]);
57
+ var DOM_WRITE_PROPS = /* @__PURE__ */ new Set(["textContent", "innerHTML", "innerText", "className", "cssText"]);
58
+ var DOM_WRITE_CALLS = /* @__PURE__ */ new Set(["setAttribute", "setProperty", "removeAttribute"]);
59
+ var R3F_RECONCILED_PROPS = /* @__PURE__ */ new Set(["dpr", "frameloop", "shadows"]);
33
60
  function calleeName(callee) {
34
61
  if (!callee) return void 0;
35
62
  if (callee.type === "Identifier") return callee.name;
@@ -43,14 +70,31 @@ function isStateSetterCall(callee) {
43
70
  const name = callee.name;
44
71
  return /^set[A-Z]/.test(name) && name !== "setInterval" && name !== "setTimeout";
45
72
  }
73
+ function accessorName(callee) {
74
+ const obj = callee?.object;
75
+ if (obj?.type !== "CallExpression" && obj?.type !== "OptionalCallExpression") return void 0;
76
+ return obj.callee?.type === "Identifier" ? obj.callee.name : void 0;
77
+ }
78
+ function asProgram(cbNode) {
79
+ if (!cbNode) return null;
80
+ const isExpression = cbNode.type === "ArrowFunctionExpression" || cbNode.type === "FunctionExpression";
81
+ const isDeclaration = cbNode.type === "FunctionDeclaration";
82
+ if (!isExpression && !isDeclaration) return null;
83
+ const statement = isDeclaration ? cbNode : { type: "ExpressionStatement", expression: cbNode };
84
+ return { type: "File", program: { type: "Program", body: [statement], directives: [] } };
85
+ }
46
86
  function collect(ast, traverse) {
47
87
  const f = {
48
88
  hasUseClient: (ast.program.directives ?? []).some((d) => d.value?.value === "use client"),
49
89
  vvHooks: [],
90
+ vvImports: /* @__PURE__ */ new Set(),
50
91
  importedModules: /* @__PURE__ */ new Set(),
51
92
  usesUseGSAP: false,
52
93
  usesR3F: false,
94
+ isReact: false,
53
95
  raf: [],
96
+ rafSelfScheduling: [],
97
+ rafUnresolved: 0,
54
98
  hasCancelRaf: false,
55
99
  addEventListener: [],
56
100
  hasRemoveEventListener: false,
@@ -66,7 +110,13 @@ function collect(ast, traverse) {
66
110
  setStateInHot: [],
67
111
  transformAuthorities: [],
68
112
  looping: [],
69
- hasReducedMotionGuard: false
113
+ hasReducedMotionGuard: false,
114
+ frameCallbacks: [],
115
+ laneViolations: [],
116
+ orphanSubscriptions: [],
117
+ canvases: [],
118
+ handlesContextLoss: false,
119
+ gatingObserver: []
70
120
  };
71
121
  const manifest = loadHookManifest();
72
122
  const hookByName = /* @__PURE__ */ new Map();
@@ -86,26 +136,82 @@ function collect(ast, traverse) {
86
136
  return null;
87
137
  };
88
138
  const scanHotCallback = (cbNode, ctx, fromRaf) => {
89
- if (!cbNode) return;
90
- const isExpression = cbNode.type === "ArrowFunctionExpression" || cbNode.type === "FunctionExpression";
91
- const isDeclaration = cbNode.type === "FunctionDeclaration";
92
- if (!isExpression && !isDeclaration) return;
93
- const statement = isDeclaration ? cbNode : { type: "ExpressionStatement", expression: cbNode };
94
- traverse(
95
- { type: "File", program: { type: "Program", body: [statement], directives: [] } },
96
- {
97
- CallExpression(p) {
98
- if (isStateSetterCall(p.node.callee)) {
99
- f.setStateInHot.push({ line: cbNode.loc?.start.line ?? 0, ctx, fromRaf });
100
- }
101
- },
102
- OptionalCallExpression(p) {
103
- if (isStateSetterCall(p.node.callee)) {
104
- f.setStateInHot.push({ line: cbNode.loc?.start.line ?? 0, ctx, fromRaf });
105
- }
139
+ const file = asProgram(cbNode);
140
+ if (!file) return;
141
+ traverse(file, {
142
+ CallExpression(p) {
143
+ if (isStateSetterCall(p.node.callee)) {
144
+ f.setStateInHot.push({ line: cbNode.loc?.start.line ?? 0, ctx, fromRaf });
145
+ }
146
+ },
147
+ OptionalCallExpression(p) {
148
+ if (isStateSetterCall(p.node.callee)) {
149
+ f.setStateInHot.push({ line: cbNode.loc?.start.line ?? 0, ctx, fromRaf });
106
150
  }
107
151
  }
108
- );
152
+ });
153
+ };
154
+ const containsStateSetter = (cbNode) => {
155
+ const file = asProgram(cbNode);
156
+ if (!file) return false;
157
+ let found = false;
158
+ const visit = (p) => {
159
+ if (isStateSetterCall(p.node.callee)) found = true;
160
+ };
161
+ traverse(file, { CallExpression: visit, OptionalCallExpression: visit });
162
+ return found;
163
+ };
164
+ const scanLaneDiscipline = (cbNode, lane, via) => {
165
+ const file = asProgram(cbNode);
166
+ if (!file) return;
167
+ const flagsWrites = lane === "input";
168
+ const flagsReads = lane === "render";
169
+ const at = (node) => node?.loc?.start.line ?? cbNode.loc?.start.line ?? 0;
170
+ const onCall = (p) => {
171
+ const name = calleeName(p.node.callee);
172
+ if (!name) return;
173
+ if (flagsReads && LAYOUT_READ_CALLS.has(name)) {
174
+ f.laneViolations.push({ line: at(p.node), lane, via, kind: "read", what: `${name}()` });
175
+ }
176
+ if (flagsWrites && DOM_WRITE_CALLS.has(name)) {
177
+ f.laneViolations.push({ line: at(p.node), lane, via, kind: "write", what: `${name}()` });
178
+ }
179
+ };
180
+ traverse(file, {
181
+ CallExpression: onCall,
182
+ OptionalCallExpression: onCall,
183
+ MemberExpression(p) {
184
+ if (!flagsReads || p.node.computed) return;
185
+ const prop = p.node.property?.name;
186
+ if (!prop || !LAYOUT_READ_PROPS.has(prop)) return;
187
+ const parent = p.parent;
188
+ if (parent?.type === "AssignmentExpression" && parent.left === p.node) return;
189
+ f.laneViolations.push({ line: at(p.node), lane, via, kind: "read", what: `.${prop}` });
190
+ },
191
+ AssignmentExpression(p) {
192
+ if (!flagsWrites) return;
193
+ const left = p.node.left;
194
+ if (left?.type !== "MemberExpression" || left.computed) return;
195
+ const prop = left.property?.name;
196
+ const onStyle = left.object?.type === "MemberExpression" && left.object.property?.name === "style";
197
+ if (!prop) return;
198
+ if (onStyle) {
199
+ f.laneViolations.push({ line: at(p.node), lane, via, kind: "write", what: `style.${prop}` });
200
+ } else if (DOM_WRITE_PROPS.has(prop)) {
201
+ f.laneViolations.push({ line: at(p.node), lane, via, kind: "write", what: `.${prop}` });
202
+ }
203
+ }
204
+ });
205
+ };
206
+ const recordFrameCallback = (p, argPaths, via) => {
207
+ const laneArg = p.node.arguments?.[0];
208
+ const lane = laneArg?.type === "StringLiteral" ? laneArg.value : null;
209
+ const line = p.node.loc?.start.line ?? 0;
210
+ f.frameCallbacks.push({ line, lane, via });
211
+ const cb = resolveFunction(argPaths[1]);
212
+ if (!cb) return;
213
+ scanHotCallback(cb, lane ? `a \`${via}("${lane}")\` frame callback` : `a \`${via}\` frame callback`, false);
214
+ if (lane === "input" || lane === "update" || lane === "render") scanLaneDiscipline(cb, lane, via);
109
215
  };
110
216
  const handleCall = (p) => {
111
217
  const node = p.node;
@@ -113,10 +219,14 @@ function collect(ast, traverse) {
113
219
  const line = node.loc?.start.line ?? 0;
114
220
  const argPaths = typeof p.get === "function" ? p.get("arguments") : [];
115
221
  switch (name) {
116
- case "requestAnimationFrame":
222
+ case "requestAnimationFrame": {
117
223
  f.raf.push(line);
118
- scanHotCallback(resolveFunction(argPaths[0]), "a requestAnimationFrame loop", true);
224
+ const cb = resolveFunction(argPaths[0]);
225
+ if (!cb) f.rafUnresolved++;
226
+ else if (callbackSchedulesAnotherFrame(cb, traverse)) f.rafSelfScheduling.push(line);
227
+ scanHotCallback(cb, "a requestAnimationFrame loop", true);
119
228
  break;
229
+ }
120
230
  case "cancelAnimationFrame":
121
231
  f.hasCancelRaf = true;
122
232
  break;
@@ -150,6 +260,21 @@ function collect(ast, traverse) {
150
260
  case "revert":
151
261
  f.hasGsapCleanup = true;
152
262
  break;
263
+ case "useTick":
264
+ recordFrameCallback(p, argPaths, "useTick");
265
+ break;
266
+ case "subscribe":
267
+ case "retain": {
268
+ const accessor = accessorName(node.callee);
269
+ if (!accessor || !VV_ACCESSORS.has(accessor)) break;
270
+ if (name === "subscribe" && accessor === "getConductor") {
271
+ recordFrameCallback(p, argPaths, "getConductor().subscribe");
272
+ }
273
+ if (TEARDOWN_RETURNING.has(name) && p.parent?.type === "ExpressionStatement") {
274
+ f.orphanSubscriptions.push({ line, call: `${accessor}().${name}()` });
275
+ }
276
+ break;
277
+ }
153
278
  case "timeline":
154
279
  if (f.importedModules.has("gsap") || f.importedModules.has("@gsap/react")) {
155
280
  f.gsapTimeline.push(line);
@@ -171,10 +296,14 @@ function collect(ast, traverse) {
171
296
  f.importedModules.add(src);
172
297
  if (src === "@gsap/react") f.usesUseGSAP = true;
173
298
  if (src === "@react-three/fiber") f.usesR3F = true;
299
+ if (src === "react" || src.startsWith("react/") || src === "react-dom") f.isReact = true;
174
300
  if (src === "@vectorvesper/motion" || src.startsWith("@vectorvesper/motion/")) {
301
+ if (src.endsWith("/react") || src.endsWith("/r3f")) f.isReact = true;
175
302
  for (const spec of p.node.specifiers ?? []) {
176
303
  const name = spec.imported?.name ?? spec.local?.name;
177
- const hook = name && hookByName.get(name);
304
+ if (!name) continue;
305
+ f.vvImports.add(name);
306
+ const hook = hookByName.get(name);
178
307
  if (hook) f.vvHooks.push({ hook, line: p.node.loc?.start.line ?? 0 });
179
308
  }
180
309
  }
@@ -182,6 +311,7 @@ function collect(ast, traverse) {
182
311
  StringLiteral(p) {
183
312
  const v = p.node.value;
184
313
  if (v.includes("prefers-reduced-motion")) f.hasReducedMotionGuard = true;
314
+ if (v === "webglcontextlost" || v === "webglcontextrestored") f.handlesContextLoss = true;
185
315
  if (/\binfinite\b/.test(v)) f.looping.push({ line: p.node.loc?.start.line ?? 0, kind: "CSS `infinite` animation" });
186
316
  if (/\btransition\b|\banimation\b/.test(v) && /\btransform\b|\ball\b/.test(v)) {
187
317
  f.transformAuthorities.push({ line: p.node.loc?.start.line ?? 0, source: "a CSS transition/animation" });
@@ -195,6 +325,25 @@ function collect(ast, traverse) {
195
325
  const infinite = val?.type === "Identifier" && val.name === "Infinity" || val?.type === "NumericLiteral" && val.value === -1 || val?.type === "UnaryExpression" && val.operator === "-" && val.argument?.value === 1;
196
326
  if (infinite) f.looping.push({ line: p.node.loc?.start.line ?? 0, kind: "an infinite `repeat`" });
197
327
  },
328
+ // React Three Fiber re-runs its configure pass on every render of the
329
+ // canvas and resets `dpr`, `frameloop` and `shadows` from props, so what is
330
+ // written here is the last word on all three — whatever anything else set
331
+ // imperatively. Record the shape so the render-quality rules can read it.
332
+ JSXOpeningElement(p) {
333
+ f.isReact = true;
334
+ if (p.node.name?.name !== "Canvas") return;
335
+ const overrides = [];
336
+ let hasSpread = false;
337
+ for (const attr of p.node.attributes ?? []) {
338
+ if (attr.type === "JSXSpreadAttribute") {
339
+ hasSpread = true;
340
+ continue;
341
+ }
342
+ const name = attr.name?.name;
343
+ if (name && R3F_RECONCILED_PROPS.has(name)) overrides.push(name);
344
+ }
345
+ f.canvases.push({ line: p.node.loc?.start.line ?? 0, hasSpread, overrides });
346
+ },
198
347
  // A `transform` in a JSX style object/string means this node's transform is
199
348
  // being animated or set outside any hook.
200
349
  JSXAttribute(p) {
@@ -211,6 +360,9 @@ function collect(ast, traverse) {
211
360
  },
212
361
  Identifier(p) {
213
362
  const name = p.node.name;
363
+ if (/^(reducedMotion|prefersReducedMotion|prefersReduced)$/.test(name)) {
364
+ f.hasReducedMotionGuard = true;
365
+ }
214
366
  if (!BROWSER_GLOBALS.has(name)) return;
215
367
  if (fnDepth !== 0) return;
216
368
  const parent = p.parent;
@@ -222,6 +374,12 @@ function collect(ast, traverse) {
222
374
  NewExpression(p) {
223
375
  const n = p.node.callee?.name;
224
376
  if (n && OBSERVERS.has(n)) f.newObserver.push(p.node.loc?.start.line ?? 0);
377
+ if (n === "IntersectionObserver") {
378
+ const argPaths = typeof p.get === "function" ? p.get("arguments") : [];
379
+ if (containsStateSetter(resolveFunction(argPaths[0]))) {
380
+ f.gatingObserver.push(p.node.loc?.start.line ?? 0);
381
+ }
382
+ }
225
383
  if (n && /(?:Geometry|Material|Texture|RenderTarget)$/.test(n)) {
226
384
  f.threeDisposable.push(p.node.loc?.start.line ?? 0);
227
385
  }
@@ -265,20 +423,42 @@ function checkClientBoundary(f, file, framework) {
265
423
  }
266
424
  return out;
267
425
  }
426
+ function callbackSchedulesAnotherFrame(cbNode, traverse) {
427
+ const isDeclaration = cbNode.type === "FunctionDeclaration";
428
+ const isExpression = cbNode.type === "ArrowFunctionExpression" || cbNode.type === "FunctionExpression";
429
+ if (!isDeclaration && !isExpression) return false;
430
+ const statement = isDeclaration ? cbNode : { type: "ExpressionStatement", expression: cbNode };
431
+ let found = false;
432
+ traverse(
433
+ { type: "File", program: { type: "Program", body: [statement], directives: [] } },
434
+ {
435
+ CallExpression(p) {
436
+ if (calleeName(p.node.callee) === "requestAnimationFrame") found = true;
437
+ },
438
+ OptionalCallExpression(p) {
439
+ if (calleeName(p.node.callee) === "requestAnimationFrame") found = true;
440
+ }
441
+ }
442
+ );
443
+ return found;
444
+ }
268
445
  function isRafLoop(f) {
269
- return f.raf.length >= 2;
446
+ return f.rafSelfScheduling.length > 0 || f.rafUnresolved >= 2;
270
447
  }
271
448
  function checkOrphanRaf(f, file) {
272
449
  if (!isRafLoop(f)) return [];
450
+ const fix = f.isReact ? "Replace the loop with `useTick(lane, fn, { label })` from `@vectorvesper/motion/react`. It subscribes to the shared conductor for the lifetime of the component, unsubscribes on unmount, and joins the surrounding `InteractionScope` so the work yields in the right order under load." : "Drive the animation from the shared conductor \u2014 `getConductor().subscribe(lane, fn, { priority, label })` in `@vectorvesper/motion` \u2014 and call the unsubscribe it returns on teardown, so it shares one frame budget.";
273
451
  return [
274
452
  {
275
453
  rule: "orphan-raf",
276
454
  severity: "warning",
277
455
  file,
278
- line: f.raf[0],
279
- message: `This file runs its own \`requestAnimationFrame\` loop${f.raf.length > 1 ? ` (\xD7${f.raf.length})` : ""}. Independent loops each schedule their own frame and cannot shed work under load \u2014 the coordination the runtime exists to provide is lost.`,
280
- fix: "Drive the animation from the shared conductor (`getConductor().subscribe(...)` in `@vectorvesper/motion`) or a hook that already joins it, so it shares one frame budget.",
281
- nudge: "Call `list_hooks` \u2014 a sensor or governor primitive may already cover this without a hand-written loop."
456
+ // Point at the self-scheduling call when we found one — that is the loop
457
+ // itself, not merely the first rAF in the file.
458
+ line: f.rafSelfScheduling[0] ?? f.raf[0],
459
+ message: `This file runs its own \`requestAnimationFrame\` loop${f.rafSelfScheduling.length > 1 ? ` (\xD7${f.rafSelfScheduling.length})` : ""}. Independent loops each schedule their own frame and cannot shed work under load \u2014 the coordination the runtime exists to provide is lost. The frame-pressure classifier also bills the time to "main-thread", so the page cannot even tell the loop is its own.`,
460
+ fix,
461
+ nudge: 'Call `list_hooks` \u2014 a sensor or governor primitive may already cover this without a hand-written loop. `get_pattern("custom-frame-effect")` is the skeleton when none does.'
282
462
  }
283
463
  ];
284
464
  }
@@ -315,7 +495,106 @@ function checkSetStatePerFrame(f, file) {
315
495
  file,
316
496
  line: first.line,
317
497
  message: `A React state setter runs inside ${first.ctx}. Every call re-renders the component \u2014 at up to 60\u2013120\xD7/second this is the most common cause of animation jank.` + (zeroReRender ? ` \`${zeroReRender.hook.name}\` is designed to avoid exactly this.` : ""),
318
- fix: "Write the value to a `ref` or straight to the DOM node inside the callback; keep it out of React state."
498
+ fix: "Write the value to a `ref` or straight to the DOM node inside the callback; keep it out of React state. Where the render genuinely has to see it, throttle the state write \u2014 a few times a second, not every frame."
499
+ }
500
+ ];
501
+ }
502
+ function checkLaneDiscipline(f, file) {
503
+ if (f.laneViolations.length === 0) return [];
504
+ const seen = /* @__PURE__ */ new Set();
505
+ const out = [];
506
+ for (const v of f.laneViolations) {
507
+ const key = `${v.lane}:${v.kind}`;
508
+ if (seen.has(key)) continue;
509
+ seen.add(key);
510
+ const same = f.laneViolations.filter((o) => o.lane === v.lane && o.kind === v.kind);
511
+ const what = [...new Set(same.map((o) => `\`${o.what}\``))].join(", ");
512
+ const site = `\`${v.via}("${v.lane}")\``;
513
+ out.push(
514
+ v.kind === "read" ? {
515
+ rule: "lane-discipline",
516
+ severity: "warning",
517
+ file,
518
+ line: v.line,
519
+ message: `A ${site} callback reads layout (${what}). \`render\` is the write lane, so by the time this runs the subscribers before it have already written \u2014 the read forces the browser to recalculate layout synchronously, every frame, and the cost grows with everything else on the page.`,
520
+ fix: "Measure in the `input` lane and pass the value through a ref, or measure once on mount and on resize and cache it."
521
+ } : {
522
+ rule: "lane-discipline",
523
+ severity: "warning",
524
+ file,
525
+ line: v.line,
526
+ message: `A ${site} callback writes to the DOM (${what}). \`input\` is the read lane: the runtime's own sensors and gates are measuring in it, and a write here dirties layout underneath all of them, so each subsequent read pays for a fresh recalculation.`,
527
+ fix: "Keep the value on a ref in this callback and do the DOM write from a `render`-lane callback."
528
+ }
529
+ );
530
+ }
531
+ return out;
532
+ }
533
+ function checkOrphanSubscription(f, file) {
534
+ return f.orphanSubscriptions.map((s) => ({
535
+ rule: "orphan-subscription",
536
+ severity: "error",
537
+ file,
538
+ line: s.line,
539
+ message: `\`${s.call}\` returns the teardown that undoes it, and the return value is discarded here. Nothing can stop it afterwards: it survives unmount, every route change adds another, and the frame budget keeps paying for all of them.`,
540
+ fix: `Keep the returned function and call it on teardown \u2014 \`const off = ${s.call.replace(/\(\)$/, "(\u2026)")};\` then \`return () => off();\` from the effect.`,
541
+ nudge: "In React, `useTick` and the other hooks own this lifecycle for you."
542
+ }));
543
+ }
544
+ function checkRenderQualityProps(f, file) {
545
+ if (!f.vvImports.has("useRenderQuality") || f.canvases.length === 0) return [];
546
+ const out = [];
547
+ const overridden = f.canvases.find((c) => c.hasSpread && c.overrides.length > 0);
548
+ if (overridden) {
549
+ out.push({
550
+ rule: "canvas-prop-override",
551
+ severity: "error",
552
+ file,
553
+ line: overridden.line,
554
+ message: `\`<Canvas>\` receives the adapter's props and also sets \`${overridden.overrides.join("`, `")}\` directly. React Three Fiber reconciles all three from props on every render, so the later prop wins and the adapter's decision is discarded \u2014 with no error, no warning and no type complaint.`,
555
+ fix: `Delete the \`${overridden.overrides.join("`, `")}\` prop and let the spread supply it. Quality levels belong in the \`profiles\` argument to \`useRenderQuality\`.`
556
+ });
557
+ }
558
+ if (!f.canvases.some((c) => c.hasSpread)) {
559
+ out.push({
560
+ rule: "canvas-partial-spread",
561
+ severity: "warning",
562
+ file,
563
+ line: f.canvases[0].line,
564
+ message: "`useRenderQuality` is called but its result is not spread onto `<Canvas>`. It returns four things \u2014 the pixel ratio, the frame loop, the shadow setting and an `onCreated` that notices a lost graphics context. Picking fields out of it means picking which of the four jobs the adapter still does.",
565
+ fix: "Spread the whole object: `<Canvas key={scene.generation} {...canvas}>`."
566
+ });
567
+ }
568
+ return out;
569
+ }
570
+ function checkContextLoss(f, file) {
571
+ if (!f.usesR3F || f.canvases.length === 0) return [];
572
+ const handled = f.handlesContextLoss || f.vvImports.has("useRenderQuality") || f.vvImports.has("getRendererHealth");
573
+ if (handled) return [];
574
+ return [
575
+ {
576
+ rule: "webgl-context-loss",
577
+ severity: "warning",
578
+ file,
579
+ line: f.canvases[0].line,
580
+ message: "Nothing in this file notices a lost graphics context. When the browser takes one away the canvas goes black permanently and nothing is logged, so it reads as a rendering bug rather than a recoverable event.",
581
+ fix: "Spread `useRenderQuality`'s props onto the canvas and key it on a scene gate's `generation` \u2014 the `onCreated` it returns attaches the listener and reports the loss, and the moving key rebuilds the tree against a fresh context. Failing that, attach a `webglcontextlost` listener that calls `preventDefault()` (without it some drivers refuse a replacement context outright) and report to `getRendererHealth()`.",
582
+ nudge: 'Call `get_pattern("lazy-3d-section")` for the whole wiring.'
583
+ }
584
+ ];
585
+ }
586
+ function checkHandRolledGate(f, file) {
587
+ if (!f.usesR3F || f.canvases.length === 0) return [];
588
+ if (f.gatingObserver.length === 0 || f.vvImports.has("useSceneGate")) return [];
589
+ return [
590
+ {
591
+ rule: "hand-rolled-scene-gate",
592
+ severity: "info",
593
+ file,
594
+ line: f.gatingObserver[0],
595
+ message: "This gates a WebGL canvas on viewport intersection alone. `useSceneGate` is the same observer plus the parts that are easy to leave out: it waits for scrolling to stop, checks there is frame headroom before mounting, returns a quality verdict for the device, hands back a poster state for hardware that will never run the scene, and carries the generation a lost context is recovered from.",
596
+ fix: "Replace the observer with `useSceneGate({ label, cost })` and render on its `mounted`, keyed on its `generation`.",
597
+ nudge: 'Call `get_hook("useSceneGate")` for the contract, or `get_pattern("lazy-3d-section")` for the full wiring.'
319
598
  }
320
599
  ];
321
600
  }
@@ -369,9 +648,14 @@ async function analyzeSource(file, source, framework) {
369
648
  ...checkClientBoundary(facts, file, framework),
370
649
  ...checkOrphanRaf(facts, file),
371
650
  ...checkMissingCleanup(facts, file),
651
+ ...checkOrphanSubscription(facts, file),
372
652
  ...checkSetStatePerFrame(facts, file),
653
+ ...checkLaneDiscipline(facts, file),
373
654
  ...checkTransformConflict(facts, file),
374
- ...checkReducedMotion(facts, file)
655
+ ...checkReducedMotion(facts, file),
656
+ ...checkRenderQualityProps(facts, file),
657
+ ...checkContextLoss(facts, file),
658
+ ...checkHandRolledGate(facts, file)
375
659
  ].sort((a, b) => a.line - b.line);
376
660
  return { file, findings };
377
661
  } catch (err) {
@@ -399,8 +683,195 @@ async function checkMotionFiles(files, cwd) {
399
683
  return results;
400
684
  }
401
685
 
686
+ // src/mcp/advisor.ts
687
+ var PHRASE_WEIGHT = 4;
688
+ var WORD_WEIGHT = 1;
689
+ var SUFFIX = "(?:e?s|ing|ed|ly)?";
690
+ function contains(haystack, term) {
691
+ const body = term.split(/\s+/).map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + SUFFIX).join("\\s+");
692
+ return new RegExp(`(?:^|[^a-z0-9])${body}(?:[^a-z0-9]|$)`, "i").test(haystack);
693
+ }
694
+ function matchAdvice(request, rules) {
695
+ const text2 = ` ${request.toLowerCase().replace(/\s+/g, " ")} `;
696
+ const out = [];
697
+ for (const rule of rules) {
698
+ if ((rule.unless ?? []).some((term) => contains(text2, term))) continue;
699
+ const matched = [];
700
+ let score = 0;
701
+ const credit = (term) => {
702
+ if (matched.includes(term)) return;
703
+ matched.push(term);
704
+ score += term.includes(" ") ? PHRASE_WEIGHT : WORD_WEIGHT;
705
+ };
706
+ const fromWhen = rule.when.filter((term) => contains(text2, term));
707
+ const groups = rule.allOf ?? [];
708
+ const groupHits = groups.map((group) => group.filter((term) => contains(text2, term)));
709
+ const allGroupsHit = groups.length > 0 && groupHits.every((hits) => hits.length > 0);
710
+ if (fromWhen.length < (rule.minHits ?? 1) && !allGroupsHit) continue;
711
+ for (const term of fromWhen) credit(term);
712
+ if (allGroupsHit) for (const hits of groupHits) for (const term of hits) credit(term);
713
+ if (matched.length === 0) continue;
714
+ out.push({ rule, score: score * 1e3 + rule.priority, matched });
715
+ }
716
+ return out.sort((a, b) => b.score - a.score);
717
+ }
718
+ function checkCombination(names, hooks) {
719
+ const byName = new Map(hooks.map((h) => [h.name.toLowerCase(), h]));
720
+ const notes = [];
721
+ const resolved = [];
722
+ for (const raw of names) {
723
+ const name = raw.trim().replace(/[()]/g, "");
724
+ const hook = byName.get(name.toLowerCase()) ?? hooks.find((h) => h.exports?.some((e) => e.toLowerCase() === name.toLowerCase()));
725
+ if (!hook) {
726
+ notes.push({
727
+ severity: "error",
728
+ text: `\`${name}\` is not in the manifest. Call \`list_hooks\` for what exists \u2014 a primitive that was removed is the most likely reason a name looks right and is not.`
729
+ });
730
+ continue;
731
+ }
732
+ resolved.push(hook);
733
+ }
734
+ const owners = resolved.filter((h) => h.runtime.ownsTransform);
735
+ if (owners.length > 1) {
736
+ notes.push({
737
+ severity: "error",
738
+ text: `${owners.map((h) => `\`${h.name}\``).join(" and ")} each own their element's \`transform\` and write it every frame. On the same element they overwrite each other frame to frame; give each its own node, nesting them if the effects should compose.`
739
+ });
740
+ } else if (owners.length === 1) {
741
+ notes.push({
742
+ severity: "info",
743
+ text: `\`${owners[0].name}\` owns its element's \`transform\`. Anything else animating that element's transform \u2014 a CSS transition, a motion library, a hover scale \u2014 belongs on a wrapper or a child.`
744
+ });
745
+ }
746
+ for (const hook of resolved) {
747
+ for (const conflict of hook.runtime.conflictsWith ?? []) {
748
+ notes.push({ severity: "warning", text: `\`${hook.name}\`: ${conflict}` });
749
+ }
750
+ }
751
+ const clientOnly = resolved.filter((h) => h.runtime.requiresClient);
752
+ if (clientOnly.length > 0) {
753
+ notes.push({
754
+ severity: "info",
755
+ text: `${clientOnly.map((h) => `\`${h.name}\``).join(", ")} ${clientOnly.length === 1 ? "is" : "are"} client-only. In an app-router project the file needs \`"use client"\`; \`check_motion\` reports its absence as an error.`
756
+ });
757
+ }
758
+ const rerendering = resolved.filter((h) => (h.runtime.reRendersPerFrame ?? 0) > 0);
759
+ if (rerendering.length > 0) {
760
+ notes.push({
761
+ severity: "warning",
762
+ text: `${rerendering.map((h) => `\`${h.name}\``).join(", ")} re-render${rerendering.length === 1 ? "s" : ""} during animation. Keep ${rerendering.length === 1 ? "it" : "them"} in a small leaf component so the re-render does not reconcile a subtree that did not change.`
763
+ });
764
+ }
765
+ const shared = /* @__PURE__ */ new Set();
766
+ for (const hook of resolved) for (const s of hook.runtime.sharedSingletons ?? []) shared.add(s);
767
+ if (shared.size > 0 && resolved.length > 1) {
768
+ notes.push({
769
+ severity: "info",
770
+ text: `These share ${[...shared].map((s) => `\`${s}\``).join(", ")}. Sharing is the design, not a collision: one frame loop and one set of sensors for the page however many primitives are mounted, so adding another costs a subscriber rather than another loop.`
771
+ });
772
+ }
773
+ return notes;
774
+ }
775
+ var VERDICT_HEADING = {
776
+ "no-runtime": "You do not need a runtime primitive here",
777
+ single: "One primitive covers this",
778
+ composition: "This is a composition \u2014 the order matters",
779
+ custom: "Nothing covers this directly; build it on the conductor"
780
+ };
781
+ function severityMark(s) {
782
+ return s === "error" ? "\u2717" : s === "warning" ? "\u25B2" : "\xB7";
783
+ }
784
+ function renderRule(rule, heading) {
785
+ const lines = [`## ${heading}`, "", `**${rule.headline}**`, "", rule.because, ""];
786
+ if (rule.verdict === "no-runtime") {
787
+ if (rule.instead) lines.push(`**Do this instead:** ${rule.instead}`, "");
788
+ } else {
789
+ if (rule.use.length > 0) {
790
+ lines.push(
791
+ `**Use, in this order:** ${rule.use.map((u) => `\`${u}\``).join(" \u2192 ")}`,
792
+ ""
793
+ );
794
+ }
795
+ if (rule.pattern) {
796
+ lines.push(
797
+ `**Pattern:** \`${rule.pattern}\` \u2014 call \`get_pattern("${rule.pattern}")\` for the wiring order, a complete example, and the pitfalls. Follow it rather than assembling the hooks yourself; the order is the part that is easy to get wrong and hard to notice.`,
798
+ ""
799
+ );
800
+ }
801
+ if (rule.instead) lines.push(`**This replaces:** ${rule.instead}`, "");
802
+ }
803
+ if (rule.components.length > 0) {
804
+ lines.push(
805
+ `**Already built:** ${rule.components.map((c) => `\`${c}\``).join(", ")} \u2014 \`get_component\` for the contract if one of these is close enough to install.`,
806
+ ""
807
+ );
808
+ }
809
+ lines.push(`**When this is the wrong call:** ${rule.notWhen}`, "");
810
+ return lines;
811
+ }
812
+ function formatPlan({ building, using }) {
813
+ const manifest = loadHookManifest();
814
+ const rules = manifest?.advice ?? [];
815
+ const hooks = manifest?.hooks ?? [];
816
+ if (rules.length === 0) {
817
+ return "No advice table is bundled with this build of the server, so this tool cannot answer.\n\nUse `search` to find candidate primitives and `get_pattern` for compositions, then `check_motion` on whatever you write.";
818
+ }
819
+ const matches = matchAdvice(building, rules);
820
+ const out = [`# Plan \u2014 ${building.trim()}`, ""];
821
+ if (matches.length === 0) {
822
+ out.push(
823
+ "## Nothing in the decision table matched this",
824
+ "",
825
+ "That is worth taking at face value rather than reaching for the nearest primitive. Two things are usually true when it happens: the effect is a transition between states, which is CSS, or it is genuinely novel, which is a custom effect on the shared conductor.",
826
+ "",
827
+ '**If anything about it recomputes every frame**, call `get_pattern("custom-frame-effect")`. That is the skeleton: subscribe to the conductor, read in `input`, compute in `update`, write in `render`, return the unsubscribe. Never start a private `requestAnimationFrame` loop \u2014 it runs outside the frame budget, cannot be shed under load, and is invisible to devtools.',
828
+ "",
829
+ "**If it does not**, it is probably a CSS transition or the Web Animations API, and neither costs main-thread time.",
830
+ "",
831
+ "`search` with a different wording may also find something this table missed.",
832
+ ""
833
+ );
834
+ } else {
835
+ const [top, ...rest] = matches;
836
+ out.push(...renderRule(top.rule, VERDICT_HEADING[top.rule.verdict]));
837
+ const also = rest.filter((m) => m.rule.id !== top.rule.id).slice(0, 3);
838
+ if (also.length > 0) {
839
+ out.push("## Also in what you described", "");
840
+ for (const m of also) {
841
+ const what = m.rule.verdict === "no-runtime" ? m.rule.instead ?? "the platform equivalent" : m.rule.pattern ? `pattern \`${m.rule.pattern}\`` : m.rule.use.map((u) => `\`${u}\``).join(" + ");
842
+ out.push(`- **${m.rule.headline}** \u2192 ${what}`);
843
+ }
844
+ out.push("", `Ask again with just that part of the request for the full reasoning on any of them.`, "");
845
+ }
846
+ const watch = [...new Set(matches.slice(0, 4).flatMap((m) => m.rule.watchFor))];
847
+ if (watch.length > 0) {
848
+ out.push(
849
+ "## After you write it",
850
+ "",
851
+ `Run \`check_motion\` on the file. For this shape the findings to expect are ${watch.map((r) => `\`${r}\``).join(", ")} \u2014 each of those is a mistake that compiles, runs, and shows up later as jank, a leak, or an accessibility complaint.`,
852
+ ""
853
+ );
854
+ }
855
+ }
856
+ if (using && using.length > 0) {
857
+ const notes = checkCombination(using, hooks);
858
+ out.push("## The combination you named", "");
859
+ if (notes.length === 0) {
860
+ out.push("Nothing in these contracts conflicts.", "");
861
+ } else {
862
+ for (const n of notes) out.push(`- ${severityMark(n.severity)} ${n.text}`);
863
+ out.push("");
864
+ }
865
+ }
866
+ out.push(
867
+ "---",
868
+ "_`plan_motion` matches your description against a hand-written decision table. It is deliberate about recommending nothing when the platform already does the job. Call `get_hook` or `get_pattern` for the contract before writing code, and `check_motion` after._"
869
+ );
870
+ return out.join("\n");
871
+ }
872
+
402
873
  // src/mcp/server.ts
403
- var VERSION = true ? "2.1.0" : "0.0.0-dev";
874
+ var VERSION = true ? "2.3.0" : "0.0.0-dev";
404
875
  function text(body) {
405
876
  return { content: [{ type: "text", text: body }] };
406
877
  }
@@ -620,13 +1091,14 @@ ${p.docsUrl}`);
620
1091
  );
621
1092
  return out.join("\n");
622
1093
  }
623
- function severityMark(s) {
1094
+ function severityMark2(s) {
624
1095
  return s === "error" ? "\u2717 error" : s === "warning" ? "\u25B2 warning" : "\xB7 note";
625
1096
  }
626
- var CHECK_SCOPE_NOTE = "_Static analysis \u2014 it catches the common silent motion mistakes (unmanaged loops, missing cleanup, state-per-frame, transform conflicts, missing reduced-motion, SSR-unsafe access), not all of them. An **error** breaks at build or runtime; a **warning** compiles and runs but janks, leaks, or excludes users._";
1097
+ var CHECK_SCOPE_NOTE = "_Static analysis \u2014 it catches the common silent motion mistakes (unmanaged loops, missing cleanup, discarded unsubscribes, state-per-frame, work in the wrong frame lane, transform conflicts, canvas props that overrule the adapter, unhandled graphics-context loss, missing reduced-motion, SSR-unsafe access), not all of them. An **error** breaks at build or runtime; a **warning** compiles and runs but janks, leaks, or excludes users; a **note** is a primitive that would do the job better._";
627
1098
  function formatCheckResults(results) {
628
1099
  let errors = 0;
629
1100
  let warnings = 0;
1101
+ let notes = 0;
630
1102
  let filesWithFindings = 0;
631
1103
  let unreadable = 0;
632
1104
  const blocks = [];
@@ -643,20 +1115,26 @@ function formatCheckResults(results) {
643
1115
  for (const f of r.findings) {
644
1116
  if (f.severity === "error") errors++;
645
1117
  else if (f.severity === "warning") warnings++;
646
- lines.push(`- **${severityMark(f.severity)} \xB7 ${f.rule}** (line ${f.line}) \u2014 ${f.message}`);
1118
+ else notes++;
1119
+ lines.push(`- **${severityMark2(f.severity)} \xB7 ${f.rule}** (line ${f.line}) \u2014 ${f.message}`);
647
1120
  lines.push(` - **Fix:** ${f.fix}`);
648
1121
  if (f.nudge) lines.push(` - ${f.nudge}`);
649
1122
  }
650
1123
  blocks.push(lines.join("\n"));
651
1124
  }
652
- const total = errors + warnings;
1125
+ const total = errors + warnings + notes;
653
1126
  const analyzed = results.length - unreadable;
654
1127
  if (total === 0 && unreadable === 0) {
655
1128
  return `\u2713 No motion issues found in ${results.length} file${results.length === 1 ? "" : "s"}.
656
1129
 
657
1130
  ` + CHECK_SCOPE_NOTE;
658
1131
  }
659
- const headline = total > 0 ? `${total} issue${total === 1 ? "" : "s"} across ${filesWithFindings} file${filesWithFindings === 1 ? "" : "s"} \u2014 ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}.` : `No issues in the ${analyzed} file${analyzed === 1 ? "" : "s"} that could be read.`;
1132
+ const counts = [
1133
+ `${errors} error${errors === 1 ? "" : "s"}`,
1134
+ `${warnings} warning${warnings === 1 ? "" : "s"}`,
1135
+ ...notes > 0 ? [`${notes} note${notes === 1 ? "" : "s"}`] : []
1136
+ ].join(", ");
1137
+ const headline = total > 0 ? `${total} finding${total === 1 ? "" : "s"} across ${filesWithFindings} file${filesWithFindings === 1 ? "" : "s"} \u2014 ${counts}.` : `No issues in the ${analyzed} file${analyzed === 1 ? "" : "s"} that could be read.`;
660
1138
  const unreadableNote = unreadable > 0 ? `
661
1139
  \u26A0\uFE0F ${unreadable} file${unreadable === 1 ? "" : "s"} could not be analyzed \u2014 ${unreadable === 1 ? "it was" : "they were"} NOT checked. Fix the path or the syntax and run again.` : "";
662
1140
  return ["# Motion check", headline + unreadableNote, "", ...blocks, "", CHECK_SCOPE_NOTE].join("\n");
@@ -932,11 +1410,27 @@ Hook discovery still works offline \u2014 try \`list_hooks\`.`
932
1410
  );
933
1411
  }
934
1412
  );
1413
+ server.registerTool(
1414
+ "plan_motion",
1415
+ {
1416
+ title: "Decide whether this needs a motion primitive, and which",
1417
+ description: 'START HERE for any animation, scroll, pointer, video or WebGL work, before `search` and before writing code. Describe what you are building in plain language \u2014 one component or several working together \u2014 and this answers whether it needs a Vector Vesper primitive AT ALL, which ones, in what order, what the combination conflicts with, and when the recommendation would be wrong. It deliberately answers "use CSS, not this runtime" when that is correct, which is often: a hover state, a fade on mount, a spinner and a one-time scroll reveal are all platform features that cost no main-thread time, and wrapping them in a frame loop makes them worse. Pass `using` to have an already-chosen set of primitives checked for transform-ownership collisions and declared conflicts.',
1418
+ inputSchema: {
1419
+ building: z.string().min(3).describe(
1420
+ 'What you are building, in plain language. Include the interaction ("follows the cursor", "scrubs with scroll"), the medium (video, canvas, shader, text) and anything about scale ("a grid of 40 cards", "3000 particles") \u2014 each of those changes the answer.'
1421
+ ),
1422
+ using: z.array(z.string()).optional().describe(
1423
+ "Primitives you already intend to use, by name. Returns a combination check: two owners of one transform, declared conflicts, client-boundary requirements, per-frame re-renders."
1424
+ )
1425
+ }
1426
+ },
1427
+ async ({ building, using }) => text(formatPlan({ building, using }))
1428
+ );
935
1429
  server.registerTool(
936
1430
  "check_motion",
937
1431
  {
938
1432
  title: "Check motion code for silent failures",
939
- description: "Read the file(s) just written or edited and report the motion mistakes that do NOT show up as type or build errors but surface later as jank, leaks, or accessibility problems: a self-scheduling requestAnimationFrame loop running outside the shared frame budget, a listener/observer/timeline never torn down, a setState fired every frame, one element's transform animated by two owners at once, autonomous motion with no prefers-reduced-motion guard, and browser APIs used at module scope (an SSR crash). Call this after generating any animation, scroll, pointer or WebGL code \u2014 it is the check that the contract from `get_hook` was actually followed. Static analysis; reports severity, line, and the fix.",
1433
+ description: "Read the file(s) just written or edited and report the motion mistakes that do NOT show up as type or build errors but surface later as jank, leaks, black canvases, or accessibility problems: a self-scheduling requestAnimationFrame loop running outside the shared frame budget, a listener/observer/timeline never torn down, a runtime subscription whose unsubscribe was discarded, a setState fired every frame, a frame callback reading layout in the write lane, one element's transform animated by two owners at once, a <Canvas> prop that silently overrules the adapter that set it, a WebGL scene with nothing watching for a lost graphics context, autonomous motion with no prefers-reduced-motion guard, and browser APIs used at module scope (an SSR crash). Call this after generating any animation, scroll, pointer or WebGL code \u2014 it is the check that the contract from `get_hook` was actually followed. Static analysis; reports severity, line, and the fix.",
940
1434
  inputSchema: {
941
1435
  files: z.array(z.string()).min(1).describe("Path(s) of the file(s) to check \u2014 relative to the project root, or absolute."),
942
1436
  cwd: z.string().optional().describe("Project root. Defaults to the server's working directory.")
@@ -956,7 +1450,7 @@ async function startMcpServer() {
956
1450
  const server = new McpServerCtor(
957
1451
  { name: "vectorvesper", version: VERSION },
958
1452
  {
959
- instructions: 'Vector Vesper\'s motion runtime and component registry.\n\nBefore writing any animation, scroll, pointer or WebGL code in this project, call `search` or `list_hooks` to check whether a Vector Vesper primitive already covers it, then `get_hook` / `get_component` for the contract. The contract states whether something needs a client boundary, whether it owns an element\'s transform, and what it conflicts with \u2014 details that do not surface as compile errors but do surface as jank.\n\nWhen the task COMBINES primitives (scrollytelling, a predictive media card, an adaptive hero, a marquee) call `list_patterns` / `get_pattern` first: a pattern carries the wiring order, one complete example, the pitfalls with the `check_motion` rule that catches each, and how to verify. When no hook or pattern covers the task, build a custom effect on the runtime: `get_pattern("custom-frame-effect")` is the canonical skeleton, and `get_hook("FrameConductor")`, `get_hook("SensorBus")` and `get_hook("damp")` are the core primitives it rides on. Never start a private requestAnimationFrame loop \u2014 subscribing to the shared conductor is always the answer.\n\nAfter writing animation, scroll, pointer or WebGL code, call `check_motion` on the file(s) you changed. It reports the same failure class the contracts warn about \u2014 an unmanaged frame loop, missing cleanup, state written every frame, a transform animated by two owners, motion with no reduced-motion guard, a browser API at module scope \u2014 checked against what you actually wrote. This is how you confirm the contract was followed; none of it shows up as a type or build error.\n\nHooks are consumed by importing `@vectorvesper/motion`. Never reimplement or inline a hook: a copy runs its own requestAnimationFrame loop instead of joining the shared frame conductor, which removes the coordination the runtime exists to provide, and nothing errors when that happens.\n\nIf asked how a hook works internally, call `get_hook` \u2014 its `How it works inside` section explains the architecture, the frame lane, and the smoothing constants directly. Prefer that over reading `node_modules/@vectorvesper/motion`: it is the same design stated at the level the question is actually about, and it stays correct as the package is bundled and rebundled.'
1453
+ instructions: 'Vector Vesper\'s motion runtime and component registry.\n\nThe workflow is: decide \u2192 read the contract \u2192 write \u2192 check.\n\n**Decide first.** For any animation, scroll, pointer, video or WebGL task, call `plan_motion` with a plain-language description of what you are building, before `search` and before writing code. It answers whether this needs a primitive AT ALL, which ones, in what order, and when that recommendation would be wrong. Take its refusals seriously: a hover state, a fade on mount, a spinner and a one-time scroll reveal are platform features that run on the compositor and cost no main-thread time, and reimplementing any of them on a frame loop makes the page worse. When `plan_motion` says to use CSS, use CSS \u2014 do not then reach for a hook. Pass `using` to have a set of primitives you have already chosen checked for transform-ownership collisions and declared conflicts.\n\n**Then read the contract.** `get_hook` / `get_pattern` / `get_component` for whatever `plan_motion` named. The contract states whether something needs a client boundary, whether it owns an element\'s transform, and what it conflicts with \u2014 details that do not surface as compile errors but do surface as jank. `search` and `list_hooks` remain the way to browse when you want to see everything rather than be told what to use.\n\nWhen the task COMBINES primitives (scrollytelling, a predictive media card, an adaptive hero, a marquee) follow the pattern `plan_motion` names rather than assembling the hooks yourself: a pattern carries the wiring order, one complete example, the pitfalls with the `check_motion` rule that catches each, and how to verify. When nothing covers the task, build a custom effect on the runtime: `get_pattern("custom-frame-effect")` is the canonical skeleton, and `get_hook("FrameConductor")`, `get_hook("SensorBus")` and `get_hook("damp")` are the core primitives it rides on. Never start a private requestAnimationFrame loop \u2014 subscribing to the shared conductor is always the answer.\n\n**Then check.** After writing animation, scroll, pointer or WebGL code, call `check_motion` on the file(s) you changed. It reports the same failure class the contracts warn about \u2014 an unmanaged frame loop, a discarded unsubscribe, state written every frame, layout read in the write lane, a transform animated by two owners, a canvas prop that silently overrules the adapter that set it, a WebGL scene with nothing watching for a lost context, motion with no reduced-motion guard, a browser API at module scope \u2014 checked against what you actually wrote. This is how you confirm the contract was followed; none of it shows up as a type or build error.\n\nHooks are consumed by importing `@vectorvesper/motion`. Never reimplement or inline a hook: a copy runs its own requestAnimationFrame loop instead of joining the shared frame conductor, which removes the coordination the runtime exists to provide, and nothing errors when that happens.\n\nIf asked how a hook works internally, call `get_hook` \u2014 its `How it works inside` section explains the architecture, the frame lane, and the smoothing constants directly. Prefer that over reading `node_modules/@vectorvesper/motion`: it is the same design stated at the level the question is actually about, and it stays correct as the package is bundled and rebundled.'
960
1454
  }
961
1455
  );
962
1456
  await registerTools(server);