Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
f079020
feat(studio): stage 7 step 3c — sdk cutover for inline-style ops
vanceingalls Jun 17, 2026
0d89bcb
fix(studio): force-reload sdk session after undo/redo bypasses suppre…
vanceingalls Jun 15, 2026
a7d93d4
feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (da…
vanceingalls Jun 15, 2026
39c2cac
fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was u…
vanceingalls Jun 15, 2026
4d77e7f
feat(studio): route element delete through SDK removeElement (§3.1)
vanceingalls Jun 15, 2026
0cd9a68
feat(studio): route timeline trim/move through SDK setTiming (§3.2)
vanceingalls Jun 15, 2026
2fcffe6
chore(studio): document CSS-path position cut-over, GSAP-path intenti…
vanceingalls Jun 15, 2026
37a7cef
feat(studio): route GSAP tween add/update/delete through SDK (§3.5 PR1)
vanceingalls Jun 15, 2026
ec66975
feat(studio): route GSAP keyframe add through SDK (§3.5 PR2)
vanceingalls Jun 15, 2026
f2458af
fix(studio,core): resolve SDK-cutover review findings
vanceingalls Jun 15, 2026
eb381ec
feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection)
vanceingalls Jun 16, 2026
ec90fd9
feat(sdk): ws-a2 — applyDraft/commitPreview/cancelPreview → moveEleme…
vanceingalls Jun 16, 2026
884b244
feat(sdk,studio): ws-4 — add history:false option; disable unused sdk…
vanceingalls Jun 16, 2026
fe1de56
feat(sdk,studio): ws-1.1 — add set method to GsapTweenSpec; route add…
vanceingalls Jun 16, 2026
8437d92
feat(sdk,studio): ws-1.2 — percentage-based removeGsapKeyframe
vanceingalls Jun 16, 2026
c5df306
feat(sdk,studio): ws-1.3 — removeGsapProperty SDK op + Studio hook cu…
vanceingalls Jun 16, 2026
0020972
feat(sdk,studio): ws-1.4 — deleteAllForSelector SDK op + Studio hook …
vanceingalls Jun 16, 2026
2c368d1
fix(core): cascade-remove GSAP tweens in removeElementFromHtml (WS-2)
vanceingalls Jun 16, 2026
a60eef0
feat(sdk,core): ws-3 prerequisites — acorn keyframe-collapse foundati…
vanceingalls Jun 16, 2026
761221e
feat(sdk,core): ws-3 — convertToKeyframes acorn port + SDK op + Studi…
vanceingalls Jun 16, 2026
dc60436
feat(sdk,core): ws-3 — materializeKeyframes + splitIntoPropertyGroups…
vanceingalls Jun 16, 2026
a29a0c6
feat(sdk,core): ws-3 — splitAnimationsInScript acorn port + SDK op
vanceingalls Jun 16, 2026
262854c
feat(sdk): stage 6 — arc path ops (setArcPath, updateArcSegment, remo…
vanceingalls Jun 16, 2026
1aa31f5
feat(sdk,core): ws-3 — unrollDynamicAnimations acorn port + SDK op
vanceingalls Jun 16, 2026
2ca772a
test(core): recast-vs-acorn parity + acorn fixes for arc/unroll/keyfr…
vanceingalls Jun 17, 2026
db16b97
feat(core): port shiftPositions/scalePositions to acorn writer (WS-3.F)
vanceingalls Jun 17, 2026
fb759b5
fix(studio): restore timeline move/resize fallback parity (review #1466)
vanceingalls Jun 17, 2026
8708720
fix(sdk): retire duplicate removeGsapKeyframe keyframeIndex variant (…
vanceingalls Jun 17, 2026
18ea1c3
fix(studio): gate ALL cutover persist paths on the flag — true dark l…
vanceingalls Jun 17, 2026
7d7732c
fix(core,sdk): correct 8 GSAP write-path review findings (#1539)
vanceingalls Jun 17, 2026
bcc0a44
fix(studio): SDK cutover review fixes — merge tween props, stabilize …
vanceingalls Jun 17, 2026
fd002ad
refactor(core): extract split/collapse helpers to satisfy no-fallow-i…
vanceingalls Jun 17, 2026
508be31
test(studio): pin dark-launch flag-gate contract (review #1539, Rames…
vanceingalls Jun 17, 2026
057e0d0
fix(studio): leading flag-gate on sdkGsapTweenPersist (review #1539 n…
vanceingalls Jun 17, 2026
f9bdd18
fix(core): unroll-preservation regressions — non-for loops + AST inde…
vanceingalls Jun 17, 2026
5b3db8d
fix(sdk,core): unrollDynamicAnimations rejects empty element list (R1…
vanceingalls Jun 17, 2026
5dcd168
perf(sdk): cache draft element in applyDraft, drop HTMLElement casts …
vanceingalls Jun 17, 2026
3e17dfa
fix(sdk,core): round-3 correctness — unroll AST safety, single-dispat…
vanceingalls Jun 17, 2026
d924a9b
fix(core): stripGsapForId re-parses per removal so all tweens for a d…
vanceingalls Jun 17, 2026
86afc0a
fix(core): gsap writer — keyframe ease routing, convert preserves del…
vanceingalls Jun 17, 2026
2dcfd3a
fix(sdk): handleSetTiming #domId + data-duration sync; validateOp res…
vanceingalls Jun 17, 2026
4f1eb96
refactor(core,sdk): name the acorn-node type alias; keyToPath round-t…
vanceingalls Jun 17, 2026
6c2d668
fix(sdk): cascadeRemoveAnimations re-parses per removal (R4 — SDK twi…
vanceingalls Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 9 additions & 57 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type ParsedGsap,
serializeValue as valueToCode,
safeJsKey as safeKey,
resolveConversionProps,
} from "./gsapSerialize";

export type {
Expand Down Expand Up @@ -2009,62 +2010,6 @@ export function updateKeyframeInScript(
return recast.print(loc.parsed.ast).code;
}

/** Resolve from/to property maps for a tween being converted to keyframes. */
const CSS_IDENTITY: Record<string, number> = {
opacity: 1,
autoAlpha: 1,
scale: 1,
scaleX: 1,
scaleY: 1,
};

function cssIdentityValue(prop: string): number {
return CSS_IDENTITY[prop] ?? 0;
}

/**
* Resolve the 0% (from) and 100% (to) property maps for a tween being
* converted to percentage keyframes.
*
* @param resolvedFromValues — Despite the "from" in the name (historical), these
* are runtime-captured DOM values that override the conversion endpoint:
* - For to(): overrides fromProps (the 0% state / where the element is now).
* - For from(): overrides toProps (the 100% state / where the element rests).
* - For fromTo(): merges into toProps (the 100% endpoint the user is editing).
*/
function resolveConversionProps(
anim: GsapAnimation,
resolvedFromValues?: Record<string, number | string>,
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
if (anim.method === "to") {
const identityFrom: Record<string, number | string> = {};
for (const [key, val] of Object.entries(anim.properties)) {
if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val;
}
const fromProps = resolvedFromValues
? { ...identityFrom, ...resolvedFromValues }
: identityFrom;
return { fromProps, toProps: { ...anim.properties } };
}
if (anim.method === "from") {
const identityTo: Record<string, number | string> = {};
for (const [key, val] of Object.entries(anim.properties)) {
if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val;
}
const toProps = resolvedFromValues ? { ...identityTo, ...resolvedFromValues } : identityTo;
return { fromProps: { ...anim.properties }, toProps };
}
// fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state),
// anim.properties = toVars (100% state). resolvedFromValues contains the
// current DOM position from a drag — it represents the NEW destination, so
// it merges into toProps (the 100% endpoint the user is editing), NOT into
// fromProps. This is intentional and not inverted.
const toProps = resolvedFromValues
? { ...anim.properties, ...resolvedFromValues }
: { ...anim.properties };
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
}

/** Strip editable properties and ease/keyframes keys from a varsArg. */
function stripEditableAndEase(varsArg: AstNode): void {
// ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it —
Expand Down Expand Up @@ -2225,9 +2170,16 @@ function buildMotionPathObjectCode(config: {
}): string {
const { waypoints, segments, autoRotate } = config;
const hasExplicitControlPoints = segments.some((s) => s.cp1 && s.cp2);
// The simple `path` array supports only one scalar curviness for the whole
// path, so per-segment curviness must use the cubic form (curviness baked into
// each segment's control points). Without this, the simple branch serializes
// only segments[0].curviness and silently drops every other segment's curve.
const curvinessVaries = segments.some(
(s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1),
);

let pathEntries: string[];
if (hasExplicitControlPoints && waypoints.length >= 2) {
if ((hasExplicitControlPoints || curvinessVaries) && waypoints.length >= 2) {
// type: "cubic" — interleave control points: [anchor, cp1, cp2, anchor, ...]
pathEntries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`];
for (let i = 0; i < segments.length; i++) {
Expand Down
167 changes: 163 additions & 4 deletions packages/core/src/parsers/gsapSerialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,20 @@ export function buildArcPath(
autoRotate: boolean | number,
isCubic: boolean,
): MotionPathShape | undefined {
if (coords.length < 2) return undefined;
const first = coords[0];
if (coords.length < 2 || !first) return undefined;
const segments: ArcPathSegment[] = [];
let waypoints: Array<{ x: number; y: number }>;
if (isCubic && coords.length >= 4) {
// coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...].
waypoints = [coords[0]!];
waypoints = [first];
for (let i = 1; i + 2 < coords.length; i += 3) {
waypoints.push(coords[i + 2]!);
segments.push({ curviness, cp1: coords[i]!, cp2: coords[i + 1]! });
const cp1 = coords[i];
const cp2 = coords[i + 1];
const anchor = coords[i + 2];
if (!cp1 || !cp2 || !anchor) continue;
waypoints.push(anchor);
segments.push({ curviness, cp1, cp2 });
}
} else {
waypoints = coords;
Expand Down Expand Up @@ -413,3 +418,157 @@ export function gsapAnimationsToKeyframes(
.filter((kf): kf is NonNullable<typeof kf> => kf !== null)
);
}

// ── Keyframe-conversion transforms (pure; shared by recast + acorn writers) ────

/**
* CSS identity values for properties whose "rest" state isn't 0 — used to
* synthesize the missing endpoint when converting a flat tween to keyframes.
*/
const CSS_IDENTITY: Record<string, number> = {
opacity: 1,
autoAlpha: 1,
scale: 1,
scaleX: 1,
scaleY: 1,
};

function cssIdentityValue(prop: string): number {
return CSS_IDENTITY[prop] ?? 0;
}

/** Build the identity-endpoint map for a flat tween's properties. */
function buildIdentityMap(props: Record<string, number | string>): Record<string, number | string> {
const identity: Record<string, number | string> = {};
for (const [key, val] of Object.entries(props)) {
if (val != null) identity[key] = typeof val === "number" ? cssIdentityValue(key) : val;
}
return identity;
}

/**
* Resolve the 0% (from) and 100% (to) property maps for a tween being
* converted to percentage keyframes.
*
* @param resolvedFromValues — Despite the "from" in the name (historical), these
* are runtime-captured DOM values that override the conversion endpoint:
* - For to(): overrides fromProps (the 0% state / where the element is now).
* - For from(): overrides toProps (the 100% state / where the element rests).
* - For fromTo(): merges into toProps (the 100% endpoint the user is editing).
*/
export function resolveConversionProps(
anim: GsapAnimation,
resolvedFromValues?: Record<string, number | string>,
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
if (anim.method === "to") {
const identity = buildIdentityMap(anim.properties);
const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity;
return { fromProps, toProps: { ...anim.properties } };
}
if (anim.method === "from") {
const identity = buildIdentityMap(anim.properties);
const toProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity;
return { fromProps: { ...anim.properties }, toProps };
}
// fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state),
// anim.properties = toVars (100% state). resolvedFromValues contains the
// current DOM position from a drag — it represents the NEW destination, so
// it merges into toProps (the 100% endpoint the user is editing), NOT into
// fromProps. This is intentional and not inverted.
const toProps = resolvedFromValues
? { ...anim.properties, ...resolvedFromValues }
: { ...anim.properties };
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
}

// ── Arc path serialization helpers (shared by recast + acorn writers) ─────────

function numericXY(props: Record<string, number | string>): { x: number; y: number } | null {
const vx = props.x;
const vy = props.y;
return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null;
}

export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> {
const keyframeWps = (anim.keyframes?.keyframes ?? [])
.map((kf) => numericXY(kf.properties))
.filter((pt): pt is { x: number; y: number } => pt !== null);
if (keyframeWps.length >= 2) return keyframeWps;
const propX = anim.properties.x;
const propY = anim.properties.y;
if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps;
const destX = typeof propX === "number" ? propX : 0;
const destY = typeof propY === "number" ? propY : 0;
return [
{ x: 0, y: 0 },
{ x: destX, y: destY },
];
}

function autoRotateSuffix(autoRotate: boolean | number): string {
if (autoRotate === true) return ", autoRotate: true";
if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`;
return "";
}

function cubicControlPoints(
seg: ArcPathSegment,
wp: { x: number; y: number },
nextWp: { x: number; y: number },
): string[] {
if (seg.cp1 && seg.cp2) {
return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`];
}
const dx = nextWp.x - wp.x;
const dy = nextWp.y - wp.y;
const c = seg.curviness ?? 1;
return [
`{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`,
`{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`,
];
}

function buildCubicPathEntries(
waypoints: Array<{ x: number; y: number }>,
segments: ArcPathSegment[],
): string[] {
const first = waypoints[0];
if (!first) return [];
const entries = [`{x: ${first.x}, y: ${first.y}}`];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const wp = waypoints[i];
const nextWp = waypoints[i + 1];
if (!seg || !wp || !nextWp) continue;
entries.push(...cubicControlPoints(seg, wp, nextWp));
entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`);
}
return entries;
}

export function buildMotionPathObjectCode(config: {
waypoints: Array<{ x: number; y: number }>;
segments: ArcPathSegment[];
autoRotate: boolean | number;
}): string {
const { waypoints, segments, autoRotate } = config;
const arSuffix = autoRotateSuffix(autoRotate);
// GSAP's simple `path` array supports only ONE scalar `curviness` for the whole
// path, so per-segment curviness can only be expressed in the cubic form (each
// segment's curviness baked into its control points). Emit cubic when segments
// carry explicit control points OR when their curviness values differ — the
// simple branch would otherwise serialize only segments[0].curviness and drop
// every other segment's curve.
const hasExplicitCp = segments.some((s) => s.cp1 && s.cp2);
const curvinessVaries = segments.some(
(s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1),
);
if ((hasExplicitCp || curvinessVaries) && waypoints.length >= 2) {
const pathStr = buildCubicPathEntries(waypoints, segments).join(", ");
return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`;
}
const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`);
const curviness = segments[0]?.curviness ?? 1;
const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : "";
return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`;
}
8 changes: 8 additions & 0 deletions packages/core/src/parsers/gsapWriter.acorn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,14 @@ describe("T6c — keyframe write ops", () => {
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
});

it("addKeyframeToScript merges a new property into an existing keyframe, preserving siblings", () => {
// 50% already holds { opacity: 0.7 }; adding x must NOT drop opacity.
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { x: 100 });
expect(result).toContain("opacity: 0.7");
expect(result).toContain("x: 100");
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
});

it("removeKeyframeFromScript removes the target percentage", () => {
// Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c)
const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50);
Expand Down
Loading
Loading