vectorvesper 2.1.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,13 +70,28 @@ 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: [],
54
96
  rafSelfScheduling: [],
55
97
  rafUnresolved: 0,
@@ -68,7 +110,13 @@ function collect(ast, traverse) {
68
110
  setStateInHot: [],
69
111
  transformAuthorities: [],
70
112
  looping: [],
71
- hasReducedMotionGuard: false
113
+ hasReducedMotionGuard: false,
114
+ frameCallbacks: [],
115
+ laneViolations: [],
116
+ orphanSubscriptions: [],
117
+ canvases: [],
118
+ handlesContextLoss: false,
119
+ gatingObserver: []
72
120
  };
73
121
  const manifest = loadHookManifest();
74
122
  const hookByName = /* @__PURE__ */ new Map();
@@ -88,26 +136,82 @@ function collect(ast, traverse) {
88
136
  return null;
89
137
  };
90
138
  const scanHotCallback = (cbNode, ctx, fromRaf) => {
91
- if (!cbNode) return;
92
- const isExpression = cbNode.type === "ArrowFunctionExpression" || cbNode.type === "FunctionExpression";
93
- const isDeclaration = cbNode.type === "FunctionDeclaration";
94
- if (!isExpression && !isDeclaration) return;
95
- const statement = isDeclaration ? cbNode : { type: "ExpressionStatement", expression: cbNode };
96
- traverse(
97
- { type: "File", program: { type: "Program", body: [statement], directives: [] } },
98
- {
99
- CallExpression(p) {
100
- if (isStateSetterCall(p.node.callee)) {
101
- f.setStateInHot.push({ line: cbNode.loc?.start.line ?? 0, ctx, fromRaf });
102
- }
103
- },
104
- OptionalCallExpression(p) {
105
- if (isStateSetterCall(p.node.callee)) {
106
- f.setStateInHot.push({ line: cbNode.loc?.start.line ?? 0, ctx, fromRaf });
107
- }
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 });
108
150
  }
109
151
  }
110
- );
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);
111
215
  };
112
216
  const handleCall = (p) => {
113
217
  const node = p.node;
@@ -156,6 +260,21 @@ function collect(ast, traverse) {
156
260
  case "revert":
157
261
  f.hasGsapCleanup = true;
158
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
+ }
159
278
  case "timeline":
160
279
  if (f.importedModules.has("gsap") || f.importedModules.has("@gsap/react")) {
161
280
  f.gsapTimeline.push(line);
@@ -177,10 +296,14 @@ function collect(ast, traverse) {
177
296
  f.importedModules.add(src);
178
297
  if (src === "@gsap/react") f.usesUseGSAP = true;
179
298
  if (src === "@react-three/fiber") f.usesR3F = true;
299
+ if (src === "react" || src.startsWith("react/") || src === "react-dom") f.isReact = true;
180
300
  if (src === "@vectorvesper/motion" || src.startsWith("@vectorvesper/motion/")) {
301
+ if (src.endsWith("/react") || src.endsWith("/r3f")) f.isReact = true;
181
302
  for (const spec of p.node.specifiers ?? []) {
182
303
  const name = spec.imported?.name ?? spec.local?.name;
183
- const hook = name && hookByName.get(name);
304
+ if (!name) continue;
305
+ f.vvImports.add(name);
306
+ const hook = hookByName.get(name);
184
307
  if (hook) f.vvHooks.push({ hook, line: p.node.loc?.start.line ?? 0 });
185
308
  }
186
309
  }
@@ -188,6 +311,7 @@ function collect(ast, traverse) {
188
311
  StringLiteral(p) {
189
312
  const v = p.node.value;
190
313
  if (v.includes("prefers-reduced-motion")) f.hasReducedMotionGuard = true;
314
+ if (v === "webglcontextlost" || v === "webglcontextrestored") f.handlesContextLoss = true;
191
315
  if (/\binfinite\b/.test(v)) f.looping.push({ line: p.node.loc?.start.line ?? 0, kind: "CSS `infinite` animation" });
192
316
  if (/\btransition\b|\banimation\b/.test(v) && /\btransform\b|\ball\b/.test(v)) {
193
317
  f.transformAuthorities.push({ line: p.node.loc?.start.line ?? 0, source: "a CSS transition/animation" });
@@ -201,6 +325,25 @@ function collect(ast, traverse) {
201
325
  const infinite = val?.type === "Identifier" && val.name === "Infinity" || val?.type === "NumericLiteral" && val.value === -1 || val?.type === "UnaryExpression" && val.operator === "-" && val.argument?.value === 1;
202
326
  if (infinite) f.looping.push({ line: p.node.loc?.start.line ?? 0, kind: "an infinite `repeat`" });
203
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
+ },
204
347
  // A `transform` in a JSX style object/string means this node's transform is
205
348
  // being animated or set outside any hook.
206
349
  JSXAttribute(p) {
@@ -231,6 +374,12 @@ function collect(ast, traverse) {
231
374
  NewExpression(p) {
232
375
  const n = p.node.callee?.name;
233
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
+ }
234
383
  if (n && /(?:Geometry|Material|Texture|RenderTarget)$/.test(n)) {
235
384
  f.threeDisposable.push(p.node.loc?.start.line ?? 0);
236
385
  }
@@ -298,6 +447,7 @@ function isRafLoop(f) {
298
447
  }
299
448
  function checkOrphanRaf(f, file) {
300
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.";
301
451
  return [
302
452
  {
303
453
  rule: "orphan-raf",
@@ -306,9 +456,9 @@ function checkOrphanRaf(f, file) {
306
456
  // Point at the self-scheduling call when we found one — that is the loop
307
457
  // itself, not merely the first rAF in the file.
308
458
  line: f.rafSelfScheduling[0] ?? f.raf[0],
309
- 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.`,
310
- 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.",
311
- nudge: "Call `list_hooks` \u2014 a sensor or governor primitive may already cover this without a hand-written loop."
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.'
312
462
  }
313
463
  ];
314
464
  }
@@ -345,7 +495,106 @@ function checkSetStatePerFrame(f, file) {
345
495
  file,
346
496
  line: first.line,
347
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.` : ""),
348
- 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.'
349
598
  }
350
599
  ];
351
600
  }
@@ -399,9 +648,14 @@ async function analyzeSource(file, source, framework) {
399
648
  ...checkClientBoundary(facts, file, framework),
400
649
  ...checkOrphanRaf(facts, file),
401
650
  ...checkMissingCleanup(facts, file),
651
+ ...checkOrphanSubscription(facts, file),
402
652
  ...checkSetStatePerFrame(facts, file),
653
+ ...checkLaneDiscipline(facts, file),
403
654
  ...checkTransformConflict(facts, file),
404
- ...checkReducedMotion(facts, file)
655
+ ...checkReducedMotion(facts, file),
656
+ ...checkRenderQualityProps(facts, file),
657
+ ...checkContextLoss(facts, file),
658
+ ...checkHandRolledGate(facts, file)
405
659
  ].sort((a, b) => a.line - b.line);
406
660
  return { file, findings };
407
661
  } catch (err) {
@@ -429,8 +683,195 @@ async function checkMotionFiles(files, cwd) {
429
683
  return results;
430
684
  }
431
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
+
432
873
  // src/mcp/server.ts
433
- var VERSION = true ? "2.1.1" : "0.0.0-dev";
874
+ var VERSION = true ? "2.4.0" : "0.0.0-dev";
434
875
  function text(body) {
435
876
  return { content: [{ type: "text", text: body }] };
436
877
  }
@@ -650,13 +1091,14 @@ ${p.docsUrl}`);
650
1091
  );
651
1092
  return out.join("\n");
652
1093
  }
653
- function severityMark(s) {
1094
+ function severityMark2(s) {
654
1095
  return s === "error" ? "\u2717 error" : s === "warning" ? "\u25B2 warning" : "\xB7 note";
655
1096
  }
656
- 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._";
657
1098
  function formatCheckResults(results) {
658
1099
  let errors = 0;
659
1100
  let warnings = 0;
1101
+ let notes = 0;
660
1102
  let filesWithFindings = 0;
661
1103
  let unreadable = 0;
662
1104
  const blocks = [];
@@ -673,20 +1115,26 @@ function formatCheckResults(results) {
673
1115
  for (const f of r.findings) {
674
1116
  if (f.severity === "error") errors++;
675
1117
  else if (f.severity === "warning") warnings++;
676
- 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}`);
677
1120
  lines.push(` - **Fix:** ${f.fix}`);
678
1121
  if (f.nudge) lines.push(` - ${f.nudge}`);
679
1122
  }
680
1123
  blocks.push(lines.join("\n"));
681
1124
  }
682
- const total = errors + warnings;
1125
+ const total = errors + warnings + notes;
683
1126
  const analyzed = results.length - unreadable;
684
1127
  if (total === 0 && unreadable === 0) {
685
1128
  return `\u2713 No motion issues found in ${results.length} file${results.length === 1 ? "" : "s"}.
686
1129
 
687
1130
  ` + CHECK_SCOPE_NOTE;
688
1131
  }
689
- 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.`;
690
1138
  const unreadableNote = unreadable > 0 ? `
691
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.` : "";
692
1140
  return ["# Motion check", headline + unreadableNote, "", ...blocks, "", CHECK_SCOPE_NOTE].join("\n");
@@ -962,11 +1410,27 @@ Hook discovery still works offline \u2014 try \`list_hooks\`.`
962
1410
  );
963
1411
  }
964
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
+ );
965
1429
  server.registerTool(
966
1430
  "check_motion",
967
1431
  {
968
1432
  title: "Check motion code for silent failures",
969
- 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.",
970
1434
  inputSchema: {
971
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."),
972
1436
  cwd: z.string().optional().describe("Project root. Defaults to the server's working directory.")
@@ -986,7 +1450,7 @@ async function startMcpServer() {
986
1450
  const server = new McpServerCtor(
987
1451
  { name: "vectorvesper", version: VERSION },
988
1452
  {
989
- 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.'
990
1454
  }
991
1455
  );
992
1456
  await registerTools(server);