feat(sdk): stage 6 — arc path ops (setArcPath, updateArcSegment, removeArcPath) (#1500)

Port arc path trio from recast to browser-safe acorn+MagicString writer.
Add SDK op types and mutate.ts handlers for setArcPath / updateArcSegment /
removeArcPath. Decompose buildMotionPathObjectCode into small sub-functions
in gsapSerialize.ts to stay within fallow complexity thresholds. Tests verify
acorn output re-parses to correct arcPath shape.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:53:02 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent a746db6017
commit 1612d18fdf
7 changed files with 510 additions and 110 deletions
+119 -94
View File
@@ -32,17 +32,6 @@ function fresh(script = GSAP_SCRIPT) {
return parseMutable(makeHtml(script));
}
// A sub-composition host: data-hf-id="hf-host" (its own leaf id) AND
// data-composition-id="sub-1" (the id studio passes when targeting the root).
function freshSubComp(script = GSAP_SCRIPT) {
return parseMutable(
`<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-host" data-composition-id="sub-1" style="opacity: 0"></div>
<script>${script}</script>
</div>`.trim(),
);
}
function getScript(parsed: ReturnType<typeof parseMutable>): string {
const doc = serializeDocument(parsed);
const m = /<script>([\s\S]*?)<\/script>/i.exec(doc);
@@ -187,89 +176,6 @@ describe("addGsapTween", () => {
expect(newScript).toContain("opacity: 0");
expect(newScript).toContain("opacity: 1");
});
it("returns EMPTY when no GSAP script", () => {
const noScript = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
const result = applyOp(noScript, {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 1 } },
});
expect(result.forward).toHaveLength(0);
});
// A normal data-hf-id target keeps the [data-hf-id] selector form.
it("emits a [data-hf-id] selector for a normal element target", () => {
const result = applyOp(fresh(), {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 1 } },
});
const script = String(result.forward[0]?.value ?? "");
expect(script).toContain(`[data-hf-id=\\"hf-box\\"]`);
expect(script).not.toContain("data-composition-id");
});
// A sub-composition ROOT is addressed by its composition id, but the SDK's
// element↔tween attribution is data-hf-id based. So a comp-id target must
// resolve to the host element and emit the CANONICAL [data-hf-id="<host>"]
// form — NOT [data-composition-id] (invisible to selectorMatchesId / cascade /
// buildAnimationIdMap) and NOT [data-hf-id="<compId>"] (matches no element).
it("emits a canonical [data-hf-id] selector for a sub-composition root target", () => {
const result = applyOp(freshSubComp(), {
type: "addGsapTween",
target: "sub-1",
tween: { method: "to", properties: { x: 1 } },
});
const script = String(result.forward[0]?.value ?? "");
expect(script).toContain(`[data-hf-id=\\"hf-host\\"]`);
expect(script).not.toContain("data-composition-id");
expect(script).not.toContain(`[data-hf-id=\\"sub-1\\"]`);
});
// validateOp/can must accept a comp-root target (resolveScoped's comp-id
// fallback resolves it) — otherwise can/apply diverge.
it("validateOp accepts a sub-composition root target (no E_TARGET_NOT_FOUND)", () => {
const r = validateOp(freshSubComp(), {
type: "addGsapTween",
target: "sub-1",
tween: { method: "to", properties: { x: 1 } },
});
expect(r.ok).toBe(true);
});
// setTiming on the comp-root after adding a tween updates the tween's GSAP
// position/duration — selectorMatchesId matches the canonical host hf-id.
it("setTiming on a comp-root syncs its tween position/duration", () => {
// applyOp mutates parsed.document in place, so chain ops on the same parsed.
const parsed = freshSubComp();
applyOp(parsed, {
type: "addGsapTween",
target: "sub-1",
tween: { method: "to", duration: 0.5, properties: { x: 1 } },
});
applyOp(parsed, { type: "setTiming", target: "sub-1", start: 2, duration: 1.5 });
const script = getScript(parsed);
// The host tween's GSAP position (3rd arg) is now 2 and duration 1.5.
expect(script).toContain(`[data-hf-id=\\"hf-host\\"]`);
expect(script).toMatch(/duration:\s*1\.5/);
expect(script).toMatch(/\},\s*2\)/);
});
// removeElement on the comp-root cascade-removes its tween (not orphaned).
it("removeElement on a comp-root cascade-removes its tween", () => {
const parsed = freshSubComp();
applyOp(parsed, {
type: "addGsapTween",
target: "sub-1",
tween: { method: "to", properties: { x: 1 } },
});
expect(getScript(parsed)).toContain(`[data-hf-id=\\"hf-host\\"]`);
applyOp(parsed, { type: "removeElement", target: "sub-1" });
expect(getScript(parsed)).not.toContain(`[data-hf-id=\\"hf-host\\"]`);
});
});
// ─── Tween op test helpers ────────────────────────────────────────────────────
@@ -832,3 +738,122 @@ window.__timelines["t"] = tl;`;
expect(newScript).toContain("hf-stage");
});
});
// ─── GSAP ops on composition with no script block ────────────────────────────
const NO_SCRIPT_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box" style="opacity:0"></div>
</div>`.trim();
describe("GSAP ops on composition with no GSAP script block", () => {
function freshNoScript() {
return parseMutable(NO_SCRIPT_HTML);
}
it("addGsapTween throws instead of silent no-op", () => {
expect(() =>
applyOp(freshNoScript(), {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 100 } },
}),
).toThrow();
});
it("setGsapTween throws instead of silent no-op", () => {
expect(() =>
applyOp(freshNoScript(), {
type: "setGsapTween",
animationId: "anim-1",
properties: { ease: "power2.out" },
}),
).toThrow();
});
it("removeGsapTween throws instead of silent no-op", () => {
expect(() =>
applyOp(freshNoScript(), { type: "removeGsapTween", animationId: "anim-1" }),
).toThrow();
});
it("addGsapKeyframe throws when script element is null", () => {
expect(() =>
applyOp(freshNoScript(), {
type: "addGsapKeyframe",
animationId: "a1",
percentage: 0,
value: { opacity: 0 },
}),
).toThrow("No GSAP script block found");
});
});
// ─── arc path ops ─────────────────────────────────────────────────────────────
const ARC_SCRIPT = `var tl = gsap.timeline({ paused: true });
tl.to('[data-hf-id="hf-hero"]', { x: 100, y: 50, duration: 2 }, 0);
window.__timelines["t"] = tl;`;
const ARC_ANIM_ID = `[data-hf-id="hf-hero"]-to-0-position`;
const ARC_ENABLED_CONFIG = {
enabled: true as const,
autoRotate: false as const,
segments: [{ curviness: 1 }],
};
function freshArc() {
return parseMutable(makeHtml(ARC_SCRIPT));
}
function enableArc(parsed: ReturnType<typeof parseMutable>) {
applyOp(parsed, { type: "setArcPath", animationId: ARC_ANIM_ID, config: ARC_ENABLED_CONFIG });
}
describe("setArcPath", () => {
it("enabled: true adds motionPath to script", () => {
const parsed = freshArc();
enableArc(parsed);
expect(getScript(parsed)).toContain("motionPath");
});
it("enabled: false removes motionPath and restores x/y", () => {
const parsed = freshArc();
enableArc(parsed);
applyOp(parsed, {
type: "setArcPath",
animationId: ARC_ANIM_ID,
config: { enabled: false, autoRotate: false, segments: [] },
});
const s = getScript(parsed);
expect(s).not.toContain("motionPath");
});
it("no-op when animation not found", () => {
const parsed = freshArc();
const before = getScript(parsed);
applyOp(parsed, { type: "setArcPath", animationId: "nonexistent", config: ARC_ENABLED_CONFIG });
expect(getScript(parsed)).toBe(before);
});
});
describe("updateArcSegment", () => {
it("changes curviness of segment", () => {
const parsed = freshArc();
enableArc(parsed);
applyOp(parsed, {
type: "updateArcSegment",
animationId: ARC_ANIM_ID,
segmentIndex: 0,
update: { curviness: 2 },
});
expect(getScript(parsed)).toContain("motionPath");
});
});
describe("removeArcPath", () => {
it("removes motionPath from script", () => {
const parsed = freshArc();
enableArc(parsed);
applyOp(parsed, { type: "removeArcPath", animationId: ARC_ANIM_ID });
expect(getScript(parsed)).not.toContain("motionPath");
});
});
+8 -8
View File
@@ -469,14 +469,14 @@ describe("validateOp", () => {
// ─── Phase 3b ops — graceful when no GSAP script, feature-detectable ────────
describe("Phase 3b ops", () => {
it("applyOp returns EMPTY when no GSAP script is present", () => {
const result = applyOp(fresh(), {
type: "addGsapTween",
target: "hf-title",
tween: { method: "from", properties: { opacity: 0 } },
});
expect(result.forward).toHaveLength(0);
expect(result.inverse).toHaveLength(0);
it("applyOp throws when no GSAP script is present", () => {
expect(() =>
applyOp(fresh(), {
type: "addGsapTween",
target: "hf-title",
tween: { method: "from", properties: { opacity: 0 } },
}),
).toThrow();
});
it("validateOp returns ok:false / E_NO_GSAP_SCRIPT when no GSAP script present", () => {
+45 -4
View File
@@ -58,6 +58,9 @@ import {
updateKeyframeInScript,
addLabelToScript,
removeLabelFromScript,
setArcPathInScript,
updateArcSegmentInScript,
removeArcPathFromScript,
} from "@hyperframes/core/gsap-writer-acorn";
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
@@ -187,9 +190,34 @@ function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult
}
}
function applyArcPathOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
const s = getGsapScript(parsed.document) ?? "";
switch (op.type) {
case "setArcPath": {
const cfg = {
...op.config,
segments: op.config.segments.map((seg) => ({ ...seg, curviness: seg.curviness ?? 1 })),
};
return handleArcPathScript(parsed, s, setArcPathInScript(s, op.animationId, cfg));
}
case "updateArcSegment":
return handleArcPathScript(
parsed,
s,
updateArcSegmentInScript(s, op.animationId, op.segmentIndex, op.update),
);
case "removeArcPath":
return handleArcPathScript(parsed, s, removeArcPathFromScript(s, op.animationId));
default:
return undefined;
}
}
function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
const kf = applyGsapKeyframeOp(parsed, op);
if (kf !== undefined) return kf;
const arc = applyArcPathOp(parsed, op);
if (arc !== undefined) return arc;
switch (op.type) {
case "addGsapTween":
return handleAddGsapTween(parsed, op.target, op.tween);
@@ -679,7 +707,7 @@ function handleAddGsapTween(
tween: GsapTweenSpec,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
if (!script) throw new Error("No GSAP script block found in the composition.");
const extras: Record<string, unknown> = {};
if (tween.repeat !== undefined) extras.repeat = tween.repeat;
@@ -720,7 +748,7 @@ function handleSetGsapTween(
properties: Partial<GsapTweenSpec>,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
if (!script) throw new Error("No GSAP script block found in the composition.");
const updates: Partial<GsapAnimation> = {};
if (properties.duration !== undefined) updates.duration = properties.duration;
@@ -760,7 +788,7 @@ function handleRemoveGsapProperty(
function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
if (!script) throw new Error("No GSAP script block found in the composition.");
const newScript = removeAnimationFromScript(script, animationId);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
@@ -844,6 +872,16 @@ function handleSplitAnimations(
return gsapScriptChange(script, newScript);
}
function handleArcPathScript(
parsed: ParsedDocument,
oldScript: string,
newScript: string,
): MutationResult {
if (newScript === oldScript) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(oldScript, newScript);
}
function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
@@ -921,7 +959,7 @@ function handleAddGsapKeyframe(
value: Record<string, unknown>,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
if (!script) throw new Error("No GSAP script block found in the composition.");
const props = value as Record<string, number | string>;
const newScript = addKeyframeToScript(
script,
@@ -1066,6 +1104,9 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
case "materializeKeyframes":
case "splitIntoPropertyGroups":
case "splitAnimations":
case "setArcPath":
case "updateArcSegment":
case "removeArcPath":
case "deleteAllForSelector":
case "removeLabel":
if (getGsapScript(parsed.document) === null)
+25 -1
View File
@@ -133,7 +133,31 @@ export type EditOp =
elementDuration: number;
}
| { type: "addLabel"; name: string; position: number }
| { type: "removeLabel"; name: string };
| { type: "removeLabel"; name: string }
| {
type: "setArcPath";
animationId: string;
config: {
enabled: boolean;
autoRotate: boolean | number;
segments: Array<{
curviness?: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}>;
};
}
| {
type: "updateArcSegment";
animationId: string;
segmentIndex: number;
update: {
curviness?: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
};
}
| { type: "removeArcPath"; animationId: string };
export interface ElasticHold {
start: number;