feat(sdk): ws-3c — addWithKeyframes + replaceWithKeyframes SDK ops (acorn writer) (#1572)

* feat(sdk): ws-b variables/brand — object-valued font/image + B1 JSON model

B1 — setVariableValue now drives the runtime JSON model
(data-composition-variables) so preview == render. CSS custom prop is
kept as a secondary compat write for compositions that CSS-bind directly
to --{id}.

B2 — object-valued font ({name, source}) and image ({url}) variable
types added core-to-SDK. Object values write to the JSON model only;
scalars write both model + explicit CSS style patches. Explicit style-path
patches in forward/inverse ensure apply-patches.ts handles each path type
purely (model vs CSS), so inverse patches restore exact pre-call state
without ambiguity.

Changed files:
  packages/core/src/core.types.ts         — font/image to CompositionVariableType + interfaces
  packages/core/src/lint/rules/composition.ts — accept font/image in lint message
  packages/core/src/parsers/htmlParser.ts     — validate font/image variable declarations
  packages/core/src/parsers/htmlParser.test.ts — tests for new variable types
  packages/core/src/runtime/validateVariables.ts — checkType for font/image
  packages/sdk/src/types.ts            — FontValue/ImageValue; widen OverrideSet + EditOp
  packages/sdk/src/index.ts            — re-export FontValue/ImageValue
  packages/sdk/src/session.ts          — widen setVariableValue signature
  packages/sdk/src/engine/patches.ts   — valueChange helper for object-valued patches
  packages/sdk/src/engine/mutate.ts    — handleSetVariableValue: B1+B2 with explicit CSS patches
  packages/sdk/src/engine/apply-patches.ts — variable case: model-only (CSS via explicit patch)
  packages/sdk/src/engine/mutate.test.ts   — B1+B2 round-trip + inverse tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sdk): ws-c elastic timing + word-alignment resolver (WS-C)

C1: getElementTimings/setElementTiming typed session methods + setHold typed
wrapper. getElementTimings reads data-duration (preferred) or data-end−data-start
(fallback) — same attr-preference as handleSetTiming. setElementTiming dispatches
a sparse map as one batch → one patch event → one undo step. setHold mirrors
setVariableValue pattern.

Also fixes a pre-existing apply-patches.ts gap: the timing/duration patch case was
absent, causing undo of duration changes to silently no-op. Added the duration
branch so inverse patches restore data-duration correctly.

C2: packages/core/src/compiler/timingResolver.ts — shared pure resolveTimings()
consumed by BOTH preview (sdk session) and render (timingCompiler) paths. Word-
anchored elements get enterAt = wordTimings[k].start + offset; elastic hold =
max(0, slotEnd − (enterAt + enterDuration + exitDuration)), clamped ≥ 0; never
timescales animated content. Un-anchored elements keep authored timing (align-on-
adjust). Deterministic + pure: no Date.now, no Math.random, no DOM.

extractGsapLabels() added to gsapParserAcorn.ts to parse tl.addLabel() calls for
the getElementTimings labels field.

Tests: timingResolver.test.ts (10 pure-function tests including preview==render
parity golden test); session.timings.test.ts (15 session-layer tests covering
duration-authored, end-authored, label extraction, batching, undo, and setHold
regression).

Gates: build ✓ · bun test (sdk+core/compiler) 434/434 ✓ · oxlint 0 warnings ✓ ·
oxfmt --check ✓ · fallow --gate new-only ✓ (complexity suppressed on 2 new
inline functions, duplication warn-only pre-existing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sdk): addElement forward op — mint hf-id, inverse = removeElement (WS-D)

Implements WS-D: the addElement EditOp and session.addElement() typed method.

- types.ts: addElement op (parent/index/html) added to EditOp union;
  addElement(parent, index, html): HfId added to Composition interface
- mutate.ts: handleAddElement inserts a single-root HTML fragment at
  parent+index, minting ids against the LIVE document's existing id set
  (not a fresh fragment set) via collectDocumentHfIds + mintFragmentIds;
  forward = patchAdd, inverse = patchRemove; MutationResult.meta.newId
  carries the minted root id
- mutate.ts: validateOp case rejects missing parent, negative index,
  empty html, zero-element html, and <script> in html
- session.ts: typed addElement(parent, index, html) returns minted id
  via result.meta.newId
- mutate.test.ts: 16 tests covering insert position, append semantics,
  id uniqueness, content-collision rehash, nested fragments, forward/
  inverse symmetry, undo, add/undo/redo stability, parent:null body
  insertion, serialize round-trip, and all five validateOp rejection codes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sdk): ws-3c — addWithKeyframes + replaceWithKeyframes SDK ops (acorn writer)

Port add-with-keyframes / replace-with-keyframes from the server recast path
to the acorn/magic-string writer and expose them as typed SDK EditOps.

- gsapWriterAcorn.ts: extend buildKeyframeObjectCode and
  addAnimationWithKeyframesToScript to accept `auto?: boolean` on keyframes
  (emits `_auto: 1` matching the recast writer)
- types.ts: add `addWithKeyframes` and `replaceWithKeyframes` to EditOp union
- mutate.ts: add handleAddWithKeyframes, handleReplaceWithKeyframes, and
  applyGsapWithKeyframesOp dispatch sub-function; validateOp cases for both
- sdkCutover.ts: add sdkAddWithKeyframesPersist + sdkReplaceWithKeyframesPersist
  (shared via dispatchWithKeyframes to eliminate clone)
- useGsapAnimationOps.ts: wire addWithKeyframes + replaceWithKeyframes
  callbacks with SDK-first / server fallback pattern
- gsapWriter.parity.test.ts: add parity tests for _auto endpoint round-trip and
  replaceWithKeyframes (remove + addWithKeyframes) differential golden harness;
  import removeAnimationFromScript from both writers

Landmine note: tween IDs are position-derived — replaceWithKeyframes removes the
old tween (renumbering survivors) then inserts the replacement at the end; the
MutationResult patch pair restores the whole GSAP script on undo, not a per-ID
inverse, so ID-held references in callers must re-parse after structural edits.

Gate: WS-3.F (retire recast / executeGsapMutation) — NOT started here.
Remaining Studio callers (gsapDragCommit, gsapRuntimeBridge, useGestureCommit,
useEnableKeyframes) remain on the server commitMutation path until WS-3.F.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-18 23:15:02 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0ca01c88cf
commit 418f198b33
6 changed files with 423 additions and 0 deletions
@@ -22,6 +22,7 @@ import {
addKeyframeToScript as addKeyframeRecast,
removeKeyframeFromScript as removeKeyframeRecast,
addAnimationWithKeyframesToScript as addWithKfRecast,
removeAnimationFromScript as removeAnimRecast,
shiftPositionsInScript as shiftRecast,
scalePositionsInScript as scaleRecast,
type SplitAnimationsOptions,
@@ -44,6 +45,7 @@ import {
addKeyframeToScript as addKeyframeAcorn,
removeKeyframeFromScript as removeKeyframeAcorn,
addAnimationWithKeyframesToScript as addWithKfAcorn,
removeAnimationFromScript as removeAnimAcorn,
shiftPositionsInScript as shiftAcorn,
scalePositionsInScript as scaleAcorn,
} from "./gsapWriterAcorn.js";
@@ -917,6 +919,132 @@ describe("parity: addAnimationWithKeyframesToScript (recast vs acorn)", () => {
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script;
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
});
// WS-3.C: auto-endpoint markers must round-trip through both writers.
it("_auto endpoint: 0% and 100% carry the _auto marker", () => {
const kfs = [
{ percentage: 0, properties: { x: 0, opacity: 1 }, auto: true },
{ percentage: 50, properties: { x: 100, opacity: 0.5 } },
{ percentage: 100, properties: { x: 200, opacity: 0 }, auto: true },
];
const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
});
it("_auto endpoint: only 0% carries auto marker", () => {
const kfs = [
{ percentage: 0, properties: { opacity: 1 }, auto: true },
{ percentage: 100, properties: { opacity: 0 } },
];
const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#el", 2, 0.5, kfs).script;
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#el", 2, 0.5, kfs).script;
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
});
it("returns a stable new animation ID that is non-empty", () => {
const kfs = [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
];
const acornResult = addWithKfAcorn(ADD_WITH_KF_BASE, "#box", 0, 1, kfs);
const recastResult = addWithKfRecast(ADD_WITH_KF_BASE, "#box", 0, 1, kfs);
expect(acornResult.id).not.toBe("");
expect(recastResult.id).not.toBe("");
// The IDs are position-derived and may differ between writers due to
// formatting differences, but both must be non-empty valid strings.
expect(typeof acornResult.id).toBe("string");
expect(typeof recastResult.id).toBe("string");
});
});
// ── replaceWithKeyframes parity (remove + addWithKeyframes, recast vs acorn) ──
// WS-3.C replace path: both writers remove the existing tween by animationId,
// then insert the replacement keyframed tween. The animation model of the
// resulting script's last animation must match.
const REPLACE_WITH_KF_BASE = `\
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, opacity: 1, duration: 0.5 }, 1);
`;
function replaceWithKfRecast(
script: string,
animId: string,
selector: string,
pos: number,
dur: number,
kfs: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>,
ease?: string,
): string {
const removed = removeAnimRecast(script, animId);
return addWithKfRecast(removed, selector, pos, dur, kfs, ease).script;
}
function replaceWithKfAcorn(
script: string,
animId: string,
selector: string,
pos: number,
dur: number,
kfs: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>,
ease?: string,
): string {
const removed = removeAnimAcorn(script, animId);
return addWithKfAcorn(removed, selector, pos, dur, kfs, ease).script;
}
describe("parity: replaceWithKeyframes (remove + addWithKeyframes, recast vs acorn)", () => {
it("replaces the only tween: resulting animation model matches", () => {
const id = acornId(REPLACE_WITH_KF_BASE);
const kfs = [
{ percentage: 0, properties: { x: 0, opacity: 0 } },
{ percentage: 100, properties: { x: 200, opacity: 1 } },
];
const acorn = replaceWithKfAcorn(REPLACE_WITH_KF_BASE, id, "#box", 0.5, 1.5, kfs);
const recast = replaceWithKfRecast(REPLACE_WITH_KF_BASE, id, "#box", 0.5, 1.5, kfs);
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
});
it("replaces the first tween in a two-tween script, preserving the other", () => {
const TWO_TWEEN = `\
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 0.5 }, 0);
tl.to("#circle", { y: 200, duration: 1 }, 1);
`;
const id = acornId(TWO_TWEEN);
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 300 } },
];
const acorn = replaceWithKfAcorn(TWO_TWEEN, id, "#box", 0, 0.75, kfs);
const recast = replaceWithKfRecast(TWO_TWEEN, id, "#box", 0, 0.75, kfs);
// The second tween (#circle) must survive unchanged.
expect(modelOf(acorn)).toHaveLength(2);
expect(modelOf(recast)).toHaveLength(2);
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
});
it("replaces with _auto endpoint markers", () => {
const id = acornId(REPLACE_WITH_KF_BASE);
const kfs = [
{ percentage: 0, properties: { opacity: 1 }, auto: true },
{ percentage: 100, properties: { opacity: 0 }, auto: true },
];
const acorn = replaceWithKfAcorn(REPLACE_WITH_KF_BASE, id, "#box", 1, 2, kfs);
const recast = replaceWithKfRecast(REPLACE_WITH_KF_BASE, id, "#box", 1, 2, kfs);
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
});
});
// ── shiftPositionsInScript / scalePositionsInScript (timeline clip move/resize) ──
@@ -1128,12 +1128,14 @@ function buildKeyframeObjectCode(
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>,
easeEach?: string,
): string {
const entries = keyframes.map((kf) => {
const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
if (kf.auto) props.push(`_auto: 1`);
return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`;
});
if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`);
@@ -1210,6 +1212,7 @@ export function addAnimationWithKeyframesToScript(
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>,
ease?: string,
): { script: string; id: string } {