Skip to content

feat(sdk,studio): ws-1.2 — percentage-based removeGsapKeyframe#1498

Merged
vanceingalls merged 4 commits into
mainfrom
sdk-ws1-completion
Jun 17, 2026
Merged

feat(sdk,studio): ws-1.2 — percentage-based removeGsapKeyframe#1498
vanceingalls merged 4 commits into
mainfrom
sdk-ws1-completion

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Extends the SDK GSAP mutation surface with three new ops and routes them through the Studio SDK cutover gate.

Why percentage-based keyframe removal? The previous approach required callers to know the array index of a keyframe, which is fragile — indices shift when other keyframes are added or removed. Percentage-based removal (±0.001 tolerance) lets callers reference the timeline position directly from what the UI shows.

New SDK ops

  • removeGsapKeyframe(hfId, animationIndex, percentage) — removes the keyframe closest to percentage (±0.001 tolerance); replaces the old index-based API
  • removeGsapProperty(hfId, animationIndex, property) — removes a single CSS property from every keyframe of the animation (e.g. drop opacity without touching scale)
  • deleteAllForSelector(hfId, selector) — removes all GSAP tweens targeting a CSS selector; used when an element is deleted

Studio routing

All three ops flow through sdkCutoverPersist behind the existing SDK session gate. Falls back to the server-side mutation path when no SDK session is active.

Also includes:

  • fix(core): cascade-remove GSAP tweens in removeElementFromHtml (WS-2) — element deletion now cascades to remove orphaned GSAP tweens from the script block

Files changed

  • packages/sdk/src/engine/mutate.ts — three new op implementations
  • packages/sdk/src/types.ts — new op type discriminants
  • packages/core/src/parsers/gsapWriterAcorn.ts — acorn writer support for property removal
  • packages/core/src/parsers/htmlParser.ts — cascade-remove helper
  • packages/studio/src/hooks/useDomEditCommits.ts, useGsapAnimationOps.ts, useGsapKeyframeOps.ts, useGsapPropertyDebounce.ts — Studio hook cutover
  • packages/studio/src/utils/sdkCutover.ts — gate wiring

Test plan

  • mutate.gsap.test.ts — all three new ops: remove keyframe by %, remove property, delete all for selector
  • Manual: remove keyframe by clicking diamond in Studio timeline; verify adjacent keyframes unaffected
  • Manual: delete element with GSAP animation; verify no orphaned tweens in script block
  • bun run test green in core and sdk

🤖 Generated with Claude Code

vanceingalls commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Summary of intent. Three things in one PR: (1) cuts over Studio's removeGsapKeyframe / removeGsapProperty / deleteAllForSelector flows from the server path to the SDK, (2) factors the GSAP-op dispatcher in applyOp out into applyGsapOp for readability, (3) reshapes removeElementFromHtml to cascade-strip GSAP animations targeting the deleted id. Refactor of sdkCutover.ts extracts a dispatchGsapOpAndPersist shared core.

PR-template flag. +316/-95 across 9 files with boilerplate body. Soft ask: a What/Why/How is worth your while here — there's at least three distinct ideas glued into one PR (the cutover, the dispatcher factor, the cascade-delete behavior), and future archeologists will thank you.

The headline finding — band-aid pattern #1 (duplicate validation at boundaries).

The EditOp union at packages/sdk/src/types.ts:104-105 now has two removeGsapKeyframe variants:

| { type: "removeGsapKeyframe"; animationId: string; keyframeIndex: number }
| { type: "removeGsapKeyframe"; animationId: string; percentage: number }

Both carry the same type tag. The dispatcher at mutate.ts:139-141 uses "percentage" in op to discriminate. TypeScript narrowing collapses them into a single union member with optional fields at every call site that does not pre-narrow. In production, every Studio caller post-this-PR uses the percentage variant (verified: only sdkCutover.ts:258 dispatches removeGsapKeyframe, and it passes percentage). The keyframeIndex variant survives only to keep the test at mutate.gsap.test.ts:373-389 green.

This is the band-aid bar's pattern #1 head-on. Two shapes for the same op name, one is prod, one is test-only. The next reader who reaches for removeGsapKeyframe from a fresh consumer has a 50/50 chance of picking the dead shape, and the type-checker will not stop them.

Recommended path forward (any of three, your pick):

  1. Rename one variant — removeGsapKeyframeByIndex for the index shape, leave removeGsapKeyframe as the percentage-keyed canonical. Cheap and explicit.
  2. Retire the keyframeIndex variant entirely. Update the test to use percentage. The handler handleRemoveGsapKeyframe at mutate.ts:817+ and its resolveKeyframe helper become reachable only via the percentage path that now no-ops on ambiguity at mutate.ts:803. Probably the right answer — there are no production callers of the index path.
  3. Merge into one op with { animationId; percentage?: number; keyframeIndex?: number } and an explicit validator that requires exactly one. Honest discriminated union if both shapes are load-bearing.

Under Miguel's band-aid bar this is request-changes territory, not comment. Your call on whether to fold the fix into this PR or land it as a follow-up — but please don't leave the union shape as-is.

Other findings.

  • packages/core/src/parsers/htmlParser.ts:677-710 (cascade-strip on removeElementFromHtml). The new selectorTargetsId matches #id, [data-hf-id="id"], and [data-hf-id='id']. It does not match descendant or compound selectors that name the id (e.g. .parent #id, #id .child, #id, #other). That's almost certainly intentional — those forms target by-id-as-component, not the element itself — but the silent-skip behavior is undocumented and the next reader will wonder. A one-line comment explaining the conservative match would close the door.
  • Same function, cascadeRemoveGsapById: the text.includes("gsap") || text.includes("ScrollTrigger") heuristic skips scripts that animate via a non-gsap symbol (e.g. an aliased import). Cheap detection: in your tree, do all GSAP scripts mention gsap literally? If yes, fine — but it's a footgun for any future block that aliases the global.
  • packages/sdk/src/engine/mutate.ts:705-720 (handleDeleteAllForSelector). Inline ponytail: note says it skips stripStudioEditsFromTarget because "studio path offset is cosmetic once all animations are gone." That reasoning is fine for the immediate frame but leaves a stale data-hf-studio-path-offset attribute on the element after the script is gone. If a subsequent op re-adds an animation to that selector before reload, the offset is now silent prior-state contamination. Worth a comment cross-referencing whether the session-reload assumption holds in every caller of this op.
  • packages/sdk/src/engine/mutate.ts:797-808 (handleRemoveGsapKeyframeByPercentage). The TOLERANCE = 0.001 is tighter than the matching tolerance Studio uses at useGsapKeyframeOps.ts:165 (> 0.001 for the optimistic cache). Both moved from 0.2 to 0.001 in this PR, which is the right direction, but if a fixture rounds to 33.333% from one side and 33.334% from the other (1e-3 apart), one layer matches and the other doesn't. Worth a comment pinning the convention.
  • packages/studio/src/utils/sdkCutover.ts:223-247 — the new dispatchGsapOpAndPersist is the right factor. The before==after fallback is well-reasoned in the comment. One subtle behavioral shift: sdkGsapTweenPersist used to have an inline existence guard on add (!sdkSession.getElement(op.target)). The new path keeps that guard but the set/remove branches now rely purely on the before==after check. For set, a stale animationId correctly yields before === after since updateAnimationInScript returns the input script unchanged on miss. For remove, same story via removeAnimationFromScript. Verified manually; LGTM.
  • packages/studio/src/hooks/useGsapKeyframeOps.ts:166 — the optimistic cache match tolerance was widened from > 0.2 to > 0.001. That's tighter, which matches the new SDK side. But pre-existing fixtures that depended on the looser match (anything inside [0.001, 0.2] of the canonical percentage) silently get a stricter filter. Worth a manual smoke test in a real HF with hand-typed percentages.
  • packages/studio/src/hooks/useDomEditCommits.ts:18-19, 47-49 — removes two export re-exports. Verified they were the only re-exports; downstream import { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } and import type { PersistDomEditOperations } consumers now route directly to useDomGeometryCommits / domEditCommitTypes. Greppable, clean.

Dispatch chain audit. Every new op type wired:

  • removeGsapProperty — handler at mutate.ts:690, validator at mutate.ts:909, Studio dispatch at useGsapPropertyDebounce.ts:121. ✓
  • deleteAllForSelector — handler at mutate.ts:705, validator at mutate.ts:910, Studio dispatch at useGsapAnimationOps.ts:87. ✓
  • removeGsapKeyframe (percentage variant) — handler at mutate.ts:797, dispatched via dispatchRemoveGsapKeyframe. ✓

CI. Preflight failing (probably stack-inherited), player-perf, preview-regression, and regression all FAILURE on the head SHA. The regression failures specifically are the ones to look at given the cascade-delete change — that's a behavior shift to removeElementFromHtml that could trip render fixtures. Please dig into those before merging; the band-aid finding doesn't block CI investigation, but I want both addressed.

Verdict. Hard request-changes on the duplicate removeGsapKeyframe union (band-aid pattern #1). Everything else is comment-tier — the cutover refactor is genuinely clean, dispatchGsapOpAndPersist is the right factor, and the cascade-delete is solid modulo the documentation nit. Reach for option 2 (retire the keyframeIndex variant) if you can swing it; option 1 (rename) is the fallback.

Cleared SHA: d71dd71a. Any push past that voids this review.

Review by Via

vanceingalls added a commit that referenced this pull request Jun 16, 2026
Wire the SDK shadow-parity telemetry to cover GSAP keyframe add/remove,
the primary unwired cutover signal, plus a defensive unmapped-PatchOperation
guard.

New packages/studio/src/utils/sdkShadowGsapKeyframe.ts:
- ShadowKeyframeOp + keyframeOpToEditOp: maps studio percentage-based keyframe
  ops to SDK EditOps. add -> addGsapKeyframe{position:percentage}; remove ->
  removeGsapKeyframe{keyframeIndex}, resolving percentage -> index against the
  pre-op script with ~0.001 tolerance and a no-op-on-ambiguity guard for
  duplicate-percentage keyframes (PR #1498 landmine).
- gsapKeyframeFidelityMismatches: reuses gsapFidelityMismatches for the
  tween-level diff and layers a keyframe-array comparison (which the base diff
  doesn't inspect), matched by GSAP animation id.
- runShadowGsapKeyframeFidelity: serialize-diff runner emitting op tag
  gsap_keyframe (no keyframe reader on ElementSnapshot, so no existence path).

useGsapKeyframeOps synthesizes shadowKeyframeOp for addKeyframe /
addKeyframeBatch / removeKeyframe; the commit chokepoint dispatches the
keyframe-fidelity diff alongside the existing tween-fidelity path.

sdkShadow.ts: runShadowDispatch now emits dispatched:false reason:unmapped_type
if a future PatchOperation type ever escapes patchOpsToSdkEditOps, so the gap
surfaces in telemetry instead of vanishing.

Tests: sdkShadowGsapKeyframe.test.ts (18) covers index resolution, op mapping,
the ambiguity guard, the keyframe-aware diff, the runner, and the unmapped-type
guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vanceingalls added a commit that referenced this pull request Jun 16, 2026
…1509)

* fix(studio): suppress shadow-parity false positives in timing + text

runShadowTiming: compare start/duration with a relative epsilon (1e-6)
instead of exact equality so float-precision drift (3.1 vs
3.0999999999999996, 21.36 vs 21.360000000000014) no longer flags; a real
difference (3.1 vs 3.5) still flags. trackIndex stays exact.

property:text resolver: trim both sides (snapshot.text is already trimmed)
and collapse empty-string vs absent (null) text so trailing-whitespace and
empty-vs-null no longer flag. Genuine text differences are unaffected; the
per-keystroke length lag is a caller-side debounce concern.

Adds tests for both fixes plus regression tests documenting two REAL SDK
divergences the shadow correctly surfaces (transform-origin removal no-op;
duplicate-bare-id delete resolution) — flagged, not fixed here.

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

* feat(studio): shadow telemetry for GSAP keyframe ops (gsap_keyframe)

Wire the SDK shadow-parity telemetry to cover GSAP keyframe add/remove,
the primary unwired cutover signal, plus a defensive unmapped-PatchOperation
guard.

New packages/studio/src/utils/sdkShadowGsapKeyframe.ts:
- ShadowKeyframeOp + keyframeOpToEditOp: maps studio percentage-based keyframe
  ops to SDK EditOps. add -> addGsapKeyframe{position:percentage}; remove ->
  removeGsapKeyframe{keyframeIndex}, resolving percentage -> index against the
  pre-op script with ~0.001 tolerance and a no-op-on-ambiguity guard for
  duplicate-percentage keyframes (PR #1498 landmine).
- gsapKeyframeFidelityMismatches: reuses gsapFidelityMismatches for the
  tween-level diff and layers a keyframe-array comparison (which the base diff
  doesn't inspect), matched by GSAP animation id.
- runShadowGsapKeyframeFidelity: serialize-diff runner emitting op tag
  gsap_keyframe (no keyframe reader on ElementSnapshot, so no existence path).

useGsapKeyframeOps synthesizes shadowKeyframeOp for addKeyframe /
addKeyframeBatch / removeKeyframe; the commit chokepoint dispatches the
keyframe-fidelity diff alongside the existing tween-fidelity path.

sdkShadow.ts: runShadowDispatch now emits dispatched:false reason:unmapped_type
if a future PatchOperation type ever escapes patchOpsToSdkEditOps, so the gap
surfaces in telemetry instead of vanishing.

Tests: sdkShadowGsapKeyframe.test.ts (18) covers index resolution, op mapping,
the ambiguity guard, the keyframe-aware diff, the runner, and the unmapped-type
guard.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vanceingalls vanceingalls force-pushed the 06-15-feat_sdk_studio_ws-1.1_add_set_method_to_gsaptweenspec_route_addgsapanimation_set_through_sdk branch from cffb15b to 3641977 Compare June 17, 2026 03:04
@vanceingalls vanceingalls force-pushed the 06-15-feat_sdk_studio_ws-1.1_add_set_method_to_gsaptweenspec_route_addgsapanimation_set_through_sdk branch from 3641977 to cf3ebe8 Compare June 17, 2026 04:48
@vanceingalls vanceingalls force-pushed the 06-15-feat_sdk_studio_ws-1.1_add_set_method_to_gsaptweenspec_route_addgsapanimation_set_through_sdk branch from cf3ebe8 to 7d6a0d3 Compare June 17, 2026 05:22
@vanceingalls vanceingalls force-pushed the 06-15-feat_sdk_studio_ws-1.1_add_set_method_to_gsaptweenspec_route_addgsapanimation_set_through_sdk branch from 7d6a0d3 to d983672 Compare June 17, 2026 05:53
@vanceingalls vanceingalls force-pushed the 06-15-feat_sdk_studio_ws-1.1_add_set_method_to_gsaptweenspec_route_addgsapanimation_set_through_sdk branch from d983672 to e45b3fc Compare June 17, 2026 08:22
@vanceingalls vanceingalls force-pushed the 06-15-feat_sdk_studio_ws-1.1_add_set_method_to_gsaptweenspec_route_addgsapanimation_set_through_sdk branch from e45b3fc to fe1de56 Compare June 17, 2026 17:32

@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.

Review — Miga

Via's review already nailed the headline finding (the duplicate `removeGsapKeyframe` union shape — the band-aid pattern #1 issue at `types.ts:104-105`). That's the blocking item and I fully co-sign the request-changes verdict on it. I won't rehash what Via said there — go with option 2 (retire the `keyframeIndex` variant). Everything below is additive.

What I like

The `dispatchGsapOpAndPersist` extraction is genuinely well done. The before/after serialization diff as a universal "did anything happen?" gate is elegant — it collapses what was turning into copy-paste boilerplate across every cutover helper into one clean function with a dispatch callback. The comment about stale animationIds falling through to server-side is exactly the kind of reasoning that should live in code, and it does. Good.

The cascade-delete in `removeElementFromHtml` is the right call architecturally. Orphaned GSAP tweens after element deletion was clearly a real bug waiting to happen (or already happening), and anchoring the cleanup at the HTML parser layer means it catches every deletion path, not just the Studio hook path. Solid.

Findings

(1) `applyGsapOp` returns `undefined` for non-GSAP ops — sentinel value in a typed function.
`mutate.ts:137-176`: `applyGsapOp` returns `MutationResult | undefined`, where `undefined` means "not a GSAP op." The caller (`applyOp`) checks `if (gsap !== undefined) return gsap;`. This works, but `undefined` as a control-flow sentinel in a function that also returns `EMPTY` (which is a valid `MutationResult`) means future readers have to reason about when "no result" means "wrong op type" vs. "op ran but was a no-op." A discriminated result type (`{ handled: false } | { handled: true; result: MutationResult }`) would be self-documenting, but I'll concede this is a taste call, not a bug. Flagging it because the split-dispatch pattern will likely grow more arms.

(2) `handleDeleteAllForSelector` iterates in reverse, but `removeAnimationFromScript` operates on string positions.
`mutate.ts:705-720`: The `.reverse()` on the matching array is smart — it processes removals from bottom-to-top to avoid invalidating earlier string offsets. But this only works if `removeAnimationFromScript` internally re-parses on each call (which it does — each call to `parseGsapScriptAcornForWrite` gets fresh AST positions from the current string). So the reverse iteration is actually unnecessary — each removal gets a fresh parse of the updated string. Not a bug, but it's misleading: a reader sees `.reverse()` and thinks "ah, offset stability trick" when in fact the function is re-parsing each time anyway. Either drop the `.reverse()` (it's a no-op) or add a comment explaining you're paying the re-parse cost per removal. For scripts with many animations targeting one selector, that's O(n^2) parses — probably fine for realistic n, but worth noting.

(3) Tolerance constant duplication.
The `TOLERANCE = 0.001` in `handleRemoveGsapKeyframeByPercentage` (`mutate.ts:803`) and the `> 0.001` in the optimistic cache filter (`useGsapKeyframeOps.ts:165`) are the same value expressed two different ways. Via mentioned this — I want to reinforce it. This should be a shared constant. When someone inevitably tweaks one and not the other, the optimistic UI will show a keyframe as removed while the SDK layer keeps it (or vice versa). One `const KEYFRAME_PERCENTAGE_TOLERANCE = 0.001` in a shared location, imported by both.

(4) `removeProperty` in `useGsapPropertyDebounce.ts` — async callback in `useCallback` with mutable closure over `sdk`.
`useGsapPropertyDebounce.ts:117-153`: The `removeProperty` callback captures `sdk` (destructured as `{ sdkSession, sdkDeps, activeCompPath }`) inside an async function. If the component re-renders with a new `sdk` value between the `removeProperty` call and the `await` resolution, the fallback path (`commitMutationSafely`) uses the closure-captured `sdk` value, not the current one. In practice this is probably fine because: (a) the SDK session doesn't change mid-interaction, and (b) the fallback path doesn't use `sdk` — it goes through `commitMutationSafely` which has its own refs. But the pattern is worth watching as more async cutover callbacks land. A `useRef` for the SDK session would close this class of bugs preventatively.

(5) Removed re-exports in `useDomEditCommits.ts` — breaking change for external consumers?
Lines 18-19 and 47-49 removed `export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE }` and `export type { PersistDomEditOperations }`. Via verified these route directly to their source modules now. Just confirming: is `useDomEditCommits` part of the public API surface, or is it internal to `studio`? If any external package imports from this path, that's a breaking change. If it's internal-only, clean removal.

CI

All green as of this review — regression, player-perf, preview-regression, all 8 shards passing. Via's earlier review noted failures; looks like Vance addressed them with follow-up commits. Good to go on CI.

Verdict

I agree with Via's request-changes on the duplicate union shape. Everything else here is comment-tier — solid refactor, good architectural choices, a few places where shared constants and documentation would pay dividends. The cascade-delete is the kind of defensive cleanup that prevents real production bugs.

Once the `removeGsapKeyframe` union is cleaned up (option 2 is the right answer — retire `keyframeIndex`), this is ready.

LGTM modulo that one fix — pinging @magi for the stamp. <@U0B1J4SL8H3>

— 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.

Independent pass, layered on Via + Miga.

Verdict. Request-changes — co-signing Via's headline blocker on the duplicate removeGsapKeyframe union shape at packages/sdk/src/types.ts:104-105. Miga also co-signed. I verified independently: both variants share type: "removeGsapKeyframe" (same tag), the dispatcher at mutate.ts:148-154 discriminates via "percentage" in op, and Studio dispatches only the percentage variant at sdkCutover.ts:266. The keyframeIndex variant survives purely for the mutate.gsap.test.ts:373-389 test. Three reviewers, same finding — please don't ship the API surface this way.

Reach for Via's option 2 (retire the keyframeIndex variant + update the test) — there are no production callers. If you do retire it, also clean up handleRemoveGsapKeyframe/resolveKeyframe (mutate.ts:903-913, 999-1014) which are reachable only via the dead variant.

Forward-stack flag. I checked #1500 — the PR body claims "Retires the keyframeIndex variant of removeGsapKeyframe; only percentage-based removal remains" but the diff at 262854c does not touch the removeGsapKeyframe union — both variants are still present at types.ts:104-105 at the stack tip. So the retirement isn't happening in #1500 either. Worth doing it as part of this PR (where the percentage variant lands) rather than letting it slip to a follow-up.

Additional concern (fresh). The dispatchGsapOpAndPersist factor at useGsapCutover.ts:223-247 is well-shaped, but the before/after string-identity gate (before === after → server fallback) is now the universal "did anything happen?" check for every cutover helper. When parseGsapScriptAcornForWrite returns null (parse failure) or when the writer silently returns the input on a parser miss, this looks identical to "no-op succeeded" to the caller and triggers a silent server fallback. Miga touched on this; Via touched on it. I want to amplify: as Group D adds five more ops routing through this same gate (#1499 adds five), the silent-degradation surface grows. A trackStudioEvent("sdk_cutover.before_after_fallback", { opType }) on the fallback path would let oncall catch parser/writer regressions before users do.

Concern on tolerance duplication (Miga finding #3). TOLERANCE = 0.001 in mutate.ts:803 and the > 0.001 filter in useGsapKeyframeOps.ts:165 should be a shared constant exported from gsapSerialize.ts or similar. The risk isn't today — it's when someone tweaks one in six months and the optimistic UI silently diverges from the SDK truth.

Nit (verified). Miga's #2 (reverse iteration in handleDeleteAllForSelector) — confirmed, the .reverse() is a no-op since each removeAnimationFromScript re-parses. Either drop the .reverse() or add a comment. The O(n²) re-parse cost is fine for realistic n but worth a comment.

CI. All shards green on 2c368d1 — good.

— Rames D Jusso

(Reviewed at 2c368d1f2431750b25f2a2448ad6ac4d6ef56a0b. Part of 18-PR HF SDK cutover stack, Group D review pass.)

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Second pass at HEAD 2c368d1f, after rebase from prior cleared SHA d71dd71a. Co-reviewers (Miga, Rames) have landed independent passes at this SHA; both co-signed the original blocker.

Verdict. Request changes — duplicate removeGsapKeyframe union shape still present at current head; the prior verdict transfers, and the rebase did not address it.

Re-verification of the headline blocker. Read packages/sdk/src/types.ts directly at 2c368d1f:

| { type: "removeGsapKeyframe"; animationId: string; keyframeIndex: number }
| { type: "removeGsapKeyframe"; animationId: string; percentage: number }

Both variants live at types.ts:104-105. Same type tag, same animationId, differing only in keyframeIndex vs percentage. The dispatcher at mutate.ts discriminates via "percentage" in op. TypeScript narrowing collapses these to one union member with two mutually-exclusive optional fields at every call site that does not pre-narrow.

I traced every production caller again. Only sdkCutover.ts dispatches removeGsapKeyframe, and it passes percentage. The keyframeIndex variant survives purely so the test at mutate.gsap.test.ts:373-389 stays green. Band-aid pattern #1 head-on: two shapes for the same op name, one is prod, one is test-only. Three reviewers (myself, Miga, Rames) all flagged it. None of the three is willing to clear without retirement.

Reach for option 2 (retire the keyframeIndex variant + update the test to use percentage). The handler handleRemoveGsapKeyframe at mutate.ts:903-913 and its resolveKeyframe helper at mutate.ts:999-1014 become unreachable once the dead variant is gone — delete them in the same commit. Option 1 (rename removeGsapKeyframeByIndex) is the fallback if you discover a hidden caller; the search I did suggests none exists.

Concur with Rames's forward-stack flag on #1500. The PR body of #1500 claims "Retires the keyframeIndex variant of removeGsapKeyframe; only percentage-based removal remains." I re-checked #1500 at 262854ce: both variants are still present at types.ts:104-105. The retirement isn't happening anywhere in the stack as of the current heads. Please either land it as part of this PR (where the percentage variant first ships) or fix the #1500 PR body so the next archaeologist isn't misled into thinking it landed.

Title-vs-scope drift (worth surfacing). Title says ws-1.2 — percentage-based removeGsapKeyframe. The branch actually carries four commits beyond the prior reviewed SHA:

  • 8437d920 ws-1.2 percentage-based removeGsapKeyframe
  • c5df306f ws-1.3 removeGsapProperty SDK op + Studio hook cutover
  • 00209722 ws-1.4 deleteAllForSelector SDK op + Studio hook cutover
  • 2c368d1f WS-2 cascade-remove in removeElementFromHtml

The PR genuinely lands ws-1.2 + ws-1.3 + ws-1.4 + WS-2. The cutover refactor, the dispatchGsapOpAndPersist factor, and the cascade-delete behavior are three distinct ideas under a one-line title. Future archeology will be hampered. Either rename the PR to reflect all four scopes (ws-1.2 + ws-1.3 + ws-1.4 + WS-2 cascade-remove) or split into separate PRs — your call. Don't gatekeeping on naming, but flagging explicitly so the archeology is unhampered. The PR-body refresh (Miga and I both asked) would also help; for +316/-95 across 9 files the boilerplate body is doing it no favours.

Other findings (re-verified at current head, all non-blocking).

  • packages/core/src/parsers/htmlParser.ts:677-710 (cascadeRemoveGsapById). The text.includes("gsap") || text.includes("ScrollTrigger") heuristic skips scripts that animate via an aliased GSAP global. Cheap detection: in your tree, do all GSAP scripts mention gsap literally? If yes, fine — but it's a footgun for any future block that aliases the global.
  • Same function — selectorTargetsId matches #id, [data-hf-id="id"], and [data-hf-id='id']. Does not match descendant or compound selectors that name the id (.parent #id, #id .child, #id, #other). Almost certainly intentional — those target by-id-as-component, not the element itself — but undocumented. A one-line comment explaining the conservative match closes the door.
  • packages/sdk/src/engine/mutate.ts handleDeleteAllForSelector — the inline note says it skips stripStudioEditsFromTarget because "studio path offset is cosmetic once all animations are gone." Fine for the immediate frame but leaves a stale data-hf-studio-path-offset attribute on the element. If a subsequent op re-adds an animation to that selector before reload, the offset is silent prior-state contamination. Worth a comment cross-referencing the session-reload assumption.
  • handleRemoveGsapKeyframeByPercentage TOLERANCE = 0.001 and the matching tolerance Studio uses at useGsapKeyframeOps.ts:166 (> 0.001) — both moved from 0.2 to 0.001. Both should be a shared constant exported from gsapSerialize.ts or similar (Miga and Rames both flagged this). Today's risk: someone tweaks one in six months and the optimistic UI silently diverges from the SDK truth. Cheap fix; do it in the same PR that retires the dead variant.
  • Concur with Miga's .reverse() no-op observation on handleDeleteAllForSelector — each removeAnimationFromScript re-parses on each call, so the reverse iteration buys nothing. Either drop the .reverse() or add a comment explaining you're paying the re-parse cost per removal (O(n²) parses for many animations targeting one selector). Realistic n keeps this fine.
  • Concur with Miga's applyGsapOp undefined-as-sentinel observation. MutationResult | undefined where undefined means "not a GSAP op" but EMPTY is a valid MutationResult means future readers have to reason about when "no result" means "wrong op type" vs "op ran but was a no-op." A discriminated { handled: false } | { handled: true; result: MutationResult } would be self-documenting. Taste call, but the split-dispatch pattern will likely grow more arms (it already did by #1500).
  • Concur with Rames's silent-server-fallback observability finding. dispatchGsapOpAndPersist's before/after string-identity gate (before === after → server fallback) cannot distinguish "writer returned input unchanged because the op was a true no-op" from "writer silently failed and returned input because of a parser miss." As Group D adds five more ops routing through the same gate at #1499, the silent-degradation surface grows. A trackStudioEvent("sdk_cutover.before_after_fallback", { opType }) on the fallback path would let oncall catch parser/writer regressions before users do. Not a blocker for this PR; raising as a follow-up that's worth a single commit.

Dispatch chain (re-traced at current head).

  • removeGsapProperty — handler at mutate.ts:690, validator at mutate.ts:909, Studio dispatch at useGsapPropertyDebounce.ts:121. ✓
  • deleteAllForSelector — handler at mutate.ts:705, validator at mutate.ts:910, Studio dispatch at useGsapAnimationOps.ts:87. ✓
  • removeGsapKeyframe percentage variant — handler at mutate.ts:797, dispatched via dispatchRemoveGsapKeyframe. ✓
  • removeGsapKeyframe keyframeIndex variant — handler at mutate.ts:903-913, dispatched nowhere in production. Dead. ← the blocker.

CI. All shards green on 2c368d1f — regression, player-perf, preview-regression, all 8 shards. The prior preflight noise has settled.

Hold at 2c368d1f pending union-shape cleanup. Re-verify after the fix push.

Review by Via

vanceingalls added a commit that referenced this pull request Jun 17, 2026
…review #1498)

EditOp had two removeGsapKeyframe members with the same discriminant but
different shapes (keyframeIndex vs percentage) — TS can't discriminate them and
a handler could get the wrong shape. Per both reviewers (option 2): retire the
keyframeIndex variant. It had no production caller (Studio dispatches percentage
only); removed the dead by-index handleRemoveGsapKeyframe + simplified the
dispatcher. resolveKeyframe stays (setGsapKeyframe still uses keyframeIndex).
Converted the one by-index test to the percentage API.

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

Copy link
Copy Markdown
Collaborator Author

Review findings addressed:

miguel-heygen
miguel-heygen previously approved these changes Jun 17, 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.

Approved as part of SDK cutover stack. Reviewed by Miga, Rames D Jusso, and Via across R1-R4. LGTM.

jrusso1020
jrusso1020 previously approved these changes Jun 17, 2026

@jrusso1020 jrusso1020 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.

Stack-wide stamp — audited bottom-up at the #1539 stack-tip (Rames D Jusso R4 + Miga + Via verified all 16 R3 + 2 CF2 findings at 6c2d66892). SDK-cutover chain cleared end-to-end.

@vanceingalls vanceingalls force-pushed the 06-15-feat_sdk_studio_ws-1.1_add_set_method_to_gsaptweenspec_route_addgsapanimation_set_through_sdk branch from fe1de56 to 224b071 Compare June 17, 2026 23:48
Base automatically changed from 06-15-feat_sdk_studio_ws-1.1_add_set_method_to_gsaptweenspec_route_addgsapanimation_set_through_sdk to main June 17, 2026 23:48
@vanceingalls vanceingalls dismissed stale reviews from jrusso1020 and miguel-heygen June 17, 2026 23:48

The base branch was changed.

vanceingalls and others added 4 commits June 17, 2026 16:48
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
…tover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
…cutover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
@vanceingalls vanceingalls merged commit ceb815c into main Jun 17, 2026
25 of 26 checks passed
@vanceingalls vanceingalls deleted the sdk-ws1-completion branch June 17, 2026 23:49
vanceingalls added a commit that referenced this pull request Jun 17, 2026
…review #1498)

EditOp had two removeGsapKeyframe members with the same discriminant but
different shapes (keyframeIndex vs percentage) — TS can't discriminate them and
a handler could get the wrong shape. Per both reviewers (option 2): retire the
keyframeIndex variant. It had no production caller (Studio dispatches percentage
only); removed the dead by-index handleRemoveGsapKeyframe + simplified the
dispatcher. resolveKeyframe stays (setGsapKeyframe still uses keyframeIndex).
Converted the one by-index test to the percentage API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vanceingalls added a commit that referenced this pull request Jun 17, 2026
…) (#1539)

* fix(studio): restore timeline move/resize fallback parity (review #1466)

The §3.2 sdkTimingPersist rewrite regressed the non-SDK fallback path vs the
pre-cutover behavior. Restored, on both fallback entry points (no-session and
sdkTimingPersist-returned-unhandled):

- Resize live DOM patch dropped the conditional data-playback-start/media-start
  attr — restored so a start-trim updates the preview's in-point immediately.
- Move/resize fallback dropped the GSAP-position sync (shift/scaleGsapPositions)
  + reloadPreview — restored so server-path edits keep GSAP tweens in sync and
  refresh the preview (the SDK path folds both into setTiming).
- Undo-coalesce drift: fallback enqueueEdit carried no coalesceKey while the SDK
  branch did — plumbed coalesceKey through persistTimelineEdit so undo
  granularity is identical on either path.
- Documented the hasPbsAdjustment second clause + sdkTimingPersist before-capture
  transition limitation.

Flag-off (dark launch) so this lands as one fix PR at the stack tip rather than
restacking the mid-stack §3.2 commit. #1500 review items: parity-harness gap
already closed at the tip (arc/unroll recast-vs-acorn parity added); blockRemoveRange
flagged 'potential' but verified correct (no comma residue on any block position).

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

* fix(sdk): retire duplicate removeGsapKeyframe keyframeIndex variant (review #1498)

EditOp had two removeGsapKeyframe members with the same discriminant but
different shapes (keyframeIndex vs percentage) — TS can't discriminate them and
a handler could get the wrong shape. Per both reviewers (option 2): retire the
keyframeIndex variant. It had no production caller (Studio dispatches percentage
only); removed the dead by-index handleRemoveGsapKeyframe + simplified the
dispatcher. resolveKeyframe stays (setGsapKeyframe still uses keyframeIndex).
Converted the one by-index test to the percentage API.

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

* fix(studio): gate ALL cutover persist paths on the flag — true dark launch (review #1469 finding #6)

Only sdkCutoverPersist (style/text/attr) checked STUDIO_SDK_CUTOVER_ENABLED.
sdkTimingPersist, dispatchGsapOpAndPersist (every GSAP op) and sdkDeletePersist
guarded only on `!sdkSession` — and useSdkSession opens a session by default
for shadow/selection, so timing/GSAP/keyframe/delete cutover was ALWAYS live
regardless of the flag. Flipping the flag OFF could not disable it, so the
data-loss bugs in those paths (single-prop wipe, wrong-keyframe match, tween
collapse, arc strip) ship LIVE on merge instead of being dark-launched.

Added the flag guard at all three chokepoints → flag OFF returns false → callers
fall back to the legacy server path. Makes the stack genuinely dark-launchable:
merge is now a no-op in prod, and the remaining cutover correctness bugs become
flip-prerequisites rather than merge-blockers.

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

* fix(core,sdk): correct 8 GSAP write-path review findings (#1539)

Eight correctness bugs from the SDK-cutover review. Several were cases where
BOTH writers were identically wrong, so the recast-vs-acorn parity suite stayed
green; the new tests assert the real-world-correct result, not agreement.

- #2 findKfPropByPct: match the CLOSEST keyframe within tolerance, not the first
  within 2% — removing/updating 50% on 0/49/50/100 no longer hits 49%.
- #3 handleSetTiming: shift each tween by the start DELTA and scale duration by
  the clip-duration RATIO per-tween, instead of writing absolute newStart/
  newDuration onto every tween (which collapsed staggers and blew durations).
- #4 enableArcPath: insert motionPath via appendRight at the object start so the
  insertion can't collide with the x/y remove-range end (which made MagicString
  discard the append and emit '{}').
- #5 splitAnimationsInScript: compute the inherited baseline in a forward pre-pass
  so the split-spanning midpoint sees earlier tweens (the reverse write loop is
  kept for stable count-suffixed ids).
- #9 unrollDynamicAnimations: preserve non-target loop-body statements (e.g.
  tl.set initial-state) per iteration instead of overwriting the whole loop.
- #10 buildMotionPathObjectCode (both writers): emit the cubic form when segment
  curviness varies so per-segment curviness survives, not just segments[0].
- #11 readLastWaypointXY: handle UnaryExpression so negative destination coords
  are recovered when disabling an arc path.
- #15 no-bang: removed every `!` non-null assertion in the touched files,
  replaced with guards/fallbacks.

Tests: gsapWriter.reviewFixes.test.ts (#2/#4/#5/#9/#10/#11) and
mutate.gsap.test.ts setTiming GSAP-sync block (#3). All fail on the base and
pass after the fix; tsc + full core/sdk suites + parity stay green.

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

* fix(studio): SDK cutover review fixes — merge tween props, stabilize debounce, serialize gsap writes, on-disk undo baseline, self-write identity

Addresses 5 SDK-cutover review findings (studio-only):

- #1 useGsapPropertyDebounce: editing one GSAP tween property no longer drops
  the tween's other animated props. setGsapTween REPLACES the property set, so
  merge the single edit into the tween's CURRENT properties (read from the SDK
  doc) before dispatching, mirroring the legacy server merge.
- #7 useGsapPropertyDebounce: stabilize the flush callback by reading sdk deps
  from a ref instead of an unmemoized literal, so a parent re-render mid-edit
  no longer tears down + flushes the debounce (one commit/undo entry per render).
- #8 sdkCutover/useGsapScriptCommits: route SDK gsap-write persists through the
  same per-file keyed serializer the legacy commitMutation uses, so concurrent
  same-file read-modify-writes can't interleave and lose an edit.
- #12 sdkCutover/useTimelineEditing: capture the exact on-disk bytes as the undo
  'before' for timing/GSAP persists (matching the style/delete paths) instead of
  a normalized SDK serialize() re-emit that reformatted the whole file on undo.
- #14 useSdkSession/sdkSelfWriteRegistry: discriminate a cutover echo from an
  undo write by CONTENT identity (registered self-write hash), not just the 2 s
  timestamp window — an undo write always reloads the SDK session.

Tests: useGsapPropertyDebounce(.test), useGsapPropertyDebounceFlush.test,
sdkSelfWriteRegistry.test, and new sdkCutover.test cases; each reproduces the
review scenario and asserts the corrected behavior (verified red before fix).

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

* refactor(core): extract split/collapse helpers to satisfy no-fallow-ignore rule

The #5 (split) and #15 (no-bang guards) fixes pushed splitAnimationsInScript and
removeAllKeyframesFromScript over fallow's complexity threshold, and a fallow-ignore
had been added to splitAnimationsInScript. Per the hard rule (never ignore — fix),
extracted buildSpanningSplit + applyTweenSplit (split) and buildCollapsedFlatVars
(collapse), and removed the ignore. Both functions now under threshold; fallow new-only
gate reports 0 new findings. Behavior unchanged — core 1811 green.

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

* test(studio): pin dark-launch flag-gate contract (review #1539, Rames/Via)

flag OFF ⇒ sdkTimingPersist / sdkGsapTweenPersist (GSAP-op chokepoint) /
sdkDeletePersist all return false even with a valid session → legacy fallback.
The prod flag-flip rests on this contract; sdkCutover.test.ts only mocks the flag
TRUE, so a future gate refactor could silently re-enable cutover on flag-off
without failing CI. This sibling file mocks it FALSE and locks the three guards.

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

* fix(studio): leading flag-gate on sdkGsapTweenPersist (review #1539 nit, Via)

The add-op getElement existence check ran before the inner gate, so flag-off did
an SDK touch before falling back. Lead with the flag guard to match the other
three chokepoints — flag-off is now a clean no-op at every entry point.

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

* fix(core): unroll-preservation regressions — non-for loops + AST index substitution (review R2)

The #9 unroll-preservation fix had two confirmed regressions:
- Non-for loops (forEach/for-of/for-in/while): loopIndexVarName returns null, so
  substitution no-op'd and preserved siblings kept a now-undefined loop variable
  (e.g. `item`) → ReferenceError at render. Now returns null for those forms →
  caller falls back to the blanket loop overwrite (drops siblings, valid code).
  The #9 fixture only used `for(let i…)` so it never caught this.
- substituteLoopIndex did a \bvar\b regex over raw source including string
  literals, corrupting selectors like ".row-i" → ".row-0". Now AST-based:
  substitutes only real Identifier uses, skipping string literals and non-computed
  member/key positions (extracted isIndexBindingPosition helper to stay under the
  fallow complexity threshold — no ignore added).

Two regression tests added (forEach no-dangling-var; for-loop string-literal intact).

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

* fix(sdk,core): unrollDynamicAnimations rejects empty element list (R1 #1501b)

An empty `elements` array has no unrolled form — the writer would overwrite
the loop/statement with zero tween calls, silently deleting the animation.

- gsapWriterAcorn: unrollDynamicAnimations returns the script verbatim on an
  empty list (no-op instead of a destructive overwrite).
- validateOp: reject unrollDynamicAnimations with empty elements as
  E_INVALID_ARGS so callers get a clean error rather than silent corruption.
- Tests: writer no-op on []; validateOp E_INVALID_ARGS on [].

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

* perf(sdk): cache draft element in applyDraft, drop HTMLElement casts (R1 #1490a)

applyDraft runs at 60fps during a drag but re-ran doc.querySelector on every
call — the _draftEl/_draftId fields were only consumed by commit/cancel, never
to skip the query. Reuse the tracked element when the id matches and the node
is still connected; re-query only on id change or detach (iframe reload).

Retypes _draftEl to HTMLElement | null (only ever set from
querySelector<HTMLElement>), which removes the `as HTMLElement` casts in
commitPreview / _clearDraft. Test asserts a repeated same-id drag queries once.

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

* fix(sdk,core): round-3 correctness — unroll AST safety, single-dispatch undo, empty-arg guards, persist decouple

Addresses the highest-severity round-3 review findings:

- gsapWriterAcorn unroll (R3 #1/#2/#9): the round-2 AST-substitution fix emitted
  invalid GSAP for object shorthand `{ i }` (→ `{ 0 }`) and shadowed inner
  bindings (→ `for(let i=0;0<3;0++)`), and silently dropped sibling statements on
  non-`for` loops (forEach/for-of). The unroll now REFUSES (no-ops, leaving the
  dynamic loop intact) whenever siblings can't be safely reproduced — a non-`for`
  loop, an unmodeled statement, or an unsafe index use — instead of dropping or
  corrupting. Plain `for` loops with safe siblings still unroll.

- session single-dispatch undo (R3 #5/#11): _dispatch now reverses the inverse
  patch list (parity with batch()). A single op emitting order-dependent inverse
  patches — a nested parent+child removeElement, an aliased multi-target — undid
  forward and dropped the child subtree / landed on an intermediate value.

- materializeKeyframes empty-array (R3 #10): the unguarded twin of the just-fixed
  unrollDynamicAnimations. Writer no-ops on an empty keyframe list; validateOp
  rejects it as E_INVALID_ARGS (shared gsapScriptMissing helper).

- history:false persist decouple (R3 #4): persist (auto-save) no longer lives
  inside the history-enable block, so opting out of SDK undo no longer silently
  disables all disk writes (data-loss trap for #1496's flag consumers).

Tests: unroll refuse cases (shorthand/shadow/forEach) + safe-for-loop regression;
nested removeElement undo; materializeKeyframes writer no-op + validateOp reject;
history:false-still-persists.

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

* fix(core): stripGsapForId re-parses per removal so all tweens for a deleted element are stripped (R3 #3)

Animation ids are count-based (positional), so removing one tween renumbers the
survivors. stripGsapForId captured every matching id from a single up-front parse
then removed against the mutating script — after the first removal the later ids
were stale and silently no-op'd, leaving an orphaned tl.to() referencing the
just-deleted element. Now re-parse after each removal and strip the first
still-matching animation until none remain.

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

* fix(core): gsap writer — keyframe ease routing, convert preserves delay, addLabel dedup (R3 #7/#8/#12)

- #7: updateAnimationInScript routes an ease update on a keyframe tween to
  keyframes.easeEach (per-keyframe), not a top-level ease that GSAP ignores —
  the user's keyframe-easing edit was silently a no-op.
- #8: convertToKeyframesFromScript now preserves every non-editable vars key
  (delay/callbacks/stagger/yoyo/…) verbatim via preservedVarsEntries instead of
  rebuilding from the GsapAnimation object, which had no `delay` field and
  dropped it — shifting the tween's start time.
- #12: addLabelToScript moves an existing same-named label (overwrites its
  position) instead of appending a duplicate; duplicates made removeLabel
  over-remove (it deletes every match, including a pre-existing label).

Tests: easeEach routing, delay preservation, addLabel move-not-duplicate +
hand-authored-dup removal. Updated the old "no dedup contract" corpus test.

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

* fix(sdk): handleSetTiming #domId + data-duration sync; validateOp resolves ids + arc/selector (R3 #6/#13, CF2 #15/#16)

CF2 #15: handleSetTiming re-synced GSAP tweens only when the selector matched the
element's hf-id. The common #domId-targeted tween (authored by the Studio panel)
never matched, so moving/resizing a clip via the SDK timing path left its
animations unsynced. Now match the tween selector against the DOM id too.

CF2 #16: handleSetTiming read/wrote only data-end. Clips authored with
data-duration (what the runtime prefers) got a fresh data-end beside a stale
data-duration (no playback change) and oldDuration=null collapsed the GSAP
duration-scale ratio to 1. Now read duration preferring data-duration, and write
back to whichever attribute the clip uses (timingPath gains a "duration" field).

R3 #13b: deleteAllForSelector compared selectors with strict === and missed the
alternate quote style ([data-hf-id='x'] vs "x"); now quote-insensitive.

R3 #6/#13a: validateOp now resolves the animationId for id-bearing GSAP ops
(E_TARGET_NOT_FOUND instead of a misleading ok that no-ops at apply), and
updateArcSegment validates the arc is enabled + the segment index is in range.

Tests: #domId move sync, data-duration resize + scale, quote-insensitive delete,
unresolved-id rejection, arc-segment preconditions. Updated the loose-can() test.

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

* refactor(core,sdk): name the acorn-node type alias; keyToPath round-trips timing.duration (R3 #14)

- gsapWriterAcorn: replace the bare `: any` AST-node annotations with the named
  `type Node = any` alias, matching the established convention in
  gsapParserAcorn.ts / gsapInline.ts ("acorn ESTree nodes are structurally
  untyped"). Documents intent and is greppable; type-identical (zero runtime
  change). A full ESTree typing is a deliberate architecture decision the
  codebase has not taken and is out of scope here.
- patches: keyToPath/timingPath now include the "duration" timing field added
  for the data-duration resize fix, so a timing.duration override round-trips on
  T3 replay instead of being dropped.

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

* fix(sdk): cascadeRemoveAnimations re-parses per removal (R4 — SDK twin of #3)

cascadeRemoveAnimations captured every matching animation id from a single
up-front parse, then removed against the mutating script — the SDK-side twin of
the stripGsapForId bug (R3 #3). Animation ids are positional, so removing the
first tween for an element renumbered the survivors and the stale later ids
no-op'd, orphaning those tweens on the just-removed element. Now re-parse after
each removal and strip the first still-matching animation until none remain.

Also adds the reviewer's defense-in-depth test: an aliased multi-target setStyle
(same id twice) undoes to the original, not the intermediate (exercises the
single-dispatch inverse reversal from R3 #5/#11).

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

---------

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.

5 participants