@mehshekari/gsap-motion 4.2.1 → 4.4.2

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.
@@ -75,6 +75,31 @@ Reach the state first, and change the conditions that usually break things:
75
75
  - `--reduced`, `--dark`, `--mobile`, `--cpu 4`. `--mobile` makes input touch,
76
76
  so it cannot be combined with `--hover`: a coarse pointer has no hover, which
77
77
  is the thing worth knowing.
78
+ - **Cost on a production build, not the dev server.** In development React's
79
+ StrictMode mounts every effect twice — two contexts, two WebGL contexts — and
80
+ the bundle is unminified, so what a dev server measures is not what ships.
81
+ Motion and timing can be watched on either.
82
+ - `--headed` for anything WebGL. Headless Chrome draws in software — on one
83
+ Windows machine, the Basic Render Driver headless and the Intel GPU headed —
84
+ so a 3D scene's frames, rate and long frames describe the wrong thing. A
85
+ report warns when the page has a canvas and the run was headless. The DOM's
86
+ layout and style numbers do not depend on it.
87
+ - `--seek <name>` for a moment too short or too far into the timeline for the
88
+ clock to land on. `--at` then means seconds of the timeline: before each
89
+ capture it calls `window.<name>(t)` and waits two rendered frames. A bundle
90
+ never exposes `gsap`, so the page has to export the function — in
91
+ development only, removed with the context:
92
+
93
+ ```ts
94
+ if (process.env.NODE_ENV !== "production") {
95
+ Object.assign(window, { __seek: (t: number) => tl.pause(t) });
96
+ // and in the cleanup: Reflect.deleteProperty(window, "__seek");
97
+ }
98
+ ```
99
+
100
+ Against a production build it is missing, and the run says so rather than
101
+ capturing the wrong frames. Through an MCP browser, call it from
102
+ `evaluate_script` instead.
78
103
 
79
104
  **Without a browser, report the stage as not run.** Not "fine".
80
105
 
@@ -99,6 +124,8 @@ that no source rule can reach.
99
124
  | **X% of the screen in motion** | Everything moving at once reads as noise, not as choreography. |
100
125
  | **seams** | A step far larger than that motion's own rhythm: a loop put back by hand instead of carried. |
101
126
  | **motion over text** | Something painted over a line a reader is in the middle of. Hit-tested, so it is what is really on top. |
127
+ | **frames over 50ms** | Long Animation Frames, with the sampler's own time taken out of each: the rest is the page's. "Not measured" means the browser has no such API, not that nothing was long. |
128
+ | **layout shift** | The browser's own shift total, input excluded. Sampling reads layout but never moves it, so this is the page's alone — a pin swapping its position at a section edge shows here. |
102
129
 
103
130
  Three things it deliberately does not do, so you do not read them into it:
104
131
 
@@ -113,6 +140,33 @@ Three things it deliberately does not do, so you do not read them into it:
113
140
  A clean report is evidence, not proof: it watched one window, on one machine, at
114
141
  one size. Say which.
115
142
 
143
+ ## In a browser you already drive
144
+
145
+ Both scripts open a Chrome of their own. Where a project allows only one — a
146
+ DevTools MCP server, say — they print what they would have run instead, and
147
+ open nothing:
148
+
149
+ ```bash
150
+ node <skill-dir>/scripts/review-motion.mjs --probe install # start recording
151
+ node <skill-dir>/scripts/review-motion.mjs --probe read # the findings
152
+ node <skill-dir>/scripts/capture-motion.mjs --probe timeline # the running timeline
153
+ ```
154
+
155
+ Each prints one function. Run `install` through the tool's `evaluate_script`,
156
+ then reach the state — scroll, hover, wait — with that tool's own input, and
157
+ then `read`. It returns what `review --json` would: the same conclusions,
158
+ computed in the page, so the frame log never leaves it.
159
+
160
+ What changes, so the report says it:
161
+
162
+ - **First motion counts from `install`**, not from navigation. A reload discards
163
+ the recorder; install again after one.
164
+ - **Emulation is the driving tool's**: its CPU throttling, viewport and
165
+ media features, set before `install`.
166
+ - **No layout or style counts.** Those come from the DevTools protocol, which a
167
+ page cannot reach — read them from the tool's own performance trace. Long
168
+ frames and layout shifts are observed in the page, so `read` has them.
169
+
116
170
  ## When to stop
117
171
 
118
172
  Stop when every check is clean, or after **three rounds**, reporting what is
@@ -54,6 +54,15 @@ two is not a coin toss.
54
54
  | "it feels cheap" | linear easing, or one uniform duration | `tune` |
55
55
  | "it feels slow" | usually too sequential, not too long: try overlap | `tune` |
56
56
 
57
+ ### "Match this site, one to one"
58
+
59
+ The reference is the precedent, and its motion is the spec. Measure its curves
60
+ and durations from it — `inspect` on its URL with a dense `--at`, or a
61
+ recording frame by frame — and copy them exactly. "Motion carries meaning or it
62
+ goes" judged the reference when it was designed; applied to the copy, it
63
+ simplifies what was asked for verbatim. Personalising is a second pass, after
64
+ the copy matches.
65
+
57
66
  ### "Something crazy"
58
67
 
59
68
  A fade-and-scale is a failure of nerve; twelve techniques at once is a failure
@@ -87,7 +96,8 @@ single hover takes [interaction](interaction.md) and nothing else from its row.
87
96
  | Request | Add |
88
97
  |---|---|
89
98
  | SVG draw / morph / path | [svg](svg.md), [timeline](timeline.md) |
90
- | Scroll sequence, pin, scrub | [scrolltrigger](scrolltrigger.md), [timeline](timeline.md), [performance](performance.md) |
99
+ | Scroll sequence, pin, scrub | [scrolltrigger](scrolltrigger.md), [timeline](timeline.md), [performance](performance.md), [scroll-storytelling](../preset/scroll-storytelling.md) |
100
+ | Smooth scroll, Lenis | [scrolltrigger](scrolltrigger.md#lenis), [devices](devices.md) |
91
101
  | Horizontal scroll | [scrolltrigger](scrolltrigger.md), [scroll-storytelling](../preset/scroll-storytelling.md) |
92
102
  | Text reveal, kinetic type | [text](text.md), [timeline](timeline.md) |
93
103
  | Magnetic, cursor, hover | [interaction](interaction.md), [performance](performance.md) |
@@ -97,6 +97,39 @@ Things that bite:
97
97
  - `position: sticky` is cheaper and needs no plugin. Use it when you only need
98
98
  the element to stay put, not to drive a timeline.
99
99
 
100
+ ### `pinType`
101
+
102
+ How the pin holds the element. On the window it defaults to `"fixed"`: at each
103
+ enter and leave, ScrollTrigger swaps the element's whole state — `position`,
104
+ `top`, `left`, `width`, `height`, margins, padding — and the browser lays it out
105
+ again in that frame. `"transform"` swaps nothing, and writes a translate every
106
+ frame while the pin is active (verified against gsap 3.15).
107
+
108
+ `"fixed"` is the default because native scrolling runs off the main thread, and
109
+ a translate written once per frame would trail it. Under Lenis that reason is
110
+ gone for the wheel — the scroll moves in the same frame as the pin — and the
111
+ state swap at every section edge is what remains. On one landing page it was
112
+ measured at a CLS of 4 to 7.5 and frames of 50 to 117ms per edge, and
113
+ `"transform"` removed both. Touch is still native under Lenis, so keep
114
+ `"fixed"` there:
115
+
116
+ ```ts
117
+ // Inside the component's context, so it reverts with it. Both conditions are
118
+ // named: a matchMedia callback runs only while one of them matches.
119
+ gsap.matchMedia().add({ smoothed: "(pointer: fine)", touch: "(pointer: coarse)" }, (context) => {
120
+ const { smoothed } = context.conditions;
121
+ ScrollTrigger.create({ trigger: section, pin: true, pinType: smoothed ? "transform" : "fixed" });
122
+ });
123
+ ```
124
+
125
+ A trackpad sends wheel events, so a laptop is smoothed too. A laptop with a
126
+ touch screen still reads `(pointer: fine)`, and scrolled by finger its pin
127
+ trails the native scroll. If that audience matters, branch on
128
+ `(any-pointer: coarse)` instead: every device with touch gets `"fixed"`, and
129
+ with it the cost at each edge.
130
+
131
+ Without smooth scrolling, leave the default.
132
+
100
133
  ## Horizontal scroll
101
134
 
102
135
  A vertical scroll driving a horizontal translation. `xPercent` avoids measuring:
@@ -162,6 +195,49 @@ interferes with `position: fixed`, anchor links, and native scroll on some
162
195
  touch devices. Do not add it to make a reveal smoother — fix the reveal. Add it
163
196
  only when the whole page is designed around smoothed scrolling.
164
197
 
198
+ ## Lenis
199
+
200
+ Lenis smooths wheel input and writes the result to the real scroll position, so
201
+ ScrollTrigger needs no `scrollerProxy`. Pins need one decision, in
202
+ [`pinType`](#pintype). What it needs above all is **one clock**, shared with
203
+ GSAP:
204
+
205
+ ```ts
206
+ const lenis = new Lenis(); // autoRaf stays false, its default
207
+ lenis.on("scroll", ScrollTrigger.update); // triggers read this frame's scroll
208
+ const raf = (time: number) => lenis.raf(time * 1000); // the ticker's time is seconds
209
+ gsap.ticker.add(raf, false, true); // prioritized: before GSAP renders
210
+ gsap.ticker.lagSmoothing(0);
211
+
212
+ // Teardown. A context owns neither of these.
213
+ gsap.ticker.remove(raf);
214
+ lenis.destroy();
215
+ ```
216
+
217
+ - **Prioritized, because GSAP renders in its own first ticker listener.** Added
218
+ after it, Lenis moves the scroll once GSAP has drawn the frame: a numeric
219
+ `scrub` — a tween — and anything tweened from the scroll position render one
220
+ frame late. `scrub: true` sets progress directly and is not affected.
221
+ - **Never `autoRaf: true` as well.** Lenis steps by the difference between
222
+ consecutive `raf` calls, and the two loops pass clocks with different origins,
223
+ so the step swings and the scroll stutters.
224
+ - **`lagSmoothing(0)`**: after a frame longer than 500ms, GSAP otherwise
225
+ advances its clock by 33ms, and Lenis on that clock crawls back to the
226
+ scroll. It applies to every tween on the page, which then jump after a long
227
+ frame instead of slowing.
228
+ - **Touch is native by default.** With `syncTouch` off, Lenis smooths nothing on
229
+ a touch screen and only runs its loop. Leave it off — it takes over the
230
+ platform's momentum, and is unstable on older iOS — and consider not creating
231
+ Lenis at all without a fine pointer; see [devices.md](devices.md).
232
+ - **Reduced motion is handled.** By default Lenis tracks the input 1:1 and makes
233
+ programmatic scrolls instant, and keeps running. Do not destroy it for reduced
234
+ motion: the ScrollTrigger sync above depends on it.
235
+ - In-page links need `anchors: true`; a nested scroll container needs
236
+ `data-lenis-prevent`. In React, `ReactLenis` from `lenis/react` takes the same
237
+ options: `autoRaf: false`, and the ticker wiring in an effect.
238
+
239
+ Verified against `lenis@1.3` and `gsap@3.15`.
240
+
165
241
  ## React
166
242
 
167
243
  The lifecycle is the hard part. `useGSAP` reverts its context on unmount, which
@@ -189,3 +265,6 @@ images settle. `ScrollTrigger.refresh()` on mount is usually needed. See
189
265
  | Janky while scrolling | layout-triggering properties — see [performance](performance.md) |
190
266
  | Works, then breaks after navigation | ScrollTriggers not killed on unmount |
191
267
  | Wrong on mobile after address-bar hide | viewport resize — `invalidateOnRefresh` |
268
+ | Smooth scroll stutters | Lenis on two loops — `autoRaf` and the ticker |
269
+ | A numeric scrub trails the scroll by a frame | Lenis's `raf` added to the ticker without `prioritize` |
270
+ | A layout shift and a long frame at each pinned section's edge | a `"fixed"` pin under Lenis — `pinType: "transform"` for the wheel |
@@ -151,6 +151,10 @@ if (asJson) {
151
151
  .join(", ");
152
152
 
153
153
  console.log(`\n${tally}`);
154
+ /** Said here because a report that never mentions it reads as "there is no way". */
155
+ console.log(
156
+ "A deliberate finding is waived with `<rule-id>-ok` and a reason, on its line or in the eight above.",
157
+ );
154
158
  }
155
159
 
156
160
  process.exit(findings.some((f) => f.level === "error") ? 1 : 0);
@@ -29,6 +29,12 @@
29
29
  * --cpu 4 throttle the CPU by this factor
30
30
  * --json machine-readable, for scripts
31
31
  * --chrome <path> a browser to use, overriding the search
32
+ * --seek <name> call window.<name>(t) before each capture, and read
33
+ * --at as seconds of the timeline instead of the clock
34
+ * --headed a visible Chrome, on the machine's GPU: what a WebGL
35
+ * page needs for its frames and rate to mean anything
36
+ * --probe timeline print the timeline reader as a function, for a
37
+ * browser you already drive, and open none
32
38
  *
33
39
  * `--scroll`, `--hover` and `--click` run in the order they are written, before
34
40
  * the first capture. An animation that needs a pointer or a scroll position is
@@ -40,7 +46,7 @@
40
46
  import { spawn } from "node:child_process";
41
47
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
42
48
  import { homedir, tmpdir } from "node:os";
43
- import { join, resolve } from "node:path";
49
+ import { join, resolve, sep } from "node:path";
44
50
  import { fileURLToPath } from "node:url";
45
51
 
46
52
  // --- The browser -------------------------------------------------------------
@@ -85,11 +91,20 @@ export function connect(executable, { headless = true } = {}) {
85
91
  const chrome = spawn(
86
92
  executable,
87
93
  [
88
- ...(headless ? ["--headless=new"] : []),
94
+ /** Headless runs without a GPU, for a steady result; headed gets the machine's own. */
95
+ ...(headless ? ["--headless=new", "--disable-gpu"] : []),
89
96
  "--remote-debugging-pipe",
97
+ /**
98
+ * A window nobody can see is a page Chrome stops animating: headed and
99
+ * moved off-screen, as a window behind an editor is, rAF went from 122 a
100
+ * second to none, and the run hung. With these it ran at 166. They are
101
+ * Puppeteer's defaults too.
102
+ */
103
+ "--disable-backgrounding-occluded-windows",
104
+ "--disable-renderer-backgrounding",
105
+ "--disable-background-timer-throttling",
90
106
  "--no-first-run",
91
107
  "--no-default-browser-check",
92
- "--disable-gpu",
93
108
  "--disable-extensions",
94
109
  `--user-data-dir=${profile}`,
95
110
  "about:blank",
@@ -176,6 +191,10 @@ export function parseOptions(argv) {
176
191
  cpu: 1,
177
192
  json: false,
178
193
  chrome: null,
194
+ headed: false,
195
+ seek: null,
196
+ atGiven: false,
197
+ probe: null,
179
198
  errors: [],
180
199
  };
181
200
 
@@ -192,6 +211,7 @@ export function parseOptions(argv) {
192
211
  options.errors.push("--at takes milliseconds, such as --at 0,300,900");
193
212
  } else {
194
213
  options.at = times.sort((a, b) => a - b);
214
+ options.atGiven = true;
195
215
  }
196
216
  break;
197
217
  }
@@ -234,6 +254,20 @@ export function parseOptions(argv) {
234
254
  case "--chrome":
235
255
  options.chrome = value() ?? null;
236
256
  break;
257
+ case "--headed":
258
+ options.headed = true;
259
+ break;
260
+ case "--seek": {
261
+ /** A name the page exports, never an expression: it is spliced into the page's code. */
262
+ const name = value() ?? "";
263
+ if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(name)) options.seek = name;
264
+ else options.errors.push("--seek takes the name of a function on window, such as --seek __seek");
265
+ break;
266
+ }
267
+ case "--probe":
268
+ if (value() === "timeline") options.probe = "timeline";
269
+ else options.errors.push("--probe takes timeline");
270
+ break;
237
271
  default:
238
272
  if (argument.startsWith("--")) options.errors.push(`Unknown option: ${argument}`);
239
273
  else if (options.url === null) options.url = argument;
@@ -241,7 +275,15 @@ export function parseOptions(argv) {
241
275
  }
242
276
  }
243
277
 
244
- if (options.url === null) options.errors.push("No URL. Pass the page to watch.");
278
+ /** A probe is printed, not run, so it needs no page. */
279
+ if (options.url === null && options.probe === null) {
280
+ options.errors.push("No URL. Pass the page to watch.");
281
+ }
282
+
283
+ /** Seeking makes `--at` timeline seconds, so the millisecond default would mean minutes. */
284
+ if (options.seek && !options.atGiven) {
285
+ options.errors.push("--seek needs --at, in seconds of the timeline, such as --at 3,3.3,3.6");
286
+ }
245
287
 
246
288
  /** A coarse pointer has no hover; emulating both would measure something no device is. */
247
289
  if (options.mobile && options.steps.some((step) => step.type === "hover")) {
@@ -325,6 +367,21 @@ const COUNT_FRAMES = `(() => {
325
367
  return true;
326
368
  })()`;
327
369
 
370
+ /**
371
+ * The timeline reader as a function, for a browser something else already
372
+ * holds — a DevTools MCP server's `evaluate_script`. Layouts and style
373
+ * recalculations come from the DevTools protocol, which a page cannot reach, so
374
+ * they are not part of it.
375
+ */
376
+ export const probe = () => `() => ${READ_TIMELINE}`;
377
+
378
+ /** Two rendered frames: a seek has been applied and painted once this resolves. */
379
+ const SETTLED = "new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => done(true))))";
380
+
381
+ /** A frame's file name: milliseconds of the clock, or seconds of the timeline when seeking. */
382
+ export const frameName = (at, options) =>
383
+ options.seek ? `frame-${at.toFixed(3).padStart(8, "0")}s.png` : `frame-${String(at).padStart(5, "0")}ms.png`;
384
+
328
385
  /** The live box of a selector, so a pointer lands on an element that is moving. */
329
386
  const boxOf = (selector) => `(() => {
330
387
  const element = document.querySelector(${JSON.stringify(selector)});
@@ -333,6 +390,39 @@ const boxOf = (selector) => `(() => {
333
390
  return { x: box.x + box.width / 2, y: box.y + box.height / 2, width: box.width, height: box.height };
334
391
  })()`;
335
392
 
393
+ // --- Where output goes -------------------------------------------------------
394
+
395
+ /**
396
+ * What a report says when the page has a canvas and Chrome ran without its GPU:
397
+ * a WebGL scene was then drawn in software, and its frames, rate and long frames
398
+ * describe that, not the visitor's machine. Null when there is nothing to say.
399
+ */
400
+ export function gpuNote(canvases, options) {
401
+ if (!canvases || options.headed) return null;
402
+ return (
403
+ `${canvases} canvas${canvases === 1 ? "" : "es"} on the page, and Chrome ran without its GPU:` +
404
+ " a WebGL scene's frames, rate and long frames are software rendering's. Measure it with --headed."
405
+ );
406
+ }
407
+
408
+ /**
409
+ * Creates `out` and returns its absolute path. When it is inside the project's
410
+ * `.gsap-motion/` — the default is — that folder gets a `.gitignore` of `*`,
411
+ * so the frames are never committed: a real project committed four of them in
412
+ * its first commit, and a knowledge-graph tool indexed them after that. The
413
+ * project's own `.gitignore` is left alone, and so is any other `--out`.
414
+ */
415
+ export function prepareOut(out, cwd = process.cwd()) {
416
+ const path = resolve(cwd, out);
417
+ mkdirSync(path, { recursive: true });
418
+ const own = resolve(cwd, ".gsap-motion");
419
+ if (path === own || path.startsWith(own + sep)) {
420
+ const ignore = join(own, ".gitignore");
421
+ if (!existsSync(ignore)) writeFileSync(ignore, "# Written by gsap-motion: frames are not source.\n*\n");
422
+ }
423
+ return path;
424
+ }
425
+
336
426
  // --- The run -----------------------------------------------------------------
337
427
 
338
428
  const wait = (ms) => new Promise((done) => setTimeout(done, ms));
@@ -356,7 +446,7 @@ async function capture(options) {
356
446
  );
357
447
  }
358
448
 
359
- const browser = connect(executable);
449
+ const browser = connect(executable, { headless: !options.headed });
360
450
  const notes = [];
361
451
 
362
452
  try {
@@ -467,18 +557,27 @@ async function capture(options) {
467
557
  }
468
558
  }
469
559
 
470
- mkdirSync(resolve(options.out), { recursive: true });
560
+ const out = prepareOut(options.out);
471
561
 
472
562
  const frames = [];
473
563
  let elapsed = 0;
564
+ if (options.seek && !(await evaluate(`typeof window.${options.seek} === "function"`))) {
565
+ throw new Error(
566
+ `--seek: window.${options.seek} is not a function on this page. Export it in development; see refine.md.`,
567
+ );
568
+ }
474
569
  for (const at of options.at) {
475
- if (at > elapsed) {
570
+ if (options.seek) {
571
+ /** Seek, then let two frames render, so what is captured is that time drawn. */
572
+ await evaluate(`window.${options.seek}(${at})`);
573
+ await evaluate(SETTLED);
574
+ } else if (at > elapsed) {
476
575
  await wait(at - elapsed);
477
576
  elapsed = at;
478
577
  }
479
578
  const shot = await call("Page.captureScreenshot", { format: "png" });
480
579
  const bytes = Buffer.from(shot.data, "base64");
481
- const file = join(resolve(options.out), `frame-${String(at).padStart(5, "0")}ms.png`);
580
+ const file = join(out, frameName(at, options));
482
581
  writeFileSync(file, bytes);
483
582
  frames.push({ at, file, bytes: bytes.length });
484
583
  }
@@ -487,6 +586,7 @@ async function capture(options) {
487
586
  const drawn = await evaluate(
488
587
  "({ frames: window.__gsapMotionFrames ?? null, since: window.__gsapMotionStart ?? null })",
489
588
  );
589
+ const canvases = await evaluate("document.querySelectorAll(\"canvas\").length");
490
590
  const after = await call("Performance.getMetrics");
491
591
 
492
592
  const delta = (name) => {
@@ -511,7 +611,7 @@ async function capture(options) {
511
611
  overMs: span,
512
612
  };
513
613
 
514
- return { url: options.url, frames, timeline, measured, notes, chrome: executable };
614
+ return { url: options.url, frames, timeline, measured, notes, canvases, chrome: executable };
515
615
  } finally {
516
616
  await browser.close();
517
617
  }
@@ -527,13 +627,18 @@ export function report(result, options) {
527
627
  options.dark && "dark",
528
628
  options.mobile && "mobile 390x844",
529
629
  options.cpu > 1 && `CPU ÷${options.cpu}`,
630
+ options.headed && "headed, on the GPU",
631
+ options.seek && `seeking with window.${options.seek}`,
530
632
  ].filter(Boolean);
531
633
  if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
532
634
  for (const note of result.notes) lines.push(` ${note}`);
635
+ const gpu = gpuNote(result.canvases, options);
636
+ if (gpu) lines.push(` ${gpu}`);
533
637
 
534
638
  lines.push("", `Frames (${result.frames.length}), in ${options.out}`);
535
639
  for (const frame of result.frames) {
536
- lines.push(` ${String(frame.at).padStart(5)}ms ${frame.file}`);
640
+ const when = options.seek ? `${frame.at}s`.padStart(8) : `${String(frame.at).padStart(5)}ms`;
641
+ lines.push(` ${when} ${frame.file}`);
537
642
  }
538
643
 
539
644
  lines.push("", "Timeline, as the page is running it");
@@ -591,6 +696,11 @@ async function main(argv) {
591
696
  process.exit(2);
592
697
  }
593
698
 
699
+ if (options.probe) {
700
+ console.log(probe());
701
+ return;
702
+ }
703
+
594
704
  try {
595
705
  const result = await capture(options);
596
706
  console.log(options.json ? JSON.stringify(result, null, 2) : report(result, options));
@@ -214,26 +214,75 @@ export function tweenMethod(node) {
214
214
  return allowed.has(method) ? method : null;
215
215
  }
216
216
 
217
- const argumentObject = (call, index) => {
218
- const node = unwrap(call.arguments[index]);
219
- return node?.type === "ObjectExpression" ? node : null;
220
- };
217
+ /**
218
+ * The object literal bound to `name` by a `const`, when the file binds that
219
+ * name exactly once. Two bindings of one name, in different functions, would
220
+ * need scope to tell apart; a wrong guess is a false finding, so neither is
221
+ * read.
222
+ */
223
+ function constObject(root, name) {
224
+ const bound = findAll(
225
+ root,
226
+ (node) => node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.id.name === name,
227
+ );
228
+ if (bound.length !== 1 || !bound[0].init) return null;
229
+ const declaration = parentOf(root, bound[0]);
230
+ if (declaration?.kind !== "const") return null;
231
+ const init = unwrap(bound[0].init);
232
+ return init.type === "ObjectExpression" ? init : null;
233
+ }
234
+
235
+ /** `node`'s properties, re-anchored at `site`: a finding lands where the tween uses them. */
236
+ const anchoredAt = (properties, site) =>
237
+ properties.map((property) => ({ ...property, start: site.start, end: site.end }));
238
+
239
+ /**
240
+ * The object a vars argument stands for: a literal, or a `const` bound to one,
241
+ * with any spread of such a `const` unfolded in place. Vars written once and
242
+ * shared between a `fromTo`'s ends are how a real film's credits hid two of
243
+ * seven filter tweens from the audit.
244
+ */
245
+ function resolveObject(root, node, seen = new Set()) {
246
+ let target = unwrap(node);
247
+ let site = null;
248
+ if (target?.type === "Identifier" && root) {
249
+ site = target;
250
+ target = constObject(root, target.name);
251
+ }
252
+ if (target?.type !== "ObjectExpression" || seen.has(target)) return null;
253
+ seen.add(target);
254
+
255
+ let unfolded = false;
256
+ const properties = target.properties.flatMap((property) => {
257
+ if (property.type !== "SpreadElement") return [property];
258
+ const spread = resolveObject(root, property.argument, seen);
259
+ if (!spread) return [property];
260
+ unfolded = true;
261
+ return anchoredAt(spread.properties, property);
262
+ });
263
+ /** A literal with nothing to unfold is returned as it is, not as a copy. */
264
+ if (site) return { ...target, start: site.start, end: site.end, properties: anchoredAt(properties, site) };
265
+ return unfolded ? { ...target, properties } : target;
266
+ }
267
+
268
+ const argumentObject = (call, index, root) => resolveObject(root, call.arguments[index]);
221
269
 
222
270
  /**
223
271
  * The vars object that holds a tween's own settings — `repeat`, `ease`,
224
272
  * `yoyo`: the second argument, a `fromTo`'s third, a timeline's first. `null`
225
- * when it is not an object literal.
273
+ * when it is neither an object literal nor a `const` bound to one. Pass the
274
+ * file's `root` to follow a `const`; without it, only literals are read.
226
275
  */
227
- export const ownVars = (call, method) =>
228
- argumentObject(call, method === "fromTo" ? 2 : method === "timeline" ? 0 : 1);
276
+ export const ownVars = (call, method, root) =>
277
+ argumentObject(call, method === "fromTo" ? 2 : method === "timeline" ? 0 : 1, root);
229
278
 
230
279
  /**
231
280
  * Every vars object a tween call carries, as object literals: both of a
232
281
  * `fromTo`'s, a timeline's own, or the tween's.
233
282
  */
234
- export function varsObjects(call, method) {
283
+ export function varsObjects(call, method, root) {
235
284
  const indexes = method === "fromTo" ? [1, 2] : method === "timeline" ? [0] : [1];
236
- return indexes.map((index) => argumentObject(call, index)).filter(Boolean);
285
+ return indexes.map((index) => argumentObject(call, index, root)).filter(Boolean);
237
286
  }
238
287
 
239
288
  const FUNCTIONS = new Set([