vectorvesper 2.7.0 → 2.8.1

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.7.0" : "0.0.0-dev";
1126
+ var CLI_VERSION = true ? "2.8.1" : "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();
@@ -1363,8 +1363,9 @@ async function loginCommand(key) {
1363
1363
  console.log(` ${pc10.green(pc10.bold("\u2714"))} Token saved \u2014 you're logged in.`);
1364
1364
  console.log(` ${pc10.dim("Stored at:")} ${pc10.cyan(getGlobalConfigPath())}`);
1365
1365
  console.log("");
1366
- console.log(` ${pc10.dim("Pro components are now available when you run")} ${pc10.cyan("npx vectorvesper add <slug>")}`);
1367
- console.log(` ${pc10.dim("To verify your account:")} ${pc10.cyan("npx vectorvesper whoami")}`);
1366
+ console.log(` ${pc10.dim("Install a component with")} ${pc10.cyan("npx vectorvesper add <slug>")}`);
1367
+ console.log(` ${pc10.dim("Pro components install if your membership covers them.")}`);
1368
+ console.log(` ${pc10.dim("Your membership:")} ${pc10.cyan("https://vectorvesper.dev/account")}`);
1368
1369
  console.log("");
1369
1370
  }
1370
1371
  async function logoutCommand() {
@@ -1392,30 +1393,25 @@ async function logoutCommand() {
1392
1393
  async function whoamiCommand() {
1393
1394
  const config = loadGlobalConfig();
1394
1395
  console.log("");
1395
- console.log(pc10.bold(pc10.cyan(" Vector Vesper Account Status\n")));
1396
+ console.log(pc10.bold(pc10.cyan(" Vector Vesper CLI\n")));
1396
1397
  if (config.auth?.token) {
1397
1398
  const maskedKey = config.auth.token.slice(0, 6) + "\u2026" + config.auth.token.slice(-4);
1398
- const tier = config.auth.tier || "pro";
1399
- console.log(` ${pc10.bold("Status:")} ${pc10.green("Authenticated")}`);
1399
+ console.log(` ${pc10.bold("Status:")} ${pc10.green("Signed in")}`);
1400
1400
  console.log(` ${pc10.bold("Token:")} ${pc10.cyan(maskedKey)}`);
1401
- console.log(` ${pc10.bold("Tier:")} ${pc10.magenta(tier)}`);
1402
1401
  if (config.auth.email) {
1403
1402
  console.log(` ${pc10.bold("Email:")} ${pc10.cyan(config.auth.email)}`);
1404
1403
  }
1405
- if (config.auth.expiresAt) {
1406
- const expiry = new Date(config.auth.expiresAt);
1407
- const now = /* @__PURE__ */ new Date();
1408
- const isExpired = expiry < now;
1409
- console.log(` ${pc10.bold("Expires:")} ${isExpired ? pc10.red("EXPIRED") : pc10.green(expiry.toLocaleDateString())}`);
1410
- }
1404
+ console.log("");
1405
+ console.log(` ${pc10.dim("Your membership is checked by the server each time you install.")}`);
1406
+ console.log(` ${pc10.dim("A Pro component arrives if it covers you, and is refused if not.")}`);
1411
1407
  console.log("");
1412
1408
  console.log(` ${pc10.dim("Config:")} ${pc10.cyan(getGlobalConfigPath())}`);
1413
- console.log(` ${pc10.dim("Manage your account at")} ${pc10.cyan("https://vectorvesper.dev/account")}`);
1409
+ console.log(` ${pc10.dim("See your membership at")} ${pc10.cyan("https://vectorvesper.dev/account")}`);
1414
1410
  } else {
1415
- console.log(` ${pc10.bold("Status:")} ${pc10.yellow("Anonymous (free tier)")}`);
1411
+ console.log(` ${pc10.bold("Status:")} ${pc10.yellow("Not signed in")}`);
1416
1412
  console.log(` ${pc10.bold("Access:")} ${pc10.dim("Free components only")}`);
1417
1413
  console.log("");
1418
- console.log(` ${pc10.dim("To unlock pro components:")}`);
1414
+ console.log(` ${pc10.dim("To install Pro components you have a membership for:")}`);
1419
1415
  console.log(` ${pc10.cyan("npx vectorvesper login <token>")}`);
1420
1416
  console.log(` \u{1F511} ${pc10.dim("Generate a token at")} ${pc10.cyan("https://vectorvesper.dev/account")}`);
1421
1417
  }
@@ -1423,7 +1419,7 @@ async function whoamiCommand() {
1423
1419
  }
1424
1420
 
1425
1421
  // src/index.ts
1426
- var version = true ? "2.7.0" : "0.0.0-dev";
1422
+ var version = true ? "2.8.1" : "0.0.0-dev";
1427
1423
  var program = new Command();
1428
1424
  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
1425
  const opts = thisCommand.opts();
@@ -1465,7 +1461,7 @@ program.command("mcp [action] [client]").description(
1465
1461
  "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
1462
  ).option("--stdio", "Force server mode even from an interactive terminal").option("-g, --global", "Write user-level config instead of project-level (install)").action(
1467
1463
  async (action, client, options) => {
1468
- const { mcpCommand } = await import("./mcp-RHYAXOSU.js");
1464
+ const { mcpCommand } = await import("./mcp-D5QWQ2PC.js");
1469
1465
  await mcpCommand(action, { ...options, client });
1470
1466
  }
1471
1467
  );
@@ -146,7 +146,7 @@ function detectClients(cwd = process.cwd()) {
146
146
  }
147
147
 
148
148
  // src/commands/mcp.ts
149
- var VERSION = true ? "2.7.0" : "0.0.0-dev";
149
+ var VERSION = true ? "2.8.1" : "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-DVFJRSZW.js");
296
+ const { startMcpServer } = await import("./server-BEBUGQ7F.js");
297
297
  try {
298
298
  await startMcpServer();
299
299
  } catch (error) {
@@ -146,13 +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: [],
152
154
  gpuLostHandlers: [],
153
155
  mentionsDestroyedReason: false,
154
156
  webgpuClassChecks: [],
155
- gatingObserver: []
157
+ gatingObserver: [],
158
+ threeRenderers: [],
159
+ uncappedPixelRatio: [],
160
+ animationLoops: [],
161
+ hasForceContextLoss: false,
162
+ reportsLost: false,
163
+ reportsHealthy: false,
164
+ rendererRebuilds: [],
165
+ hasIntersectionObserver: false
156
166
  };
157
167
  const reactSetters = collectReactSetters(ast, traverse);
158
168
  const manifest = loadHookManifest();
@@ -374,9 +384,42 @@ function collect(ast, traverse) {
374
384
  // handling. Being loose here can only suppress a finding, and a missed
375
385
  // warning is the failure this file prefers over a false one.
376
386
  case "reportLost":
387
+ f.handlesContextLoss = true;
388
+ f.reportsLost = true;
389
+ break;
377
390
  case "reportHealthy":
378
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
+ }
379
421
  break;
422
+ }
380
423
  case "requestDevice":
381
424
  f.gpuDevices.push(line);
382
425
  break;
@@ -500,7 +543,17 @@ function collect(ast, traverse) {
500
543
  // imperatively. Record the shape so the render-quality rules can read it.
501
544
  JSXOpeningElement(p) {
502
545
  f.isReact = true;
503
- 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;
504
557
  const overrides = [];
505
558
  let hasSpread = false;
506
559
  for (const attr of p.node.attributes ?? []) {
@@ -541,9 +594,11 @@ function collect(ast, traverse) {
541
594
  f.moduleScopeBrowserApi.push({ line: p.node.loc?.start.line ?? 0, api: name });
542
595
  },
543
596
  NewExpression(p) {
544
- 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);
545
599
  if (n && OBSERVERS.has(n)) f.newObserver.push(p.node.loc?.start.line ?? 0);
546
600
  if (n === "IntersectionObserver") {
601
+ f.hasIntersectionObserver = true;
547
602
  const argPaths = typeof p.get === "function" ? p.get("arguments") : [];
548
603
  if (containsStateSetter(resolveFunction(argPaths[0]))) {
549
604
  f.gatingObserver.push(p.node.loc?.start.line ?? 0);
@@ -664,7 +719,7 @@ function checkMissingCleanup(f, file) {
664
719
  if (f.newObserver.length && !f.hasObserverDisconnect) push(f.newObserver[0], "An observer", "`.disconnect()`");
665
720
  if (f.gsapTimeline.length && !f.usesUseGSAP && !f.hasGsapCleanup)
666
721
  push(f.gsapTimeline[0], "A GSAP timeline", "`.kill()` (or drive it from `useGSAP`, which reverts automatically)");
667
- if (f.threeDisposable.length && !f.usesR3F && !f.hasThreeDispose)
722
+ if (f.threeDisposable.length && !f.usesR3F && !f.hasThreeDispose && !f.vvImports.has("useThreeScene"))
668
723
  push(f.threeDisposable[0], "A three.js geometry/material/texture", "`.dispose()`");
669
724
  return out;
670
725
  }
@@ -755,24 +810,64 @@ function checkRenderQualityProps(f, file) {
755
810
  function checkContextLoss(f, file) {
756
811
  const r3fCanvas = f.usesR3F && f.canvases.length > 0;
757
812
  const rawContext = f.rawGlContexts.length > 0;
758
- if (!r3fCanvas && !rawContext) return [];
759
- 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");
760
832
  if (handled) return [];
833
+ const threeOnly = handRenderer && !r3fCanvas && !rawContext;
761
834
  return [
762
835
  {
763
836
  rule: "webgl-context-loss",
764
837
  severity: "warning",
765
838
  file,
766
- line: r3fCanvas ? f.canvases[0].line : f.rawGlContexts[0],
839
+ line,
767
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.",
768
- 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()`.",
769
- nudge: 'Call `get_pattern("lazy-3d-section")` for the whole wiring.'
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."
770
865
  }
771
866
  ];
772
867
  }
773
868
  function checkGpuDeviceLoss(f, file) {
774
869
  if (f.gpuDevices.length === 0) return [];
775
- const handled = f.gpuLostHandlers.length > 0 || f.vvImports.has("watchGPUDevice") || f.vvImports.has("useRenderQuality");
870
+ const handled = f.gpuLostHandlers.length > 0 || f.vvImports.has("watchGPUDevice") || f.vvImports.has("useRenderQuality") || f.vvImports.has("useThreeScene");
776
871
  if (handled) return [];
777
872
  return [
778
873
  {
@@ -845,10 +940,118 @@ function checkTransformConflict(f, file) {
845
940
  }
846
941
  ];
847
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
+ }
848
1048
  function checkReducedMotion(f, file) {
849
- if (f.looping.length === 0 || f.hasReducedMotionGuard) return [];
1049
+ if (f.hasReducedMotionGuard) return [];
850
1050
  if (f.vvHooks.some((v) => v.hook.runtime.respectsReducedMotion)) return [];
851
- 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;
852
1055
  return [
853
1056
  {
854
1057
  rule: "no-reduced-motion",
@@ -856,7 +1059,7 @@ function checkReducedMotion(f, file) {
856
1059
  file,
857
1060
  line: first.line,
858
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.`,
859
- 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."
860
1063
  }
861
1064
  ];
862
1065
  }
@@ -886,11 +1089,16 @@ async function analyzeSource(file, source, framework) {
886
1089
  ...checkTransformConflict(facts, file),
887
1090
  ...checkReducedMotion(facts, file),
888
1091
  ...checkRenderQualityProps(facts, file),
1092
+ ...checkAdapterPropsToWrapper(facts, file),
889
1093
  ...checkContextLoss(facts, file),
890
1094
  ...checkGpuDeviceLoss(facts, file),
891
1095
  ...checkGpuDestroyReason(facts, file),
892
1096
  ...checkWebgpuClassCheck(facts, file),
893
- ...checkHandRolledGate(facts, file)
1097
+ ...checkHandRolledGate(facts, file),
1098
+ ...checkOffscreenRender(facts, file),
1099
+ ...checkUncappedPixelRatio(facts, file),
1100
+ ...checkUnreleasedContext(facts, file),
1101
+ ...checkRendererRebuild(facts, file)
894
1102
  ].sort((a, b) => a.line - b.line);
895
1103
  return { file, findings };
896
1104
  } catch (err) {
@@ -1069,6 +1277,19 @@ function formatPlan({ building, using }) {
1069
1277
  } else {
1070
1278
  const [top, ...rest] = matches;
1071
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
+ }
1072
1293
  const also = rest.filter((m) => m.rule.id !== top.rule.id).slice(0, 3);
1073
1294
  if (also.length > 0) {
1074
1295
  out.push("## Also in what you described", "");
@@ -1106,7 +1327,7 @@ function formatPlan({ building, using }) {
1106
1327
  }
1107
1328
 
1108
1329
  // src/mcp/server.ts
1109
- var VERSION = true ? "2.7.0" : "0.0.0-dev";
1330
+ var VERSION = true ? "2.8.1" : "0.0.0-dev";
1110
1331
  function text(body) {
1111
1332
  return { content: [{ type: "text", text: body }] };
1112
1333
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vectorvesper",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
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",