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
@@ -5,6 +5,8 @@ import { roundTo3 } from "../utils/rounding";
import {
sdkGsapTweenPersist,
sdkGsapDeleteAllForSelectorPersist,
sdkAddWithKeyframesPersist,
sdkReplaceWithKeyframesPersist,
type CutoverDeps,
} from "../utils/sdkCutover";
import {
@@ -181,10 +183,104 @@ export function useGsapAnimationOps({
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
);
type KeyframeEntry = {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
};
const addWithKeyframes = useCallback(
async (
selection: DomEditSelection,
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeEntry[],
ease?: string,
label = "Add animation with keyframes",
) => {
if (sdkSession && sdkDeps) {
const targetPath = selection.sourceFile || activeCompPath || "index.html";
const handled = await sdkAddWithKeyframesPersist(
targetPath,
targetSelector,
position,
duration,
keyframes,
ease,
sdkSession,
sdkDeps,
{ label },
);
if (handled) return;
}
void commitMutation(
selection,
{
type: "add-with-keyframes",
targetSelector,
position,
duration,
keyframes,
...(ease ? { ease } : {}),
},
{ label, softReload: true },
);
},
[commitMutation, activeCompPath, sdkSession, sdkDeps],
);
const replaceWithKeyframes = useCallback(
async (
selection: DomEditSelection,
animationId: string,
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeEntry[],
ease?: string,
label = "Replace animation with keyframes",
) => {
if (sdkSession && sdkDeps) {
const targetPath = selection.sourceFile || activeCompPath || "index.html";
const handled = await sdkReplaceWithKeyframesPersist(
targetPath,
animationId,
targetSelector,
position,
duration,
keyframes,
ease,
sdkSession,
sdkDeps,
{ label },
);
if (handled) return;
}
void commitMutation(
selection,
{
type: "replace-with-keyframes",
animationId,
targetSelector,
position,
duration,
keyframes,
...(ease ? { ease } : {}),
},
{ label, softReload: true },
);
},
[commitMutation, activeCompPath, sdkSession, sdkDeps],
);
return {
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addWithKeyframes,
replaceWithKeyframes,
};
}
+80
View File
@@ -435,6 +435,86 @@ export function sdkGsapConvertToKeyframesPersist(
);
}
type KeyframeSpec = {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
};
type KeyframesPayload = {
targetSelector: string;
position: number;
duration: number;
keyframes: KeyframeSpec[];
ease?: string;
};
/** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
function dispatchWithKeyframes(
s: Composition,
payload: KeyframesPayload,
animationId?: string,
): void {
if (animationId !== undefined) {
s.dispatch({ type: "replaceWithKeyframes", animationId, ...payload });
} else {
s.dispatch({ type: "addWithKeyframes", ...payload });
}
}
export function sdkAddWithKeyframesPersist(
targetPath: string,
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeSpec[],
ease: string | undefined,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
const payload: KeyframesPayload = {
targetSelector,
position,
duration,
keyframes,
...(ease ? { ease } : {}),
};
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
dispatchWithKeyframes(s, payload),
);
}
export function sdkReplaceWithKeyframesPersist(
targetPath: string,
animationId: string,
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeSpec[],
ease: string | undefined,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
const payload: KeyframesPayload = {
targetSelector,
position,
duration,
keyframes,
...(ease ? { ease } : {}),
};
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => dispatchWithKeyframes(s, payload, animationId),
{ animationId, opLabel: "replaceWithKeyframes" },
);
}
export async function sdkDeletePersist(
hfId: string,
originalContent: string,