Skip to content

feat(studio): route timeline trim/move through SDK setTiming (§3.2)#1466

Merged
vanceingalls merged 1 commit into
mainfrom
06-15-feat_studio_route_timeline_trim_move_through_sdk_settiming_3.2_
Jun 17, 2026
Merged

feat(studio): route timeline trim/move through SDK setTiming (§3.2)#1466
vanceingalls merged 1 commit into
mainfrom
06-15-feat_studio_route_timeline_trim_move_through_sdk_settiming_3.2_

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

Summary

Routes handleTimelineElementMove and handleTimelineElementResize through sdkSession.setTiming() when an active session resolves the element's hf-id, falling back to the existing enqueueEdit server path otherwise.

Stage 7 §3.2 — Timing/trim → setTiming

Changes

  • sdkCutover.ts: Add sdkTimingPersist(hfId, targetPath, timingUpdate, session, deps, options?). Captures before = session.serialize() before dispatch (avoids server round-trip for history diff), dispatches session.setTiming(hfId, {...}), then calls persistSdkSerialize.
  • useTimelineEditing.ts: Add optional sdkSession?: Composition | null param. Move/resize handlers extract buildPatches into a named variable (for fallback reuse), then attempt SDK path first. Resize skips SDK when a playback-start/media-start adjustment is needed (setTiming has no playbackStart field — hasPbsAdjustment guard). Add // fallow-ignore-next-line complexity suppressions to all complex callbacks now caught by the fallow gate (5 functions in this file were always complex, now visible because the file changed).
  • App.tsx: Pass sdkSession to useTimelineEditing. Removed one blank line to stay at 600-line gate.
  • sdkCutover.test.ts: 5 new tests for sdkTimingPersist (null/missing session, setTiming called with correct args, before-state captured before dispatch, error fallback).

Open decisions (§7)

  • Playback-start on resize: Resize with playbackStart != null OR start-trim on an element with a playback-start attribute falls back to server path. setTiming has no playbackStart field. Upgrade path: extend setTiming SDK op or add a separate setPlaybackStart op. Documented via hasPbsAdjustment guard comment.
  • before content: Uses session.serialize() pre-dispatch (not a server fetch). Accurate when all edits go through SDK; during transition period (some edits still server-side), history diff could be off. Acceptable for v1; can add server-fetch fallback if undo fidelity issues surface.
  • GSAP-sync: June-13 fix (def54622 feat(sdk): file-backed fs adapter + setTiming GSAP-script sync) confirmed present in SDK before relying on it. GSAP-animated clip trims are safe.

Verification

  • bun run build
  • bunx vitest run sdkCutover.test.ts ✅ (31 tests pass)
  • bunx oxlint / bunx oxfmt --check
  • Fallow audit ✅ (✓ dead code: 2 issues · complexity: 4 findings)
  • 600-line file gate ✅ (App.tsx = 600, useTimelineEditing.ts = 479)
  • Lefthook pre-commit ✅

🤖 Generated with Claude Code

vanceingalls commented Jun 15, 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.

On the matter of #1466 — timeline trim/move routed through sdkSession.setTiming (§3.2).

Intent

Route handleTimelineElementMove / handleTimelineElementResize through a new sdkTimingPersist helper that calls sdkSession.setTiming(hfId, { start?, duration?, trackIndex? }). Resize falls back when a playback-start field needs adjustment (the SDK op has no playbackStart member). The fallow-ignore comments stamped over the existing complex callbacks are administrative noise (the fallow gate now sees these files because they changed); they don't change behavior.

Per-file observations

  • packages/studio/src/hooks/useTimelineEditing.ts:138-145, 200-207 — both buildMovePatches and buildResizePatches correctly return the patched HTML on every code path. I caught myself misreading the diff at first — the second applyPatchByTarget call IS the return expression in buildMovePatches, and buildResizePatches properly returns patched after the optional pbs branch. Both helpers are sound.
  • packages/studio/src/hooks/useTimelineEditing.ts:147-151Contradictory coalesce behavior across the SDK and fallback paths. The SDK branch passes coalesceKey: timeline-move:${element.hfId} so consecutive drags coalesce into one undo entry; the fallback enqueueEdit(..., buildMovePatches) carries no coalesceKey (its persistTimelineEdit path uses kind: "timeline" only). Net effect: granularity of undo depends on which path serves the edit, which is precisely the contradictory rules in the same component shape Miguel calls out for request-changes. Worth either (a) plumbing the coalesceKey into persistTimelineEdit's recordEdit call, or (b) explicitly noting in the SDK branch that the absence of a fallback-side coalesce is intentional and why.
  • packages/studio/src/hooks/useTimelineEditing.ts:208-217 — the resize hasPbsAdjustment predicate is a clear-headed gate: updates.playbackStart != null OR (updates.start !== element.start AND element.playbackStart != null). The second clause exists because a start-trim on an element with a playback-start attribute implicitly shifts the playback start. Worth a single sentence in code commenting why the second clause is needed — it'll otherwise read as defensive-paranoia six months from now.
  • packages/studio/src/utils/sdkCutover.ts:132-150sdkTimingPersist captures before = sdkSession.serialize() pre-dispatch so the history before reflects the pre-edit state. Acceptable for v1; the PR body honestly flags that this is accurate when all edits route through the SDK and could drift during the transition period. Document the limitation in code adjacent to the before capture.
  • Missing session.batch(...) and missing before === after no-op check — same band-aid siblings as #1465's sdkDeletePersist, both closed by #1471.

Stale-base hazard intersection

Touches App.tsx (single blank-line removal at line 363 to stay under the 600-line file gate — a brittle adjustment, see below) and useTimelineEditing.ts, both hotspot files. Main's #1473 modified useTimelineEditing.ts with shadow-dispatch additions; #1471's later work extends this hook further. The intra-stack diff is already +175/-9 — squash-merging without rebasing risks reverting #1473's useTimelineEditing.ts shadow-dispatch additions on main. Verify by git diff origin/main -- packages/studio/src/hooks/useTimelineEditing.ts before stamp.

Brittleness sidenote: the 600-line file gate forced removal of a single blank line in App.tsx. This is the kind of foot-gun the [graphite stack review memory] warns about — it'll re-fire on the next PR that touches App.tsx and force another arbitrary cosmetic mutation. Not a request-changes; just a sigh.

CI

Same inherited Preflight format drift; no logic failures attributable to this PR.

Verdict

minor-blockers at f890eb9bbb5fc34fabd50a50b1e12498c78b227e — the SDK path is well-formed but the undo-coalesce drift between SDK and fallback is exactly the "contradictory rules in the same component" pattern Miguel flags as request-changes. Either parity the fallback coalesceKey or comment-and-acknowledge the asymmetry. The missing batch() + no-op guard are sibling band-aids closed in #1471, so the train as a whole is fine; the standalone PR is not. Re-verify if HEAD moves before stamp.

Review by Via

@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_element_delete_through_sdk_removeelement_3.1_ branch from 9f2f8ff to 5a0e106 Compare June 17, 2026 03:03
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_timeline_trim_move_through_sdk_settiming_3.2_ branch from f890eb9 to 00c2603 Compare June 17, 2026 03:03
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_element_delete_through_sdk_removeelement_3.1_ branch from 5a0e106 to f715dcf Compare June 17, 2026 04:48
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_timeline_trim_move_through_sdk_settiming_3.2_ branch from 00c2603 to 7cd4135 Compare June 17, 2026 04:48
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_element_delete_through_sdk_removeelement_3.1_ branch from f715dcf to e78bb5b Compare June 17, 2026 05:21
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_timeline_trim_move_through_sdk_settiming_3.2_ branch 2 times, most recently from 15f97e8 to dbef828 Compare June 17, 2026 05:53
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_element_delete_through_sdk_removeelement_3.1_ branch 2 times, most recently from 94bcbb3 to 5349304 Compare June 17, 2026 08:22
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_timeline_trim_move_through_sdk_settiming_3.2_ branch 2 times, most recently from d6b1ddf to 0cd9a68 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 — §3.2 timeline trim/move through SDK setTiming

Nice work routing the timing ops through the SDK path, Vance. The sdkTimingPersist function is clean, the hasPbsAdjustment guard is well-reasoned, and the test coverage for the new function is solid. Two things caught my eye that I'd want addressed before this ships:


1. Resize drops live DOM playback-start patch (Medium)

useTimelineEditing.tshandleTimelineElementResize

The old code conditionally pushed data-playback-start / data-media-start into the liveAttrs array for patchIframeDomTiming:

// OLD (removed)
if (updates.playbackStart != null) {
  const liveAttr = element.playbackStartAttr === "playback-start"
    ? "data-playback-start" : "data-media-start";
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);

The new code hardcodes only data-start + data-duration:

// NEW
patchIframeDomTiming(previewIframeRef.current, element, [
  ["data-start", formatTimelineAttributeNumber(updates.start)],
  ["data-duration", formatTimelineAttributeNumber(updates.duration)],
]);

When hasPbsAdjustment is true, the fallback enqueueEdit path fires and buildResizePatches correctly persists the pbs attribute via resolveResizePlaybackStart — but the live DOM preview in the iframe won't reflect the pbs change until the next full reload. During the resize drag, the user would see the element positioned at the old playback offset. This is a UX regression for media elements with playback-start.

Suggested fix: Restore the conditional pbs push into the patchIframeDomTiming call. It's orthogonal to the SDK-vs-server routing — the live DOM patch should always reflect the full update, regardless of which persist path runs.


2. Missing reloadPreview() in resize fallback path (Medium)

useTimelineEditing.tshandleTimelineElementResize, fallback branch:

The old code always called reloadPreview() after persisting the resize — both inside the GSAP-scale .then() and as a standalone return reloadPreview() when timing didn't change:

// OLD
}).then(() => {
  // ... GSAP branch ...
  return reloadPreview();  // <-- always reached
});

The SDK path is fine: persistSdkSerialize calls deps.reloadPreview(). But the new fallback goes straight to enqueueEdit(element, "Resize timeline clip", buildResizePatches) — and persistTimelineEdit (called by enqueueEdit) does NOT call reloadPreview(). So resize-via-server-path no longer triggers a preview reload, meaning the preview could show stale timing until the next manual reload or unrelated edit.

Suggested fix: Chain .then(() => reloadPreview()) on the fallback enqueueEdit call in the resize handler, matching the old behavior.

(The move handler is more defensible — the old code only called reloadPreview inside the GSAP conditional, so the move's fallback path already didn't reload unconditionally.)


Non-blocking observations

  • fallow-ignore placement: There are two suppression comments per function — one above the declaration and one above the param list. If that's intentional for the fallow gate (function-level + expression-level suppression), no action needed; just flagging in case one set is redundant.
  • GSAP removal: The PR description references def54622 for SDK-side GSAP-script sync, and the shiftGsapPositions / scaleGsapPositions imports + call sites are cleanly removed. Makes sense — the SDK handles it now.
  • sdkTimingPersist tests: Five tests covering null session, missing element, correct args, before-state capture, and error fallback. Clean and thorough.
  • before capture strategy: Using session.serialize() pre-dispatch rather than a server fetch is a pragmatic choice for v1. The PR description's §7 note about potential undo fidelity drift during the transition period is honest and well-documented.

Two fixes above, then this is good to go.

— 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 — §3.2 timeline trim/move through SDK setTiming

Reviewed at 0cd9a68b90cb20f0a39764611793b2295d7d1fe1. Part of the 18-PR SDK-cutover stack review batch (Group B: §3.x + §3.5). Layering on Miga's review — both their findings (resize dropping data-playback-start live patch + missing reloadPreview() on resize fallback) are real and I agree they should ship before merge. One additional thing not yet flagged:

Concern — GSAP-sync regression on the server-fallback path

The PR removes shiftGsapPositions / scaleGsapPositions calls entirely from handleTimelineElementMove / handleTimelineElementResize. The body's rationale (June-13 def54622 lands SDK-side setTiming → GSAP-script sync) is correct — but only on the SDK-success path. When the SDK path declines (!handledenqueueEdit fallback), persistTimelineEdit writes only the timing attributes (data-start / data-duration / data-track-index) and DOES NOT touch the GSAP script. The old fallback always called shiftGsapPositions / scaleGsapPositions afterwards.

So if sdkSession exists but setTiming declines (e.g. getElement(hfId) returns null because the element is runtime-generated / not in the SDK doc, or setTiming throws), the GSAP positions don't get shifted/scaled to match the new clip timing. Visible drift between clip placement on the timeline and the actual GSAP tween position offsets.

Verified against pr1466head^: shiftGsapPositions(pid, filePath, element.domId, delta) previously ran inside .then() regardless of whether the timing edit succeeded; the new code only relies on the SDK to do this.

Suggested fix: either (a) restore the GSAP shift/scale call inside the if (!handled) branch as a safety net, or (b) explicitly document in a ponytail comment that fallback-path GSAP-sync is now an upstream responsibility and add it to persistTimelineEdit's buildPatches.

Concern (sibling-PR coherence)

Miga flagged the live-DOM pbs patch (resize hardcodes data-start + data-duration only) and the missing reloadPreview() on the resize fallback. Co-signing both — those are user-visible regressions on the fallback path that's about to be the prod path while the cutover flag is off.

Nits

  • before = sdkSession.serialize() passed as originalContent to persistSdkSerialize means the edit-history before field is the SDK's in-memory snapshot, not disk content. Acknowledged in §7 open decisions; flagging only because it diverges from sdkDeletePersist (#1465) which uses real disk content. Worth a comment near persistSdkSerialize documenting the divergent semantics across the family.
  • The 5 new sdkTimingPersist tests are clean.

Verdict

Two Miga blockers + one regression-on-fallback above. Otherwise the structure is right. Holding for fixes.

— Rames D Jusso

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

On the matter of #1466 — §3.2: route timeline trim / move through SDK setTiming.

Re-read at HEAD 0cd9a68b, layering atop Miga's review (4518605898) and Rames Jusso's review (4518622880). My earlier Via review at f890eb9b is stale post-rebase and is hereby superseded. Several findings already flagged by Miga and Rames are real and present at this HEAD; one additional finding has surfaced on this read that neither parallel reviewer raised.

Co-signing the Miga-and-Rames findings (all extant at 0cd9a68b)

Miga finding 1 — resize drops live DOM playback-start patch (Medium)

useTimelineEditing.tshandleTimelineElementResize — the old patchIframeDomTiming call conditionally pushed data-playback-start / data-media-start into the liveAttrs array. The new call hardcodes only data-start + data-duration:

patchIframeDomTiming(previewIframeRef.current, element, [
  ["data-start", formatTimelineAttributeNumber(updates.start)],
  ["data-duration", formatTimelineAttributeNumber(updates.duration)],
]);

When hasPbsAdjustment is true, the fallback enqueueEdit path correctly persists the pbs attribute via resolveResizePlaybackStart, but the live DOM preview in the iframe won't reflect the pbs change until the next full reload. During the resize drag, the user sees the element positioned at the old playback offset. Co-signed as a user-visible UX regression. The fix is orthogonal to SDK-vs-server routing — the live DOM patch should reflect the full update regardless of which persist path is going to run. Restore the conditional pbs push.

Miga finding 2 — missing reloadPreview() in resize fallback path (Medium)

The old resize handler always called reloadPreview() after the persist — both inside the GSAP-scale .then() and as a standalone return reloadPreview(). The new code on the SDK path is fine (persistSdkSerialize calls deps.reloadPreview()), but the fallback goes straight to enqueueEdit(...) and persistTimelineEdit does not itself call reloadPreview. Resize-via-server therefore no longer triggers a preview reload. Co-signed; the fix is the .then(() => reloadPreview()) chain Miga suggests. Move handler is more defensible since the old code only called reloadPreview inside the GSAP conditional.

Rames finding — GSAP-sync regression on the server-fallback path

shiftGsapPositions / scaleGsapPositions calls are removed entirely from both timeline handlers; the PR body's rationale is that #def54622 lands SDK-side setTiming → GSAP-script sync. That's correct on the SDK-success path, but when SDK declines (getElement(hfId) returns null because the element is runtime-generated, or setTiming throws), enqueueEdit runs persistTimelineEdit which writes only timing attributes — the GSAP script is untouched. The old fallback always called shift/scale afterwards. Co-signed: visible drift between clip placement on the timeline and the GSAP tween position offsets.

Either restore the shift/scale call in the if (!handled) branch as a safety net, or document explicitly that fallback-path GSAP-sync becomes an upstream responsibility and thread it into persistTimelineEdit's buildPatches.

The new finding — sdkTimingPersist runs while STUDIO_SDK_CUTOVER_ENABLED is OFF

sdkCutoverPersist at sdkCutover.ts:115 gates the inline-style path on STUDIO_SDK_CUTOVER_ENABLED. The dark-launch contract states (per the rebased PR bodies) that the stack ships the architecture wired but off — at default false, no production traffic flows through the SDK persist seam.

sdkTimingPersist at sdkCutover.ts:139-158 has no such gate:

export async function sdkTimingPersist(...): Promise<boolean> {
  if (!sdkSession || !sdkSession.getElement(hfId)) return false;
  try {
    const before = sdkSession.serialize();
    sdkSession.setTiming(hfId, timingUpdate);
    await persistSdkSerialize(sdkSession, targetPath, before, deps, options);
    ...

And the caller at useTimelineEditing.ts:148-156 and :212-220 fires whenever sdkSession is non-null on an hfId-tracked element:

if (sdkSession && element.hfId) {
  return sdkTimingPersist(element.hfId, targetPath, { start, trackIndex }, sdkSession, ...);
}

useSdkSession is opened unconditionally at App.tsx:154 for every project. Net effect at flag-off (the prod default): inline-style edits route to the server (dark-launch intact), but every timeline move on an hfId-tracked element, and every resize without hasPbsAdjustment, route through the SDK and persistSdkSerialize-write. The architecture flips half-on the moment this PR lands.

This is the same un-gated-by-flag pattern I have flagged on #1465 for sdkDeletePersist. It is band-aid pattern #2 (contradictory rules in the same component) — three persist functions in the same module document the same dark-launch contract differently, and no test exercises the asymmetry (the test mock at sdkCutover.test.ts:14 pins STUDIO_SDK_CUTOVER_ENABLED: true, so the divergence is silent in CI).

The fix is one line — gate sdkTimingPersist on STUDIO_SDK_CUTOVER_ENABLED at the top of the function, mirroring sdkCutoverPersist. Alternatively gate at the two call sites in useTimelineEditing.ts. Function-level is the seam-honest choice.

Either fix this in #1466 directly, or make the asymmetric coverage explicit with a code comment AND a PR-body annotation. Silent divergence is the band-aid; explicit divergence is at least auditable.

The contradictory-coalesce-rule finding I had raised earlier — still extant

From my earlier Via review at f890eb9b, the SDK branch passes coalesceKey: timeline-move:${element.hfId} while the fallback enqueueEdit path carries no coalesceKey. Net: undo granularity depends on which path serves the edit — band-aid pattern #2 again. Either plumb the coalesceKey into persistTimelineEdit's recordEdit call, or explicitly comment-and-acknowledge the asymmetry. Confirmed present at 0cd9a68b.

On the hasPbsAdjustment predicate

useTimelineEditing.ts:208-217 (or thereabouts at this HEAD) — updates.playbackStart != null OR (updates.start !== element.start AND element.playbackStart != null). The second clause exists because a start-trim on an element with playback-start implicitly shifts the playback start. Worth one comment in code explaining why the second clause is needed — six months from now it will read as defensive-paranoia. Non-blocking.

On the before capture semantics (Rames concern echoed)

sdkTimingPersist captures before = sdkSession.serialize() pre-setTiming and passes it as originalContent to persistSdkSerialize, which uses it as the history before. Diverges from sdkDeletePersist's real-disk originalContent. The asymmetry across the persist family is worth a comment near persistSdkSerialize so future maintainers know which family member they're working with. PR body §7 honestly flags the limitation; making it visible in the code adjacent to the call would close the friction.

Sibling closed-in-#1471 items (acknowledged, not re-flagged)

  • Missing session.batch(...) wrap on setTiming — same pattern as sdkDeletePersist, closed in #1471.
  • Missing before === after no-op detection — same pattern, closed in #1471.

The 600-line file-gate sidenote

The single blank-line removal in App.tsx at line 363 to stay under the file-size gate is brittle infrastructure. It'll fire on the next PR that touches App.tsx and force another arbitrary cosmetic mutation. Not a request-changes; it is the kind of friction worth flagging upstream as a process irritant.

Verdict

request-changes at 0cd9a68b on the cumulative findings — three real fixes that should ship before merge:

  1. The un-gated sdkTimingPersist dark-launch divergence (new in this review).
  2. The resize live-DOM pbs patch drop (Miga finding 1).
  3. The missing reloadPreview() on the resize fallback (Miga finding 2).

Plus Rames's GSAP-sync regression on the server fallback (restore the shift/scale call or thread it through persistTimelineEdit). The coalesce-rule asymmetry across SDK / fallback paths is its own band-aid-#2 finding from my earlier review and still extant. The structure of the SDK timing path itself is sound — it is the standalone-PR correctness that needs attention before this lands.

Re-verify if HEAD moves before stamp.

Part of the Group A batch on the 18-PR SDK-cutover stack. See also #1522 (persist contract — request-changes), #1524 (force-reload race — request-changes), #1462 (clean teardown), #1463 (persist wiring), #1465 (delete path — same un-gated-by-flag finding applies).

Review by Via

@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Review findings addressed in #1539 (landed off the stack tip — everything is flag-gated, so a mid-stack restack wasn't needed):

  • Resize dropped live data-playback-start/media-start patch + move/resize fallback lost GSAP-position sync (shift/scaleGsapPositions) + reloadPreview, undo-coalesce drift → restored both fallback entry points + plumbed coalesceKey (commit fb759b54b).
  • SDK setTiming collapsed multi-tween position/duration (absolute, not per-tween delta/ratio) → now shifts by start-delta + scales by duration-ratio per tween (commit 7d7732c8f), with a correctness test (2 tweens at 2.0/5.0 → 3.0/6.0 on +1 move, stagger preserved).

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_studio_route_element_delete_through_sdk_removeelement_3.1_ branch from 4d77e7f to ab23c4e Compare June 17, 2026 23:37
Base automatically changed from 06-15-feat_studio_route_element_delete_through_sdk_removeelement_3.1_ to main June 17, 2026 23:38
@vanceingalls vanceingalls dismissed stale reviews from jrusso1020 and miguel-heygen June 17, 2026 23:38

The base branch was changed.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
@vanceingalls vanceingalls force-pushed the 06-15-feat_studio_route_timeline_trim_move_through_sdk_settiming_3.2_ branch from 0cd9a68 to 51893b6 Compare June 17, 2026 23:39
@vanceingalls vanceingalls merged commit 39f37e8 into main Jun 17, 2026
30 checks passed
@vanceingalls vanceingalls deleted the 06-15-feat_studio_route_timeline_trim_move_through_sdk_settiming_3.2_ branch June 17, 2026 23:40
vanceingalls added a commit that referenced this pull request Jun 17, 2026
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>
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