vectorvesper 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1123,7 +1123,7 @@ Remove ${slug}
1123
1123
  import fs7 from "fs";
1124
1124
  import path7 from "path";
1125
1125
  import pc8 from "picocolors";
1126
- var CLI_VERSION = true ? "2.6.0" : "0.0.0-dev";
1126
+ var CLI_VERSION = true ? "2.8.0" : "0.0.0-dev";
1127
1127
  async function infoCommand() {
1128
1128
  console.log(pc8.bold(pc8.cyan("\nVector Vesper Diagnostics\n")));
1129
1129
  const projectInfo = detectProject();
@@ -1423,7 +1423,7 @@ async function whoamiCommand() {
1423
1423
  }
1424
1424
 
1425
1425
  // src/index.ts
1426
- var version = true ? "2.6.0" : "0.0.0-dev";
1426
+ var version = true ? "2.8.0" : "0.0.0-dev";
1427
1427
  var program = new Command();
1428
1428
  program.name("vv").description("Vector Vesper CLI \u2014 add visual components to your React project").version(version).option("--verbose", "Show detailed debug output").hook("preAction", (thisCommand) => {
1429
1429
  const opts = thisCommand.opts();
@@ -1465,7 +1465,7 @@ program.command("mcp [action] [client]").description(
1465
1465
  "Run as an MCP server (stdio) for AI coding agents. `vv mcp install` wires it into your editor; `vv mcp status` checks the setup."
1466
1466
  ).option("--stdio", "Force server mode even from an interactive terminal").option("-g, --global", "Write user-level config instead of project-level (install)").action(
1467
1467
  async (action, client, options) => {
1468
- const { mcpCommand } = await import("./mcp-BEM4TVKM.js");
1468
+ const { mcpCommand } = await import("./mcp-LMUWTS3I.js");
1469
1469
  await mcpCommand(action, { ...options, client });
1470
1470
  }
1471
1471
  );
@@ -146,7 +146,7 @@ function detectClients(cwd = process.cwd()) {
146
146
  }
147
147
 
148
148
  // src/commands/mcp.ts
149
- var VERSION = true ? "2.6.0" : "0.0.0-dev";
149
+ var VERSION = true ? "2.8.0" : "0.0.0-dev";
150
150
  var CONFIG_SNIPPET = `{
151
151
  "mcpServers": {
152
152
  "vectorvesper": {
@@ -293,7 +293,7 @@ ${pc.red("\u2717")} Unknown argument "${action}" for \`vv mcp\`.
293
293
  return;
294
294
  }
295
295
  protectStdout();
296
- const { startMcpServer } = await import("./server-CIFO3HSN.js");
296
+ const { startMcpServer } = await import("./server-PYIRS3NJ.js");
297
297
  try {
298
298
  await startMcpServer();
299
299
  } catch (error) {
@@ -146,9 +146,23 @@ function collect(ast, traverse) {
146
146
  laneViolations: [],
147
147
  orphanSubscriptions: [],
148
148
  canvases: [],
149
+ adapterBindings: /* @__PURE__ */ new Set(),
150
+ propSpreads: [],
149
151
  rawGlContexts: [],
150
152
  handlesContextLoss: false,
151
- gatingObserver: []
153
+ gpuDevices: [],
154
+ gpuLostHandlers: [],
155
+ mentionsDestroyedReason: false,
156
+ webgpuClassChecks: [],
157
+ gatingObserver: [],
158
+ threeRenderers: [],
159
+ uncappedPixelRatio: [],
160
+ animationLoops: [],
161
+ hasForceContextLoss: false,
162
+ reportsLost: false,
163
+ reportsHealthy: false,
164
+ rendererRebuilds: [],
165
+ hasIntersectionObserver: false
152
166
  };
153
167
  const reactSetters = collectReactSetters(ast, traverse);
154
168
  const manifest = loadHookManifest();
@@ -194,6 +208,16 @@ function collect(ast, traverse) {
194
208
  }
195
209
  return false;
196
210
  };
211
+ const memberPath = (n) => {
212
+ if (!n || typeof n.type !== "string") return null;
213
+ if (n.type === "Identifier") return n.name;
214
+ if (n.type === "MemberExpression" && !n.computed) {
215
+ const obj = memberPath(n.object);
216
+ const prop = n.property?.type === "Identifier" ? n.property.name : null;
217
+ return obj && prop ? `${obj}.${prop}` : null;
218
+ }
219
+ return null;
220
+ };
197
221
  const isLatched = (setterPath) => {
198
222
  let node = setterPath;
199
223
  while (node) {
@@ -203,6 +227,10 @@ function collect(ast, traverse) {
203
227
  const collect2 = (n) => {
204
228
  if (!n || typeof n.type !== "string") return;
205
229
  if (n.type === "Identifier") guards.add(n.name);
230
+ if (n.type === "MemberExpression") {
231
+ const p = memberPath(n);
232
+ if (p) guards.add(p);
233
+ }
206
234
  for (const key of Object.keys(n)) {
207
235
  const v = n[key];
208
236
  if (Array.isArray(v)) v.forEach(collect2);
@@ -213,7 +241,8 @@ function collect(ast, traverse) {
213
241
  let latched = false;
214
242
  const check = (inner) => {
215
243
  const target = inner.node.left ?? inner.node.argument;
216
- if (target?.type === "Identifier" && guards.has(target.name)) latched = true;
244
+ const name = memberPath(target);
245
+ if (name && guards.has(name)) latched = true;
217
246
  };
218
247
  if (typeof node.traverse === "function") {
219
248
  node.traverse({ AssignmentExpression: check, UpdateExpression: check });
@@ -303,6 +332,25 @@ function collect(ast, traverse) {
303
332
  );
304
333
  if (lane === "input" || lane === "update" || lane === "render") scanLaneDiscipline(cb, lane, via);
305
334
  };
335
+ const isDetachedProbeCanvas = (p, node) => {
336
+ const obj = node.callee?.object;
337
+ if (!obj || obj.type !== "Identifier") return false;
338
+ const binding = typeof p.scope?.getBinding === "function" ? p.scope.getBinding(obj.name) : null;
339
+ if (!binding) return false;
340
+ const decl = binding.path?.node;
341
+ const init = decl?.type === "VariableDeclarator" ? decl.init : null;
342
+ if (!init || init.type !== "CallExpression") return false;
343
+ if (calleeName(init.callee) !== "createElement") return false;
344
+ const tag = init.arguments?.[0];
345
+ if (tag?.type !== "StringLiteral" || tag.value !== "canvas") return false;
346
+ const INERT = /* @__PURE__ */ new Set(["getContext", "width", "height"]);
347
+ return (binding.referencePaths ?? []).every((ref) => {
348
+ const parent = ref.parentPath?.node;
349
+ if (!parent) return false;
350
+ const isMember = parent.type === "MemberExpression" || parent.type === "OptionalMemberExpression";
351
+ return isMember && parent.object === ref.node && INERT.has(parent.property?.name);
352
+ });
353
+ };
306
354
  const handleCall = (p) => {
307
355
  const node = p.node;
308
356
  const name = calleeName(node.callee);
@@ -320,6 +368,68 @@ function collect(ast, traverse) {
320
368
  case "cancelAnimationFrame":
321
369
  f.hasCancelRaf = true;
322
370
  break;
371
+ // Telling RendererHealth about a context change IS handling it, and it is
372
+ // the half `webglcontextlost`-string detection cannot see.
373
+ //
374
+ // This is keyed on the call rather than on an import name on purpose.
375
+ // Projects routinely re-export the runtime through their own barrel — our
376
+ // own docs site imports `getRendererHealth` from `@/motion`, which is a
377
+ // file that does `export * from "@vectorvesper/motion/react"`. Every
378
+ // check that asks "is this name in `vvImports`" is blind to that, so the
379
+ // scene-gate page reported having no context-loss handling three lines
380
+ // above the `reportLost()` that handles it. Found 2026-09-09.
381
+ //
382
+ // Deliberately loose: only `reportLost`/`reportHealthy`, not
383
+ // `getRendererHealth`, because reading health for a readout is not
384
+ // handling. Being loose here can only suppress a finding, and a missed
385
+ // warning is the failure this file prefers over a false one.
386
+ case "reportLost":
387
+ f.handlesContextLoss = true;
388
+ f.reportsLost = true;
389
+ break;
390
+ case "reportHealthy":
391
+ f.handlesContextLoss = true;
392
+ f.reportsHealthy = true;
393
+ break;
394
+ case "setPixelRatio": {
395
+ const arg = node.arguments?.[0];
396
+ const bare = arg?.type === "Identifier" && arg.name === "devicePixelRatio" || arg?.type === "MemberExpression" && !arg.computed && arg.property?.name === "devicePixelRatio" && arg.object?.type === "Identifier" && ["window", "globalThis", "self"].includes(arg.object.name);
397
+ if (bare) f.uncappedPixelRatio.push(line);
398
+ break;
399
+ }
400
+ case "setAnimationLoop":
401
+ if (node.arguments?.[0] && node.arguments[0].type !== "NullLiteral") f.animationLoops.push(line);
402
+ break;
403
+ case "forceContextLoss":
404
+ f.hasForceContextLoss = true;
405
+ break;
406
+ case "useEffect":
407
+ case "useLayoutEffect": {
408
+ const deps = node.arguments?.[1];
409
+ if (deps?.type !== "ArrayExpression" || deps.elements.length === 0) break;
410
+ const cb = resolveFunction(argPaths[0]);
411
+ if (!cb || !callbackBuildsRenderer(cb, traverse)) break;
412
+ const moving = deps.elements.map((el) => depName(el)).filter((dep) => dep !== null && !isStableDep(dep));
413
+ if (moving.length > 0) f.rendererRebuilds.push({ line: deps.loc?.start.line ?? line, deps: moving });
414
+ break;
415
+ }
416
+ case "useRenderQuality": {
417
+ const parent = p.parent;
418
+ if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") {
419
+ f.adapterBindings.add(parent.id.name);
420
+ }
421
+ break;
422
+ }
423
+ case "requestDevice":
424
+ f.gpuDevices.push(line);
425
+ break;
426
+ case "then": {
427
+ const obj = node.callee?.object;
428
+ if ((obj?.property?.name ?? obj?.property?.value) === "lost") {
429
+ f.gpuLostHandlers.push(line);
430
+ }
431
+ break;
432
+ }
323
433
  case "setInterval":
324
434
  f.setInterval.push(line);
325
435
  break;
@@ -350,9 +460,9 @@ function collect(ast, traverse) {
350
460
  break;
351
461
  case "getContext": {
352
462
  const kind = node.arguments?.[0];
353
- if (kind?.type === "StringLiteral" && /^(webgl2?|experimental-webgl)$/.test(kind.value)) {
354
- f.rawGlContexts.push(line);
355
- }
463
+ if (kind?.type !== "StringLiteral" || isDetachedProbeCanvas(p, node)) break;
464
+ if (/^(webgl2?|experimental-webgl)$/.test(kind.value)) f.rawGlContexts.push(line);
465
+ if (kind.value === "webgpu") f.gpuDevices.push(line);
356
466
  break;
357
467
  }
358
468
  case "kill":
@@ -411,6 +521,7 @@ function collect(ast, traverse) {
411
521
  const v = p.node.value;
412
522
  if (v.includes("prefers-reduced-motion")) f.hasReducedMotionGuard = true;
413
523
  if (v === "webglcontextlost" || v === "webglcontextrestored") f.handlesContextLoss = true;
524
+ if (v === "destroyed") f.mentionsDestroyedReason = true;
414
525
  if (/\binfinite\b/.test(v) && /\b\d+(?:\.\d+)?m?s\b/.test(v)) {
415
526
  f.looping.push({ line: p.node.loc?.start.line ?? 0, kind: "CSS `infinite` animation" });
416
527
  }
@@ -432,7 +543,17 @@ function collect(ast, traverse) {
432
543
  // imperatively. Record the shape so the render-quality rules can read it.
433
544
  JSXOpeningElement(p) {
434
545
  f.isReact = true;
435
- if (p.node.name?.name !== "Canvas") return;
546
+ const element = p.node.name?.name ?? "";
547
+ for (const attr of p.node.attributes ?? []) {
548
+ if (attr.type !== "JSXSpreadAttribute") continue;
549
+ if (attr.argument?.type !== "Identifier") continue;
550
+ f.propSpreads.push({
551
+ line: p.node.loc?.start.line ?? 0,
552
+ element,
553
+ name: attr.argument.name
554
+ });
555
+ }
556
+ if (element !== "Canvas") return;
436
557
  const overrides = [];
437
558
  let hasSpread = false;
438
559
  for (const attr of p.node.attributes ?? []) {
@@ -473,9 +594,11 @@ function collect(ast, traverse) {
473
594
  f.moduleScopeBrowserApi.push({ line: p.node.loc?.start.line ?? 0, api: name });
474
595
  },
475
596
  NewExpression(p) {
476
- const n = p.node.callee?.name;
597
+ const n = constructorName(p.node.callee);
598
+ if (n === "WebGLRenderer") f.threeRenderers.push(p.node.loc?.start.line ?? 0);
477
599
  if (n && OBSERVERS.has(n)) f.newObserver.push(p.node.loc?.start.line ?? 0);
478
600
  if (n === "IntersectionObserver") {
601
+ f.hasIntersectionObserver = true;
479
602
  const argPaths = typeof p.get === "function" ? p.get("arguments") : [];
480
603
  if (containsStateSetter(resolveFunction(argPaths[0]))) {
481
604
  f.gatingObserver.push(p.node.loc?.start.line ?? 0);
@@ -484,6 +607,22 @@ function collect(ast, traverse) {
484
607
  if (n && /(?:Geometry|Material|Texture|RenderTarget)$/.test(n)) {
485
608
  f.threeDisposable.push(p.node.loc?.start.line ?? 0);
486
609
  }
610
+ if (n === "WebGPURenderer") f.gpuDevices.push(p.node.loc?.start.line ?? 0);
611
+ },
612
+ /**
613
+ * `x instanceof WebGPURenderer`, which is the wrong question.
614
+ *
615
+ * three's `Renderer.init` catches a WebGPU failure and swaps in a WebGL
616
+ * backend, so the class says WebGPU while the canvas is WebGL. Branching on
617
+ * it to decide how to notice a lost context leaves the fallback with no
618
+ * handling at all, silently.
619
+ */
620
+ BinaryExpression(p) {
621
+ if (p.node.operator !== "instanceof") return;
622
+ const right = p.node.right;
623
+ if (right?.type === "Identifier" && /^WebGPURenderer$/.test(right.name)) {
624
+ f.webgpuClassChecks.push(p.node.loc?.start.line ?? 0);
625
+ }
487
626
  },
488
627
  CallExpression(p) {
489
628
  handleCall(p);
@@ -580,7 +719,7 @@ function checkMissingCleanup(f, file) {
580
719
  if (f.newObserver.length && !f.hasObserverDisconnect) push(f.newObserver[0], "An observer", "`.disconnect()`");
581
720
  if (f.gsapTimeline.length && !f.usesUseGSAP && !f.hasGsapCleanup)
582
721
  push(f.gsapTimeline[0], "A GSAP timeline", "`.kill()` (or drive it from `useGSAP`, which reverts automatically)");
583
- if (f.threeDisposable.length && !f.usesR3F && !f.hasThreeDispose)
722
+ if (f.threeDisposable.length && !f.usesR3F && !f.hasThreeDispose && !f.vvImports.has("useThreeScene"))
584
723
  push(f.threeDisposable[0], "A three.js geometry/material/texture", "`.dispose()`");
585
724
  return out;
586
725
  }
@@ -671,21 +810,105 @@ function checkRenderQualityProps(f, file) {
671
810
  function checkContextLoss(f, file) {
672
811
  const r3fCanvas = f.usesR3F && f.canvases.length > 0;
673
812
  const rawContext = f.rawGlContexts.length > 0;
674
- if (!r3fCanvas && !rawContext) return [];
675
- const handled = f.handlesContextLoss || f.vvImports.has("useRenderQuality") || f.vvImports.has("getRendererHealth");
813
+ const handRenderer = !f.usesR3F && f.threeRenderers.length > 0;
814
+ if (!r3fCanvas && !rawContext && !handRenderer) return [];
815
+ const adapter = ["useRenderQuality", "useThreeScene", "watchGPUDevice"].some((n) => f.vvImports.has(n));
816
+ if (adapter) return [];
817
+ const line = r3fCanvas ? f.canvases[0].line : f.rawGlContexts[0] ?? f.threeRenderers[0];
818
+ if (f.reportsLost && !f.reportsHealthy) {
819
+ return [
820
+ {
821
+ rule: "webgl-context-loss",
822
+ severity: "warning",
823
+ file,
824
+ line,
825
+ message: "This file reports a lost context and never reports that the replacement is up. Until `reportHealthy()` is called, every scene gate on the page holds its scene in recovery at reduced quality.",
826
+ fix: "Call `getRendererHealth().reportHealthy()` once the replacement renderer has drawn its first frame. Pass `builtAt` to `reportLost` as well \u2014 the generation that was current when the renderer was made \u2014 so a replacement lost moments after it was built still gets a rebuild of its own.",
827
+ ...handRenderer ? { nudge: 'Call `get_pattern("three-scene")`: `useThreeScene` wires all of it.' } : {}
828
+ }
829
+ ];
830
+ }
831
+ const handled = f.handlesContextLoss || f.vvImports.has("getRendererHealth");
676
832
  if (handled) return [];
833
+ const threeOnly = handRenderer && !r3fCanvas && !rawContext;
677
834
  return [
678
835
  {
679
836
  rule: "webgl-context-loss",
680
837
  severity: "warning",
681
838
  file,
682
- line: r3fCanvas ? f.canvases[0].line : f.rawGlContexts[0],
839
+ line,
683
840
  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.",
684
- fix: (r3fCanvas ? "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 " : "You created this context by hand, so the adapter does not apply. Attach a ") + "`webglcontextlost` listener that calls `preventDefault()` (without it some drivers refuse a replacement context outright) and report to `getRendererHealth()`.",
841
+ fix: (r3fCanvas ? "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 " : threeOnly ? "For a three.js scene, `useThreeScene` from `@vectorvesper/motion/three` listens from the moment the renderer exists and rebuilds the scene on a fresh one. By hand, attach a " : "You created this context by hand, so the adapter does not apply. Attach a ") + "`webglcontextlost` listener that calls `preventDefault()` (without it some drivers refuse a replacement context outright) and report to `getRendererHealth()`.",
842
+ nudge: threeOnly ? 'Call `get_pattern("three-scene")` for the whole file.' : 'Call `get_pattern("lazy-3d-section")` for the whole wiring.'
843
+ }
844
+ ];
845
+ }
846
+ function checkAdapterPropsToWrapper(f, file) {
847
+ if (f.adapterBindings.size === 0) return [];
848
+ const strays = f.propSpreads.filter(
849
+ (s) => f.adapterBindings.has(s.name) && s.element !== "Canvas"
850
+ );
851
+ if (strays.length === 0) return [];
852
+ const first = strays[0];
853
+ const reachesCanvas = f.propSpreads.some(
854
+ (s) => f.adapterBindings.has(s.name) && s.element === "Canvas"
855
+ );
856
+ return [
857
+ {
858
+ rule: "adapter-props-to-wrapper",
859
+ severity: "info",
860
+ file,
861
+ line: first.line,
862
+ message: `\`useRenderQuality\`'s props are spread onto \`<${first.element}>\`` + (reachesCanvas ? " as well as onto `<Canvas>`" : ", and never onto a `<Canvas>` in this file") + ". If that component builds its own canvas props and forwards the rest elsewhere, all four of the adapter's jobs stop happening: the pixel ratio, the frame loop, the shadow setting, and the `onCreated` that notices a lost graphics context. The spread stays type-valid either way, because the extra props are absorbed by the component's rest parameter.",
863
+ fix: `Open \`${first.element}\` and confirm it spreads what it receives onto its \`<Canvas>\`, \`onCreated\` included. If it does not, spread the adapter's props onto the \`<Canvas>\` directly instead of onto the wrapper, or have the wrapper accept and forward them explicitly.`,
864
+ nudge: "A rest spread is not evidence the props arrived. Check the canvas, not the call site."
865
+ }
866
+ ];
867
+ }
868
+ function checkGpuDeviceLoss(f, file) {
869
+ if (f.gpuDevices.length === 0) return [];
870
+ const handled = f.gpuLostHandlers.length > 0 || f.vvImports.has("watchGPUDevice") || f.vvImports.has("useRenderQuality") || f.vvImports.has("useThreeScene");
871
+ if (handled) return [];
872
+ return [
873
+ {
874
+ rule: "webgpu-device-loss",
875
+ severity: "warning",
876
+ file,
877
+ line: f.gpuDevices[0],
878
+ message: "This file takes a WebGPU device and nothing watches `device.lost`. A WebGPU canvas never fires `webglcontextlost`, so a listener written for WebGL will not hear this one. When the device goes the scene stops drawing with nothing logged.",
879
+ fix: "Pass the device to `watchGPUDevice(device)` from `@vectorvesper/motion`, and key the scene on a scene gate's `generation` so the loss rebuilds it against a device you request fresh. Through React Three Fiber, spreading `useRenderQuality`'s props does both.",
685
880
  nudge: 'Call `get_pattern("lazy-3d-section")` for the whole wiring.'
686
881
  }
687
882
  ];
688
883
  }
884
+ function checkGpuDestroyReason(f, file) {
885
+ if (f.gpuLostHandlers.length === 0) return [];
886
+ if (f.mentionsDestroyedReason) return [];
887
+ if (f.vvImports.has("watchGPUDevice")) return [];
888
+ return [
889
+ {
890
+ rule: "webgpu-destroy-not-a-loss",
891
+ severity: "warning",
892
+ file,
893
+ line: f.gpuLostHandlers[0],
894
+ message: 'This handler treats every `device.lost` resolution as a failure. It also resolves when your own code calls `device.destroy()`, where `reason` is `"destroyed"` \u2014 so unmounting a scene reports a loss, which rebuilds the page, which unmounts more scenes.',
895
+ fix: 'Return early when `info?.reason === "destroyed"`, or use `watchGPUDevice(device)`, which does it and coalesces simultaneous losses into one rebuild.'
896
+ }
897
+ ];
898
+ }
899
+ function checkWebgpuClassCheck(f, file) {
900
+ if (f.webgpuClassChecks.length === 0) return [];
901
+ return [
902
+ {
903
+ rule: "webgpu-renderer-class-check",
904
+ severity: "info",
905
+ file,
906
+ line: f.webgpuClassChecks[0],
907
+ message: "`instanceof WebGPURenderer` does not tell you the renderer is drawing with WebGPU. three catches a failed WebGPU init and swaps in a WebGL backend, keeping the same object, so this branch can send a WebGL canvas down the WebGPU path.",
908
+ fix: "Ask the backend rather than the class: `renderer.backend?.isWebGPUBackend` with the device checked for a `lost` promise. Better still, wire both paths \u2014 the `webglcontextlost` listener is inert on a real WebGPU canvas and is exactly what saves the fallback case."
909
+ }
910
+ ];
911
+ }
689
912
  function checkHandRolledGate(f, file) {
690
913
  const ownsGpu = f.usesR3F && f.canvases.length > 0 || f.rawGlContexts.length > 0;
691
914
  if (!ownsGpu) return [];
@@ -717,10 +940,118 @@ function checkTransformConflict(f, file) {
717
940
  }
718
941
  ];
719
942
  }
943
+ function constructorName(callee) {
944
+ if (!callee) return void 0;
945
+ if (callee.type === "Identifier") return callee.name;
946
+ if ((callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && !callee.computed) {
947
+ return callee.property?.name;
948
+ }
949
+ return void 0;
950
+ }
951
+ var RENDERER_CLASSES = /* @__PURE__ */ new Set(["WebGLRenderer", "WebGPURenderer"]);
952
+ function callbackBuildsRenderer(cbNode, traverse) {
953
+ const isDeclaration = cbNode.type === "FunctionDeclaration";
954
+ const isExpression = cbNode.type === "ArrowFunctionExpression" || cbNode.type === "FunctionExpression";
955
+ if (!isDeclaration && !isExpression) return false;
956
+ const statement = isDeclaration ? cbNode : { type: "ExpressionStatement", expression: cbNode };
957
+ let found = false;
958
+ traverse(
959
+ { type: "File", program: { type: "Program", body: [statement], directives: [] } },
960
+ {
961
+ NewExpression(p) {
962
+ if (RENDERER_CLASSES.has(constructorName(p.node.callee) ?? "")) found = true;
963
+ }
964
+ }
965
+ );
966
+ return found;
967
+ }
968
+ function depName(node) {
969
+ if (!node) return null;
970
+ if (node.type === "Identifier") return node.name;
971
+ if ((node.type === "MemberExpression" || node.type === "OptionalMemberExpression") && !node.computed) {
972
+ const object = depName(node.object);
973
+ return object ? `${object}.${node.property?.name}` : null;
974
+ }
975
+ return null;
976
+ }
977
+ function isStableDep(name) {
978
+ return /(?:Ref|\.current)$/.test(name) || /generation/i.test(name) || /^(gen|key)$/.test(name) || /^set[A-Z]/.test(name);
979
+ }
980
+ function handDrawnSceneLoop(f) {
981
+ const ownsGpu = !f.usesR3F && (f.threeRenderers.length > 0 || f.rawGlContexts.length > 0);
982
+ if (!ownsGpu) return null;
983
+ if (!isRafLoop(f) && f.animationLoops.length === 0) return null;
984
+ return f.rafSelfScheduling[0] ?? f.animationLoops[0] ?? f.raf[0] ?? null;
985
+ }
986
+ function gatedByRuntime(f) {
987
+ return ["useSceneGate", "useThreeScene", "SceneState", "useRenderQuality"].some(
988
+ (n) => f.vvImports.has(n)
989
+ );
990
+ }
991
+ function checkOffscreenRender(f, file) {
992
+ const line = handDrawnSceneLoop(f);
993
+ if (line === null || gatedByRuntime(f) || f.hasIntersectionObserver) return [];
994
+ return [
995
+ {
996
+ rule: "offscreen-render",
997
+ severity: "warning",
998
+ file,
999
+ line,
1000
+ message: "This scene draws every frame from its own loop, and nothing in the file checks whether it is on screen. Scrolled away, it keeps rendering at full rate for nobody, on the GPU the rest of the page needs.",
1001
+ fix: "For a three.js scene, `useThreeScene` from `@vectorvesper/motion/three` draws only while the scene is on screen and keeps its context while it is away. For anything else, gate it with `useSceneGate` and stop drawing while its state is `idle`.",
1002
+ nudge: 'Call `get_pattern("three-scene")` for the whole file.'
1003
+ }
1004
+ ];
1005
+ }
1006
+ function checkUncappedPixelRatio(f, file) {
1007
+ if (f.uncappedPixelRatio.length === 0) return [];
1008
+ return [
1009
+ {
1010
+ rule: "uncapped-pixel-ratio",
1011
+ severity: "warning",
1012
+ file,
1013
+ line: f.uncappedPixelRatio[0],
1014
+ message: "The renderer draws at the screen's full pixel ratio with no ceiling. A 3x phone shades nine times the pixels of a 1x screen for a difference nobody can see, and the scene never gives any of it back when the frame rate drops.",
1015
+ fix: "Cap it, `Math.min(window.devicePixelRatio, 2)`, and lower the cap when the frame rate cannot hold. `useThreeScene` from `@vectorvesper/motion/three` does both, on the renderer it already has."
1016
+ }
1017
+ ];
1018
+ }
1019
+ function checkUnreleasedContext(f, file) {
1020
+ if (f.threeRenderers.length === 0 || f.usesR3F || f.hasForceContextLoss) return [];
1021
+ if (f.vvImports.has("useThreeScene")) return [];
1022
+ return [
1023
+ {
1024
+ rule: "unreleased-context",
1025
+ severity: "warning",
1026
+ file,
1027
+ line: f.threeRenderers[0],
1028
+ message: "This renderer's WebGL context is never handed back. `dispose()` frees three's buffers and textures but not the context, which lives until the garbage collector happens to reach the canvas. Browsers cap live contexts per page and drop the oldest first, which on a landing page is usually the hero: vv-lab counted 9 left behind after six trips to another page and back.",
1029
+ fix: "In the cleanup, remove your `webglcontextlost` listener, then call `renderer.dispose()` and `renderer.forceContextLoss()`. The listener has to come off first, or your own teardown reads as a GPU failure and rebuilds every other scene on the page. `useThreeScene` does all three."
1030
+ }
1031
+ ];
1032
+ }
1033
+ function checkRendererRebuild(f, file) {
1034
+ if (f.rendererRebuilds.length === 0) return [];
1035
+ const first = f.rendererRebuilds[0];
1036
+ const names = first.deps.map((d) => `\`${d}\``).join(", ");
1037
+ return [
1038
+ {
1039
+ rule: "renderer-rebuilt-on-change",
1040
+ severity: "warning",
1041
+ file,
1042
+ line: first.line,
1043
+ message: `This effect builds a renderer and re-runs whenever ${names} changes, so every change tears the scene down and makes a new WebGL context. Under a quality signal that moves, vv-lab watched one page go from 3 contexts to 7 in twenty seconds.`,
1044
+ fix: "Build the renderer once, in an effect whose dependencies do not change, and apply the change to the renderer you have \u2014 `setPixelRatio`, a draw range, a uniform \u2014 reading the latest value from a ref inside the frame. `useThreeScene` does this and hands the live quality to `update`."
1045
+ }
1046
+ ];
1047
+ }
720
1048
  function checkReducedMotion(f, file) {
721
- if (f.looping.length === 0 || f.hasReducedMotionGuard) return [];
1049
+ if (f.hasReducedMotionGuard) return [];
722
1050
  if (f.vvHooks.some((v) => v.hook.runtime.respectsReducedMotion)) return [];
723
- const first = f.looping[0];
1051
+ const sceneLoop = gatedByRuntime(f) ? null : handDrawnSceneLoop(f);
1052
+ const first = f.looping[0] ?? (sceneLoop !== null ? { line: sceneLoop, kind: "a WebGL scene drawn every frame" } : null);
1053
+ if (!first) return [];
1054
+ const sceneOnly = f.looping.length === 0;
724
1055
  return [
725
1056
  {
726
1057
  rule: "no-reduced-motion",
@@ -728,7 +1059,7 @@ function checkReducedMotion(f, file) {
728
1059
  file,
729
1060
  line: first.line,
730
1061
  message: `This file runs autonomous motion (${first.kind}) with no \`prefers-reduced-motion\` guard. Users who set that preference \u2014 often for vestibular reasons \u2014 will still see the full motion.`,
731
- fix: "Gate the motion behind `window.matchMedia('(prefers-reduced-motion: reduce)')`, or drive it from a hook that self-disables."
1062
+ fix: sceneOnly ? "For a three.js scene, `useThreeScene` from `@vectorvesper/motion/three` never builds it under reduced motion, and you show a still instead. Otherwise read `window.matchMedia('(prefers-reduced-motion: reduce)')` and draw one frame rather than a loop." : "Gate the motion behind `window.matchMedia('(prefers-reduced-motion: reduce)')`, or drive it from a hook that self-disables."
732
1063
  }
733
1064
  ];
734
1065
  }
@@ -758,8 +1089,16 @@ async function analyzeSource(file, source, framework) {
758
1089
  ...checkTransformConflict(facts, file),
759
1090
  ...checkReducedMotion(facts, file),
760
1091
  ...checkRenderQualityProps(facts, file),
1092
+ ...checkAdapterPropsToWrapper(facts, file),
761
1093
  ...checkContextLoss(facts, file),
762
- ...checkHandRolledGate(facts, file)
1094
+ ...checkGpuDeviceLoss(facts, file),
1095
+ ...checkGpuDestroyReason(facts, file),
1096
+ ...checkWebgpuClassCheck(facts, file),
1097
+ ...checkHandRolledGate(facts, file),
1098
+ ...checkOffscreenRender(facts, file),
1099
+ ...checkUncappedPixelRatio(facts, file),
1100
+ ...checkUnreleasedContext(facts, file),
1101
+ ...checkRendererRebuild(facts, file)
763
1102
  ].sort((a, b) => a.line - b.line);
764
1103
  return { file, findings };
765
1104
  } catch (err) {
@@ -938,6 +1277,19 @@ function formatPlan({ building, using }) {
938
1277
  } else {
939
1278
  const [top, ...rest] = matches;
940
1279
  out.push(...renderRule(top.rule, VERDICT_HEADING[top.rule.verdict]));
1280
+ const starter = top.rule.starter && top.rule.pattern ? manifest?.patterns?.find((p) => p.name === top.rule.pattern) : void 0;
1281
+ if (starter) {
1282
+ out.push(
1283
+ "## Start from this file",
1284
+ "",
1285
+ `It is complete. Keep everything around the scene and replace the scene with yours: every line outside it is one of the steps above. \`get_pattern("${starter.name}")\` has the pitfalls and how to check the result.`,
1286
+ "",
1287
+ "```tsx",
1288
+ starter.code,
1289
+ "```",
1290
+ ""
1291
+ );
1292
+ }
941
1293
  const also = rest.filter((m) => m.rule.id !== top.rule.id).slice(0, 3);
942
1294
  if (also.length > 0) {
943
1295
  out.push("## Also in what you described", "");
@@ -975,7 +1327,7 @@ function formatPlan({ building, using }) {
975
1327
  }
976
1328
 
977
1329
  // src/mcp/server.ts
978
- var VERSION = true ? "2.6.0" : "0.0.0-dev";
1330
+ var VERSION = true ? "2.8.0" : "0.0.0-dev";
979
1331
  function text(body) {
980
1332
  return { content: [{ type: "text", text: body }] };
981
1333
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vectorvesper",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Motion runtime and WebGL/R3F component CLI for React, with an MCP server that tells your coding agent which primitive to use, how to wire it, and when not to.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,7 +58,7 @@
58
58
  "test:mcp": "node scripts/mcp-e2e/harness.mjs --local",
59
59
  "test:mcp:published": "node scripts/mcp-e2e/harness.mjs",
60
60
  "postbuild": "node scripts/bundle-manifest.mjs",
61
- "prepublishOnly": "npm run build"
61
+ "prepublishOnly": "npm --prefix ../registry run build:hooks && npm run build"
62
62
  },
63
63
  "dependencies": {
64
64
  "@babel/core": "^7.24.0",