@mehshekari/gsap-motion 4.1.0 → 4.2.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/README.md CHANGED
@@ -32,7 +32,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
32
32
  build without warning:
33
33
 
34
34
  ```json
35
- "lint": "eslint && npx @mehshekari/gsap-motion@4.0.0 audit src --quiet"
35
+ "lint": "eslint && npx @mehshekari/gsap-motion@4.2.1 audit src --quiet"
36
36
  ```
37
37
 
38
38
  Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/gsap-motion",
3
- "version": "4.1.0",
3
+ "version": "4.2.1",
4
4
  "description": "Install the gsap-creative-animation Agent Skill, and audit GSAP animation for leaks, jank and accessibility failures — with npx.",
5
5
  "keywords": [
6
6
  "gsap",
package/skill/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: Senior motion engineer for GSAP. Turns a visual or interaction idea
4
4
  license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
5
5
  compatibility: Any Agent Skills client; written for Claude Code. GSAP 3.13+, where every plugin is free. React guidance targets @gsap/react 2.x, React 18/19 and the Next.js App Router. The audit scripts need Node.js 22+ and nothing else.
6
6
  metadata:
7
- version: "4.1.0"
7
+ version: "4.2.1"
8
8
  verified-gsap: "3.15"
9
9
  verified-gsap-react: "2.1"
10
10
  ---
@@ -54,6 +54,21 @@ mm.add(
54
54
 
55
55
  ```
56
56
 
57
+ **Both conditions are required, and dropping `motion` breaks everything.** The
58
+ callback runs only while at least one named condition matches. With `reduced`
59
+ alone, a visitor who has no preference — nearly all of them — matches nothing,
60
+ so the callback never runs and **the page ships with no animation at all**,
61
+ silently: no error, and every test with reduced motion on still passes. The
62
+ audit reports it as `matchmedia-never-runs`.
63
+
64
+ A reduced-only `add` is still correct when it does not branch — the shape below
65
+ is GSAP's own, and each callback is meant to run only for its own visitors:
66
+
67
+ ```ts
68
+ mm.add("(prefers-reduced-motion: no-preference)", () => { /* the animation */ });
69
+ mm.add("(prefers-reduced-motion: reduce)", () => { /* the end states */ });
70
+ ```
71
+
57
72
  Built inside a `useGSAP` body this needs no explicit teardown — `matchMedia`
58
73
  registers with the active context and is reverted with it.
59
74
 
@@ -147,3 +147,30 @@ Verification
147
147
 
148
148
  Say what you did not check, too. "Not checked" is information; silence reads as
149
149
  "checked and fine", and that is the one thing it must never mean.
150
+
151
+ ### "Verified" names what you saw
152
+
153
+ **A check is a measurement, not a verb.** "Verified live", "tested at 390px" and
154
+ "works as expected" say that something was run; they do not say what came back,
155
+ so nobody can tell whether it would have caught the failure.
156
+
157
+ A head-to-head test made the cost concrete. An answer written with this skill
158
+ reported *"verified live, no horizontal overflow at 390px"* — and shipped two
159
+ bugs that a 390px browser shows at a glance: the visual pushed below the fold,
160
+ and a label invisible through its whole first state. Overflow was the one thing
161
+ checked, and it was not what was broken.
162
+
163
+ So a line states the value observed, at a time and a size:
164
+
165
+ | Not this | This |
166
+ |---|---|
167
+ | verified live | badge label opacity 1 at t=0 and at every state label, 1280x800 |
168
+ | works on mobile | 390x844: the visual's top edge at 96px, above the fold; no overflow |
169
+ | reduced motion handled | reduced emulated: heading and lead at end state, no tween ran |
170
+ | animation runs | `review`: first motion at 410ms; 0 elements invisible for the whole window |
171
+
172
+ **Check the failure, not the feature.** A reveal that works is easy to observe;
173
+ the failures that ship are an element that never appears, one that appears too
174
+ late, and one that is off screen at the size you did not look at. Look for those
175
+ by name. And a claim you did not observe is worse than "not checked": it tells
176
+ the reader to stop looking.
@@ -186,3 +186,35 @@ ScrollTrigger's `scrub` is this same mechanism with scroll as the value.
186
186
  | Entrance replays on loop | `repeat: -1` on the timeline, not the child |
187
187
  | Flash of the end state before it runs | `from` without a `set`, outside a layout effect |
188
188
  | Two beats drift apart after an edit | both timed by number instead of sharing a label |
189
+ | An element is invisible until much later than it should be | several `from`/`fromTo` on it, each applying its start state when built |
190
+
191
+ ### `from` renders when it is built, not when it plays
192
+
193
+ `from` and `fromTo` default to `immediateRender: true`: the start state is
194
+ written to the element **the moment the tween is created**, not when the
195
+ playhead reaches it. With one tween per element that is exactly what you want —
196
+ nothing flashes before the entrance.
197
+
198
+ Build several against the same element and they all write at once, in order, so
199
+ **the last one wins and holds the element there until the playhead arrives.**
200
+ A helper that adds a beat per state is the usual way in: one call site, run in
201
+ a loop, and a label that stays hidden through every state before the last.
202
+
203
+ ```ts
204
+ const addBeat = (state: string) => {
205
+ tl.addLabel(state).fromTo(
206
+ badge,
207
+ { autoAlpha: 0, y: 6 },
208
+ { autoAlpha: 1, y: 0, immediateRender: false }, // written when it plays
209
+ state,
210
+ );
211
+ };
212
+
213
+ gsap.set(badge, { autoAlpha: 1, y: 0 }); // the first state, set once, on purpose
214
+ STATES.forEach(addBeat);
215
+ ```
216
+
217
+ The conflict is per property. Two tweens on one element that set **different**
218
+ properties — one fading in on `opacity`, another turning on `rotationY` — hide
219
+ nothing, and need no change. The audit reports the shape that does hide:
220
+ `stacked-from`.
@@ -478,12 +478,43 @@ export function gsapContexts(root) {
478
478
  }
479
479
  }
480
480
 
481
- const scopes = findAll(
482
- root,
483
- (node) =>
484
- node.type === "CallExpression" &&
485
- (isHook(node) || calleeName(node) === "gsap.context" || safe.has(bareName(node))),
486
- ).flatMap((call) => call.arguments);
481
+ /**
482
+ * A matchMedia is a context too: `mm.revert()` reverts what its `add`
483
+ * callbacks made. Its callback is recognised on `gsap.matchMedia().add` and on
484
+ * any name the file assigns `gsap.matchMedia()` to, followed when it is handed
485
+ * over by name. Whether the matchMedia itself is ever reverted is
486
+ * `unmanaged-instance`'s to report, not a reason to distrust its callbacks.
487
+ */
488
+ const matchMedias = new Set(
489
+ findAll(
490
+ root,
491
+ (node) =>
492
+ (node.type === "VariableDeclarator" &&
493
+ Boolean(node.init) &&
494
+ calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
495
+ (node.type === "AssignmentExpression" &&
496
+ calleeName(unwrap(node.right)) === "gsap.matchMedia"),
497
+ )
498
+ .map((node) => dottedName(node.type === "VariableDeclarator" ? node.id : node.left))
499
+ .filter(Boolean),
500
+ );
501
+ const isMatchMediaAdd = (node) => {
502
+ if (methodName(node) !== "add") return false;
503
+ const object = unwrap(unwrap(node.callee).object);
504
+ return calleeName(object) === "gsap.matchMedia" || matchMedias.has(dottedName(object));
505
+ };
506
+
507
+ const scopes = [
508
+ ...findAll(
509
+ root,
510
+ (node) =>
511
+ node.type === "CallExpression" &&
512
+ (isHook(node) || calleeName(node) === "gsap.context" || safe.has(bareName(node))),
513
+ ).flatMap((call) => call.arguments),
514
+ ...findAll(root, isMatchMediaAdd).flatMap((call) =>
515
+ call.arguments.map((argument) => resolveFunction(root, argument)).filter(Boolean),
516
+ ),
517
+ ];
487
518
 
488
519
  const counts = new Map();
489
520
  const functions = new Map();
@@ -47,6 +47,10 @@ import {
47
47
 
48
48
  /** What each rule says, in one place. */
49
49
  const HINT = {
50
+ matchMediaNeverRuns:
51
+ "A matchMedia callback runs only while at least one of its named conditions matches, and a visitor with no reduced-motion preference matches none of these — so the branch for them is dead code and nothing animates. Add `motion: \"(prefers-reduced-motion: no-preference)\"` beside it, or use two separate `add` calls.",
52
+ stackedFrom:
53
+ "A `from` or `fromTo` applies its start state the moment it is built. Built more than once against the same element, the last one wins, and holds that element at its start state until the playhead reaches it — invisible, if the start state hides it. Pass `immediateRender: false`, and set the element's first state yourself with `gsap.set`.",
50
54
  orphanTween:
51
55
  "Nothing reverts it on unmount, and React StrictMode runs it twice in development. Move it into the useGSAP body, or wrap the handler in contextSafe. A helper counts as inside only when every use of it is a call from inside a context.",
52
56
  unmanagedInstance:
@@ -1598,4 +1602,355 @@ export const RULES = [
1598
1602
  }));
1599
1603
  },
1600
1604
  },
1605
+ {
1606
+ id: "matchmedia-never-runs",
1607
+ level: "warn",
1608
+ description:
1609
+ "A matchMedia callback that branches on reduced motion, when every condition needs it",
1610
+ /**
1611
+ * `mm.add({ reduced: "(prefers-reduced-motion: reduce)" }, (context) => …)`
1612
+ * whose callback branches on the preference. The callback runs only while a
1613
+ * named condition matches, and a visitor with no preference matches none —
1614
+ * so the branch written for them never runs, and neither does the
1615
+ * animation in it. Found in a head-to-head test on a real hero, where the
1616
+ * whole page shipped without motion, and again in phase 8's first round.
1617
+ *
1618
+ * Branching is the whole signal: reading `conditions`, or asking
1619
+ * `window.matchMedia` about reduced motion inside the callback. A
1620
+ * reduced-only `add` that does not branch is GSAP's documented pattern for
1621
+ * setting end states, meant to run for those visitors only, and is correct.
1622
+ *
1623
+ * The conditions are read when they are written in the call, or kept in a
1624
+ * `const` the file declares once. An imported object, or a `let` that may be
1625
+ * reassigned, cannot be judged here and is left alone.
1626
+ */
1627
+ test(file) {
1628
+ const REDUCE = /prefers-reduced-motion\s*:\s*reduce/i;
1629
+ const PREFERENCE = /prefers-reduced-motion/i;
1630
+
1631
+ /** Names bound to `gsap.matchMedia()` in this file, at declaration or later. */
1632
+ const media = new Set(
1633
+ findAll(
1634
+ file.ast,
1635
+ (node) =>
1636
+ (node.type === "VariableDeclarator" &&
1637
+ node.id.type === "Identifier" &&
1638
+ /**
1639
+ * `let tl;` has no initializer, and `calleeName` does not accept
1640
+ * null. Every fixture initialised its declarations, so 133 passing
1641
+ * tests missed this; the first real project threw on it and took
1642
+ * the whole audit down with it.
1643
+ */
1644
+ Boolean(node.init) &&
1645
+ calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
1646
+ (node.type === "AssignmentExpression" &&
1647
+ node.left.type === "Identifier" &&
1648
+ calleeName(unwrap(node.right)) === "gsap.matchMedia"),
1649
+ ).map((node) => (node.type === "VariableDeclarator" ? node.id.name : node.left.name)),
1650
+ );
1651
+
1652
+ const onMatchMedia = (call) => {
1653
+ const callee = unwrap(call.callee);
1654
+ if (callee?.type !== "MemberExpression") return false;
1655
+ const object = unwrap(callee.object);
1656
+ if (object?.type === "Identifier") return media.has(object.name);
1657
+ return calleeName(object) === "gsap.matchMedia";
1658
+ };
1659
+
1660
+ /** The conditions as written: in the call, or in a `const` the file declares once. */
1661
+ const conditionsOf = (argument) => {
1662
+ const target = unwrap(argument);
1663
+ if (target?.type !== "Identifier") return target;
1664
+ const declarators = findAll(
1665
+ file.ast,
1666
+ (node) =>
1667
+ node.type === "VariableDeclarator" &&
1668
+ node.id.type === "Identifier" &&
1669
+ node.id.name === target.name,
1670
+ );
1671
+ if (declarators.length !== 1) return null;
1672
+ const [declarator] = declarators;
1673
+ return parentOf(file.ast, declarator)?.kind === "const" ? unwrap(declarator.init) : null;
1674
+ };
1675
+
1676
+ /** Every condition needs reduced motion: one query string, or an object of them. */
1677
+ const everyReduces = (conditions) => {
1678
+ const query = staticString(conditions);
1679
+ if (typeof query === "string") return REDUCE.test(query);
1680
+ if (conditions?.type !== "ObjectExpression" || !conditions.properties.length) return false;
1681
+ return conditions.properties.every((property) => {
1682
+ const value = staticString(property.value);
1683
+ return typeof value === "string" && REDUCE.test(value);
1684
+ });
1685
+ };
1686
+
1687
+ /** Does the callback read its conditions, or ask the browser about reduced motion? */
1688
+ const branches = (fn) => {
1689
+ const asksTheBrowser =
1690
+ findAll(fn.body, (node) => {
1691
+ if (node.type !== "CallExpression") return false;
1692
+ const name = calleeName(node);
1693
+ if (name !== "window.matchMedia" && name !== "matchMedia") return false;
1694
+ const query = staticString(node.arguments[0]);
1695
+ return typeof query === "string" && PREFERENCE.test(query);
1696
+ }).length > 0;
1697
+ if (asksTheBrowser) return true;
1698
+
1699
+ const [param] = fn.params;
1700
+ if (!param) return false;
1701
+ if (param.type === "ObjectPattern") {
1702
+ return param.properties.some((p) => keyName(p) === "conditions");
1703
+ }
1704
+ if (param.type !== "Identifier") return false;
1705
+ return findAll(fn.body, (node) => {
1706
+ const member = unwrap(node);
1707
+ return (
1708
+ member?.type === "MemberExpression" &&
1709
+ !member.computed &&
1710
+ unwrap(member.object)?.type === "Identifier" &&
1711
+ unwrap(member.object).name === param.name &&
1712
+ member.property.name === "conditions"
1713
+ );
1714
+ }).length > 0;
1715
+ };
1716
+
1717
+ return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node))
1718
+ .filter((call) => {
1719
+ if (!everyReduces(conditionsOf(call.arguments[0]))) return false;
1720
+ const callback = resolveFunction(file.ast, call.arguments[1]);
1721
+ return Boolean(callback) && branches(callback);
1722
+ })
1723
+ .map((call) => ({
1724
+ index: call.start,
1725
+ message:
1726
+ "This matchMedia callback branches on reduced motion, but every condition needs reduced motion — so it never runs for a visitor without that preference.",
1727
+ hint: HINT.matchMediaNeverRuns,
1728
+ }));
1729
+ },
1730
+ },
1731
+ {
1732
+ id: "stacked-from",
1733
+ level: "warn",
1734
+ description:
1735
+ "A timeline's `from` or `fromTo` built more than once against the same target",
1736
+ /**
1737
+ * A `from` or `fromTo` on a timeline, without `immediateRender: false`,
1738
+ * that is built more than once against the same element: written out
1739
+ * twice, or inside a loop, or inside a helper a loop calls. Each applies
1740
+ * its start state the moment it is built, so the last one wins and holds
1741
+ * the element hidden until the playhead arrives. Found in a real hero,
1742
+ * where one call site in a helper ran six times and a badge stayed
1743
+ * invisible through the whole first state.
1744
+ *
1745
+ * Two shapes must stay quiet, and both sat in that same file. A target that
1746
+ * varies with the loop — `forEach((card) => tl.from(card, …))` — is a new
1747
+ * element each pass. And a tween inside a callback that is only passed
1748
+ * along is not known to run each pass, so it is not reported.
1749
+ */
1750
+ test(file) {
1751
+ const LOOPS = ["ForStatement", "ForOfStatement", "ForInStatement", "WhileStatement", "DoWhileStatement"];
1752
+ const isLoopCallback = (fn) => {
1753
+ const parent = parentOf(file.ast, fn);
1754
+ return (
1755
+ parent?.type === "CallExpression" &&
1756
+ unwrap(parent.arguments[0]) === fn &&
1757
+ ["forEach", "map", "flatMap"].includes(methodName(parent))
1758
+ );
1759
+ };
1760
+ const renders = (call) =>
1761
+ !call.arguments.some((arg) => {
1762
+ const object = unwrap(arg);
1763
+ return (
1764
+ object?.type === "ObjectExpression" &&
1765
+ object.properties.some((p) => {
1766
+ const value = unwrap(p.value);
1767
+ return keyName(p) === "immediateRender" && value?.type === "Literal" && value.value === false;
1768
+ })
1769
+ );
1770
+ });
1771
+
1772
+ const paramNames = (fn) =>
1773
+ fn.params.flatMap((param) =>
1774
+ findAll(param, (n) => n.type === "Identifier").map((n) => n.name),
1775
+ );
1776
+ const loopNames = (loop) => {
1777
+ const left = loop.left ?? loop.init;
1778
+ return left ? findAll(left, (n) => n.type === "Identifier").map((n) => n.name) : [];
1779
+ };
1780
+
1781
+ /** Is this node inside a loop body or a loop callback? */
1782
+ const inLoop = (node) =>
1783
+ ancestorsOf(file.ast, node).some((a) => LOOPS.includes(a.type) || (isFunction(a) && isLoopCallback(a)));
1784
+
1785
+ /**
1786
+ * Walk out from the tween. The names that change each pass are gathered on
1787
+ * the way; the walk stops at the first function that is neither a loop's
1788
+ * own callback nor a named helper some loop calls, because nothing then
1789
+ * says it runs more than once.
1790
+ */
1791
+ /**
1792
+ * Does a guard on the way out pick exactly one pass? `key === "money"`
1793
+ * does; `key !== "brand"` runs on most passes and still stacks. Only an
1794
+ * equality against a literal, on a name that changes each pass, counts —
1795
+ * the version of a real hero built without the skill guarded its one
1796
+ * per-state tween that way, and a first version of this rule credited it
1797
+ * with a bug it did not have.
1798
+ */
1799
+ const selectsOnePass = (guards, varying) =>
1800
+ guards.some((test) =>
1801
+ findAll(test, (node) => {
1802
+ if (node.type !== "BinaryExpression" || !["===", "=="].includes(node.operator)) return false;
1803
+ const sides = [unwrap(node.left), unwrap(node.right)];
1804
+ const name = sides.find((side) => side?.type === "Identifier" && varying.has(side.name));
1805
+ const literal = sides.find((side) => side?.type === "Literal");
1806
+ return Boolean(name && literal);
1807
+ }).length > 0,
1808
+ );
1809
+
1810
+ /** Names that change each pass: its variable, and whatever its body declares. */
1811
+ const declaredIn = (body) =>
1812
+ body
1813
+ ? findAll(body, (n) => n.type === "VariableDeclarator" && n.id.type === "Identifier").map((n) => n.id.name)
1814
+ : [];
1815
+
1816
+ const repetition = (tween, timeline) => {
1817
+ const varying = new Set();
1818
+ const guards = [];
1819
+ const settle = (loopBody) => {
1820
+ declaredIn(loopBody).forEach((n) => varying.add(n));
1821
+ return selectsOnePass(guards, varying) ? null : varying;
1822
+ };
1823
+ let inner = tween;
1824
+ for (const ancestor of ancestorsOf(file.ast, tween)) {
1825
+ if (contains(ancestor, timeline)) return null;
1826
+ if ((ancestor.type === "IfStatement" || ancestor.type === "ConditionalExpression") && contains(ancestor.consequent, inner)) {
1827
+ guards.push(ancestor.test);
1828
+ }
1829
+ inner = ancestor;
1830
+ if (LOOPS.includes(ancestor.type)) {
1831
+ loopNames(ancestor).forEach((n) => varying.add(n));
1832
+ return settle(ancestor.body);
1833
+ }
1834
+ if (!isFunction(ancestor)) continue;
1835
+ paramNames(ancestor).forEach((n) => varying.add(n));
1836
+ if (isLoopCallback(ancestor)) return settle(ancestor.body);
1837
+
1838
+ const holder = parentOf(file.ast, ancestor);
1839
+ const name =
1840
+ holder?.type === "VariableDeclarator" && holder.id.type === "Identifier"
1841
+ ? holder.id.name
1842
+ : ancestor.type === "FunctionDeclaration"
1843
+ ? ancestor.id?.name
1844
+ : null;
1845
+ if (!name) return null;
1846
+ const calledInLoop = findAll(
1847
+ file.ast,
1848
+ (n) =>
1849
+ n.type === "CallExpression" &&
1850
+ unwrap(n.callee)?.type === "Identifier" &&
1851
+ unwrap(n.callee).name === name &&
1852
+ inLoop(n),
1853
+ ).length > 0;
1854
+ return calledInLoop ? varying : null;
1855
+ }
1856
+ return null;
1857
+ };
1858
+
1859
+ /**
1860
+ * The properties a tween's start state sets, or null when they cannot be
1861
+ * read. "Last one wins" is per property: two from-tweens on one element
1862
+ * that set different properties hide nothing, and a real project relied on
1863
+ * exactly that — a line flying in on `opacity` and `y` while a second tween
1864
+ * turned it on `rotationY`. `autoAlpha` sets opacity, so they count as one.
1865
+ */
1866
+ const CONFIG = new Set([
1867
+ "duration", "delay", "ease", "stagger", "repeat", "yoyo", "repeatDelay",
1868
+ "immediateRender", "overwrite", "paused", "id", "data", "callbackScope",
1869
+ "onStart", "onUpdate", "onComplete", "onRepeat", "onReverseComplete", "scrollTrigger",
1870
+ ]);
1871
+ const startKeys = (tween) => {
1872
+ const vars = unwrap(tween.arguments[1]);
1873
+ if (vars?.type !== "ObjectExpression") return null;
1874
+ if (vars.properties.some((p) => p.type !== "Property" || p.computed)) return null;
1875
+ return new Set(
1876
+ vars.properties
1877
+ .map((p) => keyName(p))
1878
+ .filter((key) => key && !CONFIG.has(key))
1879
+ .map((key) => (key === "autoAlpha" ? "opacity" : key)),
1880
+ );
1881
+ };
1882
+ const overlap = (a, b) => {
1883
+ if (!a || !b) return false;
1884
+ for (const key of a) if (b.has(key)) return true;
1885
+ return false;
1886
+ };
1887
+
1888
+ /**
1889
+ * Two calls that cannot both run: opposite branches of one `if` or one
1890
+ * ternary, or two cases of one `switch`. A real project built a fade-in in
1891
+ * the `if` and a fade-out in the `else` against the same element, and only
1892
+ * ever ran one of them.
1893
+ */
1894
+ const exclusive = (a, b) => {
1895
+ const upB = new Set(ancestorsOf(file.ast, b));
1896
+ for (const node of ancestorsOf(file.ast, a)) {
1897
+ if (!upB.has(node)) continue;
1898
+ if ((node.type === "IfStatement" || node.type === "ConditionalExpression") && node.alternate) {
1899
+ if (contains(node.alternate, a) !== contains(node.alternate, b)) return true;
1900
+ }
1901
+ if (node.type === "SwitchStatement") {
1902
+ const caseOf = (n) => node.cases.find((c) => contains(c, n));
1903
+ if (caseOf(a) && caseOf(a) !== caseOf(b)) return true;
1904
+ }
1905
+ }
1906
+ return false;
1907
+ };
1908
+
1909
+ const mentions = (text, names) =>
1910
+ [...names].some((name) => new RegExp(`(^|[^\\w$])${name.replace(/\$/g, "\\$")}([^\\w$]|$)`).test(text));
1911
+
1912
+ const findings = [];
1913
+ const timelines = findAll(file.ast, (node) => calleeName(node) === "gsap.timeline");
1914
+ for (const timeline of timelines) {
1915
+ const tweens = timelineLinks(file.ast, timeline).filter(
1916
+ (call) => ["from", "fromTo"].includes(methodName(call)) && call.arguments[0] && renders(call),
1917
+ );
1918
+ const reported = new Set();
1919
+ /** Earlier from-tweens on each target, to compare a later one against. */
1920
+ const earlier = new Map();
1921
+
1922
+ for (const tween of tweens) {
1923
+ const target = unwrap(tween.arguments[0]);
1924
+ const text = file.raw.slice(target.start, target.end).replace(/\s+/g, "");
1925
+ if (reported.has(text)) continue;
1926
+
1927
+ /** One call site built repeatedly always shares its own properties. */
1928
+ const varying = repetition(tween, timeline);
1929
+ const repeatedInPlace = varying !== null && !mentions(text, varying);
1930
+
1931
+ const keys = startKeys(tween);
1932
+ const before = earlier.get(text) ?? [];
1933
+ const writtenTwice = before.some(
1934
+ (other) => !exclusive(other.tween, tween) && overlap(other.keys, keys),
1935
+ );
1936
+ earlier.set(text, [...before, { tween, keys }]);
1937
+
1938
+ if (repeatedInPlace || writtenTwice) {
1939
+ reported.add(text);
1940
+ findings.push({
1941
+ /**
1942
+ * The method's own position, not the call's. A chained call starts
1943
+ * where its chain starts, so `tween.start` pointed a real report 25
1944
+ * lines above the `fromTo` it was about.
1945
+ */
1946
+ index: unwrap(tween.callee)?.property?.start ?? tween.start,
1947
+ message: `\`${text}\` gets a \`${methodName(tween)}\` more than once on this timeline, and each applies its start state as soon as it is built.`,
1948
+ hint: HINT.stackedFrom,
1949
+ });
1950
+ }
1951
+ }
1952
+ }
1953
+ return findings;
1954
+ },
1955
+ },
1601
1956
  ];