@mehshekari/gsap-motion 4.2.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.2.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.2.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.2.0"
7
+ version: "4.2.1"
8
8
  verified-gsap: "3.15"
9
9
  verified-gsap-react: "2.1"
10
10
  ---
@@ -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();
@@ -1606,38 +1606,47 @@ export const RULES = [
1606
1606
  id: "matchmedia-never-runs",
1607
1607
  level: "warn",
1608
1608
  description:
1609
- "A matchMedia callback that branches on its conditions, when every condition needs reduced motion",
1609
+ "A matchMedia callback that branches on reduced motion, when every condition needs it",
1610
1610
  /**
1611
1611
  * `mm.add({ reduced: "(prefers-reduced-motion: reduce)" }, (context) => …)`
1612
- * whose callback reads `context.conditions`. The callback runs only while a
1612
+ * whose callback branches on the preference. The callback runs only while a
1613
1613
  * named condition matches, and a visitor with no preference matches none —
1614
1614
  * so the branch written for them never runs, and neither does the
1615
1615
  * animation in it. Found in a head-to-head test on a real hero, where the
1616
- * whole page shipped without motion.
1616
+ * whole page shipped without motion, and again in phase 8's first round.
1617
1617
  *
1618
- * The reading of `conditions` is the whole signal. A reduced-only `add`
1619
- * that does not branch is GSAP's documented pattern for setting end states,
1620
- * meant to run for those visitors only, and is correct.
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.
1621
1626
  */
1622
1627
  test(file) {
1623
1628
  const REDUCE = /prefers-reduced-motion\s*:\s*reduce/i;
1629
+ const PREFERENCE = /prefers-reduced-motion/i;
1624
1630
 
1625
- /** Names bound to `gsap.matchMedia()` in this file. */
1631
+ /** Names bound to `gsap.matchMedia()` in this file, at declaration or later. */
1626
1632
  const media = new Set(
1627
1633
  findAll(
1628
1634
  file.ast,
1629
1635
  (node) =>
1630
- node.type === "VariableDeclarator" &&
1631
- node.id.type === "Identifier" &&
1632
- /**
1633
- * `let tl;` has no initializer, and `calleeName` does not accept
1634
- * null. Every fixture initialised its declarations, so 133 passing
1635
- * tests missed this; the first real project threw on it and took
1636
- * the whole audit down with it.
1637
- */
1638
- Boolean(node.init) &&
1639
- calleeName(unwrap(node.init)) === "gsap.matchMedia",
1640
- ).map((node) => node.id.name),
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)),
1641
1650
  );
1642
1651
 
1643
1652
  const onMatchMedia = (call) => {
@@ -1648,8 +1657,45 @@ export const RULES = [
1648
1657
  return calleeName(object) === "gsap.matchMedia";
1649
1658
  };
1650
1659
 
1651
- /** Does the callback read the conditions it was handed? */
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? */
1652
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
+
1653
1699
  const [param] = fn.params;
1654
1700
  if (!param) return false;
1655
1701
  if (param.type === "ObjectPattern") {
@@ -1670,20 +1716,14 @@ export const RULES = [
1670
1716
 
1671
1717
  return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node))
1672
1718
  .filter((call) => {
1673
- const conditions = unwrap(call.arguments[0]);
1674
- if (conditions?.type !== "ObjectExpression" || !conditions.properties.length) return false;
1675
- const everyReduces = conditions.properties.every((property) => {
1676
- const value = staticString(property.value);
1677
- return typeof value === "string" && REDUCE.test(value);
1678
- });
1679
- if (!everyReduces) return false;
1719
+ if (!everyReduces(conditionsOf(call.arguments[0]))) return false;
1680
1720
  const callback = resolveFunction(file.ast, call.arguments[1]);
1681
1721
  return Boolean(callback) && branches(callback);
1682
1722
  })
1683
1723
  .map((call) => ({
1684
1724
  index: call.start,
1685
1725
  message:
1686
- "This matchMedia callback branches on its conditions, but every condition needs reduced motion — so it never runs for a visitor without that preference.",
1726
+ "This matchMedia callback branches on reduced motion, but every condition needs reduced motion — so it never runs for a visitor without that preference.",
1687
1727
  hint: HINT.matchMediaNeverRuns,
1688
1728
  }));
1689
1729
  },