Skip to content

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

Merged
vanceingalls merged 4 commits into
mainfrom
ws-e-gsap-add-replace-keyframes
Jun 19, 2026
Merged

feat(sdk): ws-3c — addWithKeyframes + replaceWithKeyframes SDK ops (acorn writer)#1572
vanceingalls merged 4 commits into
mainfrom
ws-e-gsap-add-replace-keyframes

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

WS-3.C — addWithKeyframes + replaceWithKeyframes SDK ops (acorn writer)

Part of the AI Studio (Pacific) SDK integration. Stacked on #1571 (WS-D). This is the last hotspot op before recast retirement, and gates #1573 (WS-3.F) — the GSAP differential suite must be green here before recast can be removed.

What this does

Adds two GSAP keyframe ops on the acorn writer path:

  • addWithKeyframes — add an animation with keyframes.
  • replaceWithKeyframes — replace an existing keyframed animation.

Both are wired through the SDK (mutate.ts handlers + validateOp) and the Studio dispatch layer (SDK-first with server fallback). The acorn writer now emits _auto: 1 to match the recast writer's output exactly (verified by parity tests).

Implementation notes

  • buildKeyframeObjectCode / addAnimationWithKeyframesToScript gain an auto?: boolean flag.
  • Studio cutover helpers (sdkAddWithKeyframesPersist, sdkReplaceWithKeyframesPersist) share a dispatchWithKeyframes helper to avoid fallow duplication; useGsapAnimationOps wires the callbacks.
  • Position-derived ID landmine (documented in the commit): replaceWithKeyframes removes the old tween (renumbering survivors) then inserts the replacement. The MutationResult patch pair operates on the full GSAP script text, not per-ID patches, so undo/redo is safe — but callers holding stale animationIds must re-parse after a structural edit.

Files (6 changed)

  • core: parsers/gsapWriterAcorn.ts, parsers/gsapWriter.parity.test.ts
  • sdk: types.ts, engine/mutate.ts
  • studio: utils/sdkCutover.ts, hooks/useGsapAnimationOps.ts

Gates

Deferred

Remaining Studio callers (gsapDragCommit.ts, gsapRuntimeBridge.ts, useGestureCommit.ts, useEnableKeyframes.ts) still pass keyframe ops as untyped records on the server path — wired to the SDK in #1573 or a dedicated cutover pass.

🤖 Generated with Claude Code

vanceingalls and others added 4 commits June 18, 2026 15:05
…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>
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>
…t (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>
…corn 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>

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Solid piece of work. The two new ops land cleanly across all three layers (core writer, SDK engine, Studio dispatch), the parity test suite is thorough, and the dispatchWithKeyframes helper is a smart way to avoid the fallow duplication between add and replace. The position-derived ID landmine is documented clearly in both the PR body and the handler comment, and the coarse gsapScriptChange patch pair sidesteps the renumbering hazard elegantly.

A few observations — nothing blocking, but worth considering:


1. handleReplaceWithKeyframes — silent no-op remove is guarded, but fragile

The handler does removeAnimationFromScript(script, op.animationId) without checking if the remove actually changed anything. If the ID doesn't exist, removeAnimationFromScript returns the original script unchanged, and the handler proceeds to insert a NEW tween — turning a "replace" into an "add."

validateOp already guards against this via gsapAnimationMissing(parsed, op.animationId), so in the normal dispatch path this can't fire. But if someone bypasses validateOp (tests, internal tooling, future refactor that drops the check), this becomes a silent data-integrity bug.

An optional belt-and-suspenders guard:

const afterRemove = removeAnimationFromScript(script, op.animationId);
if (afterRemove === script) return EMPTY; // animation already gone

Not blocking — the validateOp gate is there and correct. But the pattern would match cascadeRemoveAnimations's own if (next === current) return current guard style.

2. _auto: 1 — numeric literal, not boolean

The acorn writer emits _auto: 1 (numeric) while the type definition allows auto?: boolean. This is intentional for recast parity (confirmed by the parity tests passing), but it's worth a brief inline comment in buildKeyframeObjectCode explaining why it's 1 not true — future contributors seeing if (kf.auto) props.push('_auto: 1') might "fix" it to _auto: true and break the GSAP runtime contract. A one-liner like // GSAP runtime expects _auto: 1 (numeric), not true prevents that.

3. Type duplication in KeyframeEntry / KeyframeSpec

There are now three near-identical keyframe type shapes:

  • EditOp["addWithKeyframes"]["keyframes"][number] in types.ts
  • KeyframeEntry in useGsapAnimationOps.ts
  • KeyframeSpec in sdkCutover.ts

All have { percentage, properties, ease?, auto? }. The Studio-side types aren't exported from types.ts because they serve different layers (one is the SDK op shape, the others are UI/cutover plumbing), but they'll drift independently. Consider extracting a shared KeyframeSpec from types.ts and importing it in both Studio files — or at minimum, adding a // Mirrors EditOp.addWithKeyframes.keyframes[number] comment to each copy so future editors know where the source of truth lives.

4. Parity tests — replaceWithKf{Recast,Acorn} helpers could check the intermediate state

The replaceWithKfRecast / replaceWithKfAcorn test helpers do remove → add but don't assert that the remove actually removed something. If removeAnimRecast or removeAnimAcorn silently no-ops (bad ID), the test would still pass because it'd be testing "add" not "replace." Adding expect(removed).not.toBe(script) after the remove call would catch that regression path.

5. Minor: sdkAddWithKeyframesPersist doesn't pass resolverTarget

sdkReplaceWithKeyframesPersist passes { animationId, opLabel: "replaceWithKeyframes" } as the resolverTarget to dispatchGsapOpAndPersist for resolver-parity telemetry. sdkAddWithKeyframesPersist doesn't — which makes sense since there's no existing animation to resolve. Just confirming this is intentional and not an oversight (it is — adds have no prior animation ID to check).


Verdict

Clean, well-documented, follows existing patterns faithfully. The parity tests are comprehensive and the position-derived-ID hazard is handled correctly at every layer. The five observations above are all non-blocking — the first two are the most actionable (defensive guard + inline comment).

LGTM — ready for stamp.

— Miga

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Reviewing at HEAD 4ef28dbc10e0fe60c214427c11ad60ebe59e376d. Stacked on #1571 (WS-D). Net +423/-0. Gates #1573 (the recast retirement).

_auto: 1 parity with the recast writer is the right fix and the parity tests at gsapWriter.parity.test.ts:923-942 cover it. replaceWithKeyframes as remove+add through the existing gsapScriptChange patch pair is structurally sound — undo/redo carry the full script before/after, so the position-derived-ID renumber problem documented in the PR body doesn't bite the patch layer (it just bites callers, who must re-parse — that's the right tradeoff). A few asymmetries vs sibling ops to call out.

Concerns

1. No typed methods on Composition for addWithKeyframes / replaceWithKeyframes — asymmetric with addGsapTween. (packages/sdk/src/session.ts, packages/sdk/src/types.ts)

addGsapTween and setGsapTween both have typed methods on Composition (sibling of setVariableValue, etc.) AND op-union entries; consumers can call comp.addGsapTween(target, tween) or comp.dispatch({type: "addGsapTween", ...}). PR #1572 only adds the op-union entries — Studio dispatches raw via s.dispatch({type: "addWithKeyframes", ...}) (sdkCutover.ts:460-462).

Internally consistent for this PR, but it leaves the SDK surface asymmetric: callers who learned comp.addGsapTween() will have to drop down to raw dispatch for addWithKeyframes. WS-3.F may add these; if so, please mark explicitly in the deferred section. If not, add the typed methods here — addWithKeyframes(targetSelector, position, duration, keyframes, ease?): string mirroring addGsapTween's shape.

2. replaceWithKeyframes doesn't surface "previous animationId no longer valid" via meta. (packages/sdk/src/engine/mutate.ts:984-1004)

The handler returns meta: { animationId } with the NEW id (line 1003). Callers holding the OLD op.animationId will mistakenly believe their stale handle is still valid until the next read. The PR body's "Position-derived ID landmine" note documents this for humans, but the meta channel could give callers a programmatic signal. Two cheap options:

  • Return meta: { animationId, replacedAnimationId: op.animationId } so the caller can map old→new in their local state without a re-parse.
  • Or document meta.animationId after replaceWithKeyframes as "id of the replacement (may equal op.animationId by coincidence, treat as new)" — at minimum a one-line code comment near the return.

Not blocking — but a stale-handle bug here will look like "my animation disappeared" in Studio, and the breadcrumb is at the call site.

3. Iterative-merge gate check. Per my standing rubric for "behind a gate, merge-and-iterate" workstream PRs: this one merges to ws-d-add-element (stacked) and the new ops are part of the SDK public type union the moment this lands. The new EditOp shapes are wire-schema-shaped — once published they are hard to walk back. Verify the addWithKeyframes / replaceWithKeyframes shapes are stable before publish. Open questions:

  • Should auto?: boolean be on the keyframe-spec level or on the op level (e.g. autoEndpoints: 'both' | 'first' | 'last' | 'none')? The current per-keyframe-entry placement matches the recast emitter's shape, which is the safer choice — keeping it for symmetry.
  • position: number (seconds) vs position: number | string for label-relative offsets ("intro+=0.5") — addGsapTween accepts number | string (GsapTweenSpec.position). New ops here are number-only. Deliberate, or should they accept label-relative too? If number-only, that's a constraint worth a one-liner in the type doc.

Nits

  • The cross-handler comment at mutate.ts:990-993 ("Position-derived IDs renumber, so the inverse patch restores the full GSAP script rather than trying to re-insert by ID") is load-bearing and worth a 1-line repeat above handleAddWithKeyframes too — both ops share the script-level patch shape, but a reader landing on handleAddWithKeyframes first will miss the rationale.
  • useGsapAnimationOps.ts:222-230 — the legacy commitMutation fallback for addWithKeyframes and replaceWithKeyframes (when sdkSession/sdkDeps are absent) still passes ops as untyped server records (type: "add-with-keyframes", hyphenated). The PR body's "Deferred" section names the un-cutover Studio callers but not the fallback contract itself. If the server schema for add-with-keyframes already exists, fine; if not, callers landing on this path silently fail.

Verified

  • _auto: 1 parity: recast writer emits _auto: 1 when kf.auto is true (gsapParser.ts:1234); acorn writer pre-#1572 didn't (verified by reading gsapWriterAcorn.ts:1128-1142 on origin/main). PR adds it (gsapWriterAcorn.ts:1132). Parity tests at gsapWriter.parity.test.ts:923-942 cover both-endpoint and single-endpoint auto cases.
  • replaceWithKeyframes correctness: removeAnimationFromScript then addAnimationWithKeyframesToScript, captured at the script-string level (mutate.ts:995-1002) — the patch pair is gsapScriptChange(oldScript, newScript) which carries before/after whole, so undo/redo are safe even though intermediate IDs renumber.
  • gsapAnimationMissing guard in validateOp for replaceWithKeyframes (mutate.ts:1570) — uses the existing helper, consistent with setGsapTween / removeGsapTween validation shape.
  • Empty keyframe-list rejection: both ops return E_INVALID_ARGS at validate (mutate.ts:1561-1567, :1572-1578). Good.
  • dispatchWithKeyframes helper in sdkCutover.ts:455-465 correctly switches on animationId !== undefined for add vs replace — clean.
  • GSAP differential suite green per PR body — gsapWriterParity.acorn.test.ts 14/0 is the canonical gate before #1573. Important — this PR is load-bearing for the recast retirement; the parity wins are real.

What I didn't verify

  • That the server-side add-with-keyframes / replace-with-keyframes record handlers exist for the commitMutation fallback path in useGsapAnimationOps.ts:222-230 / :266-274. The PR is SDK+Studio; server is presumably an orthogonal landing.
  • Whether the deferred Studio callers (gsapDragCommit.ts, gsapRuntimeBridge.ts, etc.) need any contract-locking from these new op shapes — assumed they'll cutover in #1573 / dedicated pass per the PR body.

Math + parity is right. (1) and (2) are the two surfaces I'd want tightened — both are cheap.

Review by Rames D Jusso

miguel-heygen
miguel-heygen previously approved these changes Jun 19, 2026

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Base automatically changed from ws-d-add-element to main June 19, 2026 06:05
@vanceingalls vanceingalls dismissed miguel-heygen’s stale review June 19, 2026 06:05

The base branch was changed.

@vanceingalls vanceingalls merged commit 418f198 into main Jun 19, 2026
43 checks passed
@vanceingalls vanceingalls deleted the ws-e-gsap-add-replace-keyframes branch June 19, 2026 06:15
vanceingalls added a commit that referenced this pull request Jun 19, 2026
Addresses the still-outstanding review concerns from merged PRs #1569 / #1570 /
#1572 not already hoisted into #1573.

WS-B (#1569):
- validateVariables requires discriminant fields for object-valued font/image
  ({name,source} / {url}); a {name:42} font or {foo:42} image previously passed
  runtime validation and surfaced as a bogus font-family / missing image.
- Dropped ImageValue's [key:string]:unknown index signature (let any {url}-shaped
  object through, swallowed typos); explicit alt?/fit? instead.
- Documented the OverrideSet widening for SDK consumers.

WS-C (#1570):
- getElementTimings caches parsed GSAP labels by exact script text (avoids a full
  acorn re-parse per read; content-key invalidates on edit).
- Documented end-inclusive label window + best-effort extractGsapLabels catch.

WS-3.C (#1572):
- Added typed Composition.addWithKeyframes / replaceWithKeyframes (was asymmetric
  with addGsapTween; Studio had to use raw dispatch).
- Extracted shared KeyframeSpec type; documented position as seconds/number-only.

Gates: build + core 18/18 + sdk 19/19 + oxlint + oxfmt + fallow + typecheck all green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants