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/README.md +2 -2
- package/dist/hooks.json +834 -60
- package/dist/index.js +3 -3
- package/dist/{mcp-BEM4TVKM.js → mcp-LMUWTS3I.js} +2 -2
- package/dist/{server-CIFO3HSN.js → server-PYIRS3NJ.js} +369 -17
- package/package.json +2 -2
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.
|
|
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.
|
|
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-
|
|
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.
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
354
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
675
|
-
|
|
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
|
|
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.
|
|
1049
|
+
if (f.hasReducedMotionGuard) return [];
|
|
722
1050
|
if (f.vvHooks.some((v) => v.hook.runtime.respectsReducedMotion)) return [];
|
|
723
|
-
const
|
|
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
|
-
...
|
|
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.
|
|
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.
|
|
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",
|