Skip to content

Commit 889e9f0

Browse files
fix(core): per-property-group keyframe foundations (#1354)
Add PropertyGroupName type system (position/scale/size/rotation/visual/other), PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup functions. Parser generates group-aware animation IDs, resolves position strings (+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs across all mutations.
1 parent 8802c3f commit 889e9f0

10 files changed

Lines changed: 835 additions & 87 deletions

packages/core/src/parsers/__goldens__/complex.parsed.json

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,62 @@
11
{
22
"animations": [
33
{
4-
"targetSelector": ".ambient-line",
4+
"targetSelector": ".headline span",
55
"method": "from",
6-
"position": 0.16,
6+
"position": 0.05,
77
"properties": {
8-
"scaleX": 0,
8+
"y": 46,
99
"opacity": 0
1010
},
11-
"duration": 0.42,
11+
"duration": 0.38,
12+
"ease": "back.out(1.35)",
1213
"extras": {
13-
"stagger": "__raw:0.08"
14+
"stagger": "__raw:0.055"
1415
},
15-
"id": ".ambient-line-from-160"
16+
"resolvedStart": 0.05,
17+
"id": ".headline span-from-50"
1618
},
1719
{
18-
"targetSelector": ".ambient-word",
20+
"targetSelector": ".headline .sub",
1921
"method": "from",
20-
"position": 0.08,
22+
"position": 0.2,
2123
"properties": {
22-
"scale": 0.92,
24+
"y": 20,
2325
"opacity": 0
2426
},
25-
"duration": 0.5,
26-
"id": ".ambient-word-from-80"
27+
"duration": 0.28,
28+
"ease": "power3.out",
29+
"resolvedStart": 0.2,
30+
"id": ".headline .sub-from-200"
2731
},
2832
{
29-
"targetSelector": ".headline .sub",
33+
"targetSelector": ".ambient-word",
3034
"method": "from",
31-
"position": 0.2,
35+
"position": 0.08,
3236
"properties": {
33-
"y": 20,
37+
"scale": 0.92,
3438
"opacity": 0
3539
},
36-
"duration": 0.28,
37-
"id": ".headline .sub-from-200"
40+
"duration": 0.5,
41+
"ease": "power3.out",
42+
"resolvedStart": 0.08,
43+
"id": ".ambient-word-from-80"
3844
},
3945
{
40-
"targetSelector": ".headline span",
46+
"targetSelector": ".ambient-line",
4147
"method": "from",
42-
"position": 0.05,
48+
"position": 0.16,
4349
"properties": {
44-
"y": 46,
50+
"scaleX": 0,
4551
"opacity": 0
4652
},
47-
"duration": 0.38,
48-
"ease": "back.out(1.35)",
53+
"duration": 0.42,
54+
"ease": "power3.out",
4955
"extras": {
50-
"stagger": "__raw:0.055"
56+
"stagger": "__raw:0.08"
5157
},
52-
"id": ".headline span-from-50"
58+
"resolvedStart": 0.16,
59+
"id": ".ambient-line-from-160"
5360
}
5461
],
5562
"timelineVar": "tl",

packages/core/src/parsers/__goldens__/complex.serialized.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
gsap.defaults({ force3D: true });
44
const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } });
55
tl.from(".headline span", { y: 46, opacity: 0, duration: 0.38, ease: "back.out(1.35)", stagger: 0.055 }, 0.05);
6-
tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08);
7-
tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, stagger: 0.08 }, 0.16);
8-
tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2);
6+
tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.08);
7+
tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, ease: "power3.out", stagger: 0.08 }, 0.16);
8+
tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28, ease: "power3.out" }, 0.2);
99
window.__timelines["vpn-youtube-spot"] = tl;
1010

packages/core/src/parsers/__goldens__/fromto.parsed.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
},
1515
"duration": 0.6,
1616
"ease": "power3.out",
17+
"resolvedStart": 0.1,
1718
"id": "#hero-fromTo-100"
1819
},
1920
{
@@ -29,6 +30,7 @@
2930
"opacity": 0
3031
},
3132
"duration": 0.45,
33+
"resolvedStart": 0.5,
3234
"id": "#caption-fromTo-500"
3335
}
3436
],

packages/core/src/parsers/__goldens__/minimal.parsed.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"duration": 0.5,
1212
"ease": "power3.out",
13+
"resolvedStart": 0.2,
1314
"id": "#notification-to-200"
1415
},
1516
{
@@ -22,6 +23,7 @@
2223
},
2324
"duration": 0.3,
2425
"ease": "power3.in",
26+
"resolvedStart": 4.2,
2527
"id": "#notification-to-4200"
2628
}
2729
],

packages/core/src/parsers/__goldens__/moderate.parsed.json

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"duration": 0.5,
1212
"ease": "power3.out",
13+
"resolvedStart": 0.1,
1314
"id": "#card-to-100"
1415
},
1516
{
@@ -21,7 +22,9 @@
2122
},
2223
"duration": 0.15,
2324
"ease": "power2.out",
24-
"id": "#subscribe-btn-to-1000"
25+
"propertyGroup": "scale",
26+
"resolvedStart": 1,
27+
"id": "#subscribe-btn-to-1000-scale"
2528
},
2629
{
2730
"targetSelector": "#subscribe-btn",
@@ -32,7 +35,9 @@
3235
},
3336
"duration": 0.4,
3437
"ease": "elastic.out(1, 0.4)",
35-
"id": "#subscribe-btn-to-1150"
38+
"propertyGroup": "scale",
39+
"resolvedStart": 1.15,
40+
"id": "#subscribe-btn-to-1150-scale"
3641
},
3742
{
3843
"targetSelector": "#btn-subscribe",
@@ -43,7 +48,9 @@
4348
},
4449
"duration": 0.08,
4550
"ease": "none",
46-
"id": "#btn-subscribe-to-1150"
51+
"propertyGroup": "visual",
52+
"resolvedStart": 1.15,
53+
"id": "#btn-subscribe-to-1150-visual"
4754
},
4855
{
4956
"targetSelector": "#btn-subscribed",
@@ -54,7 +61,9 @@
5461
},
5562
"duration": 0.08,
5663
"ease": "none",
57-
"id": "#btn-subscribed-to-1180"
64+
"propertyGroup": "visual",
65+
"resolvedStart": 1.18,
66+
"id": "#btn-subscribed-to-1180-visual"
5867
},
5968
{
6069
"targetSelector": "#card",
@@ -66,6 +75,7 @@
6675
},
6776
"duration": 0.25,
6877
"ease": "power3.in",
78+
"resolvedStart": 3.8,
6979
"id": "#card-to-3800"
7080
}
7181
],

packages/core/src/parsers/gsapConstants.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,46 @@ export const SUPPORTED_PROPS = [
4545
"innerText",
4646
];
4747

48+
// ── Property Groups ─────────────────────────────────────────────────────────
49+
// Each group maps to an independent GSAP tween so editing one property
50+
// (e.g. drag → x/y) never contaminates another (e.g. scale, rotation).
51+
52+
export type PropertyGroupName = "position" | "scale" | "size" | "rotation" | "visual" | "other";
53+
54+
export const PROPERTY_GROUPS: Record<PropertyGroupName, ReadonlySet<string>> = {
55+
position: new Set(["x", "y", "xPercent", "yPercent"]),
56+
scale: new Set(["scale", "scaleX", "scaleY"]),
57+
size: new Set(["width", "height"]),
58+
rotation: new Set(["rotation", "skewX", "skewY"]),
59+
visual: new Set(["opacity", "autoAlpha"]),
60+
other: new Set<string>(),
61+
};
62+
63+
const PROP_TO_GROUP = new Map<string, PropertyGroupName>();
64+
for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [
65+
PropertyGroupName,
66+
ReadonlySet<string>,
67+
][]) {
68+
for (const p of props) PROP_TO_GROUP.set(p, group);
69+
}
70+
71+
export function classifyPropertyGroup(prop: string): PropertyGroupName {
72+
return PROP_TO_GROUP.get(prop) ?? "other";
73+
}
74+
75+
export function classifyTweenPropertyGroup(
76+
properties: Record<string, unknown>,
77+
): PropertyGroupName | undefined {
78+
const groups = new Set<PropertyGroupName>();
79+
for (const key of Object.keys(properties)) {
80+
if (key === "transformOrigin") continue;
81+
const g = classifyPropertyGroup(key);
82+
groups.add(g);
83+
}
84+
if (groups.size === 1) return groups.values().next().value;
85+
return undefined;
86+
}
87+
4888
export const SUPPORTED_EASES = [
4989
"none",
5090
"power1.in",

packages/core/src/parsers/gsapParser.stress.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -658,9 +658,9 @@ describe("14. ID collision", () => {
658658
const ids = result.animations.map((a) => a.id);
659659
// All IDs must be unique
660660
expect(new Set(ids).size).toBe(3);
661-
expect(ids[0]).toBe("#el-to-0");
662-
expect(ids[1]).toBe("#el-to-0-2");
663-
expect(ids[2]).toBe("#el-to-0-3");
661+
expect(ids[0]).toBe("#el-to-0-visual");
662+
expect(ids[1]).toBe("#el-to-0-position");
663+
expect(ids[2]).toBe("#el-to-0-position-2");
664664
});
665665

666666
it("disambiguated IDs are stable across parses", () => {
@@ -932,7 +932,7 @@ describe("Additional edge cases", () => {
932932
`;
933933
const result = parseGsapScript(script);
934934
// ID uses Math.round(position * 1000) for numeric positions
935-
expect(result.animations[0].id).toBe("#el-to--2500");
935+
expect(result.animations[0].id).toBe("#el-to--2500-position");
936936
});
937937

938938
it("fromTo with no position arg defaults to 0", () => {

0 commit comments

Comments
 (0)