Skip to content

fix(studio): harden keyframe editing semantics#2689

Merged
miguel-heygen merged 7 commits into
mainfrom
codex/studio-timeline-b-interaction-hardening-v2
Jul 25, 2026
Merged

fix(studio): harden keyframe editing semantics#2689
miguel-heygen merged 7 commits into
mainfrom
codex/studio-timeline-b-interaction-hardening-v2

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What

Hardens keyframe selection, authoring time, deletion, clip movement, and easing interactions.

Why

The reviewed timeline surfaced correctness bugs around stale selection, clip-relative versus global time, keyboard/button parity, pointer capture, bulk deletion, failed retimes, and moving clips with keyframes.

How

Preserves explicit identity and captured playhead time through the authoring transaction, makes selection gestures deterministic, keeps accessible ease controls mounted, and adds regression coverage for the critical paths. The cohesive 1,722-line delta is documented as an R9 correctness exception so fixes remain with their proof. This is B7 of the Family B draft Graphite stack.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (not applicable)

Exact Family B tip validation: 2,865 Studio tests, 67 Studio Server route tests, both package typechecks, formatting, lint, diff check, file-size gate, and Fallow audit passed.

Deferred review findings

Every blocker and high finding raised on this PR is fixed in the stack. The 3 remaining low/nit findings are parked, verbatim, in .scratch/studio-timeline-family-b/issues/07-pr-2689-deferred-review-findings.md:

  • 🟡 packages/studio/src/hooks/gsapDragPositionCommit.ts:278activeKeyframePct not cleared when arc drag falls through to temporal-keyframe branch
  • 🟢 packages/studio/src/components/TimelineToolbar.test.tsx:61 — TimelineToolbar keyboard/button parity: only the 'endpoint disabled' path asserted; 4 new motion-path label branches untested
  • 🟢 packages/studio-server/src/routes/files.ts:1056 — Server-side resolveReplacementEaseEach re-parses the entire script on every request that omits easeEach

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

Reviewed at 2b4a520fbf3f.

Correctness bundle. R9 verdict: split_path_exists — the keyframe-core fixes (~1500 lines) are genuinely interdependent, but 3 files (propertyPanelFlatTextSection, useStudioTestHooks typing refactor, e2e fixture) are unrelated to keyframe editing and could split. Two orange items on the fix set itself: arc-drag temporal-keyframe path still uses the old un-scoped resolveTweenDuration while the rest of the PR standardizes on resolveEditableTweenDuration (mixed-basis stranded), and pointer-capture 'closest-interactive-control' bailout ships without regression coverage.

R9 exception verdict — split_path_exists

Core keyframe-editing fixes (identity/selection, retime settlement, bulk delete, motion-path temporal keyframes, toolbar parity, easeEach server-side, useTimelineEditCallbacks element identity) are genuinely interdependent — Promise return type ripples through 5 files, identity model requires simultaneous updates in delete/context-menu/callbacks, motion-path drag temporal-keyframe requires paired server easeEach preservation. ~1500 lines pass 'fixes remain with their proof'. However 3 files in the delta are unrelated to keyframe editing and cleanly separable: (1) propertyPanelFlatTextSection.tsx (+28/-3) + test — text-field auto-focus fix so FlatTextSection doesn't steal canvas focus on element selection; (2) useStudioTestHooks.ts (+13/-3) — pure declare global typing refactor, zero runtime effect; (3) tests/e2e/fixtures/design-panel-qa/index.html (1-line selector fix). Removing them would drop ~120 lines without weakening the coherent argument. R9 is honest for keyframe core but has ridealong scope-creep.

Bug-test coverage audit

  • stale selection: timelineKeyframeIdentity.test.ts + useTimelineEditCallbacks.test.tsx
  • clip-relative vs global time: globalTimeCompiler.test.ts + gsapShared.test.ts + useTimelineEditCallbacks.test.tsx
  • keyboard/button parity: TimelineToolbar.test.tsx + TimelinePropertyLanes.test.tsx
  • ⚠️ pointer capture: Only ease-segment fix covered (TimelineClipDiamonds.test.tsx); Timeline.tsx onPointerDown 'closest-interactive-control' bailout at :477 has NO regression test
  • bulk deletion: timelineEditingHelpers.test.ts — but see F4
  • failed retimes: useGsapKeyframeOps.test.tsx + useGsapSelectionHandlers.test.tsx + useGsapScriptCommits.test.tsx + useTimelineEditCallbacks.test.tsx
  • moving clips with keyframes: useTimelineEditCallbacks.test.tsx + gsapRuntimeBridge.test.ts

Findings not anchored inline

🟠 'closest-interactive-control' pointer-capture bailout claimed as a bug fix but ships with zero regression coverage

packages/studio/src/player/components/Timeline.tsx:477

// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
// their own clicks — scrubbing here would preventDefault and eat them.
if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;

Searched every Timeline*.test.* in packages/studio — no test dispatches a pointerdown whose target is a nested button/input to assert the scrub path is skipped. If a future refactor removes this line (or narrows the selector), no CI signal fires. PR body lists 'pointer capture' as one of seven correctness bugs; the other pointer fix (pointerEvents: 'none' on the ease segment) is covered by TimelineClipDiamonds.test.tsx but this branch is not.

Fix: Add a Timeline.test.ts case that fires pointerdown on a nested button and asserts scrub isn't initiated.

🟡 Two files bundled here are unrelated to keyframe editing — clean split candidates that weaken R9

packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx:1

// propertyPanelFlatTextSection.tsx (+28/-3):
// adds autoFocusFieldKey state — 'does not steal canvas focus when a multi-field element is selected'
// useStudioTestHooks.ts (+13/-3):
// pure `declare global { interface Window { __studioTest?: StudioTestApi; } }` refactor

propertyPanelFlatTextSection: new test 'does not steal canvas focus when a multi-field element is selected' explicitly asserts focus behavior that has nothing to do with keyframe editing, timeline, or the seven listed bugs. useStudioTestHooks: pure typing refactor, zero runtime effect. Both land under a commit titled 'harden keyframe editing semantics' and are counted in the 1,722-line R9 exception, but neither is defended by that framing.

Fix: Either lift them out into their own follow-up PR, or update the PR body to acknowledge them as ridealong non-keyframe fixes.

🟢 parkPlayheadOnKeyframe still uses un-scoped resolveTweenDuration while surrounding drag branch uses selection-scoped duration

packages/studio/src/hooks/gsapDragCommit.ts:59

export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
  const ts = resolveTweenStart(anim);
  const td = resolveTweenDuration(anim);
  if (ts == null || !td || td <= 0) return;
  usePlayerStore.getState().requestSeek(roundTo3(ts + (pct / 100) * td));
}

Called from gsapDragPositionCommit.ts:296 (arc waypoint branch, new code in this PR). For a duration-less animation the drag's percentage is computed against clip duration but the playhead is parked against 0.5s — the two never agree. Pre-existing, but now more visible because the PR introduced the mixed-basis pattern within the same function.

Fix: Thread the selection through parkPlayheadOnKeyframe and use resolveEditableTweenDuration.

🟢 Verified clean

  • deleteSelectedKeyframes.ts Map-based dedupe uses ${animation.id}\0${percentage} — unique (anim, pct) pairs guaranteed; !selectedElementId early bail replaces the deleted null-guard cleanly
  • timelineKeyframeIdentity.ts JSON-array encoding unambiguous vs legacy elementId:group[:animId]:percentage (which could collide when elementId contains ':'); fallback branch preserves backward compat for collapsed keys
  • keyframeSelection.ts deletion — confirmed no remaining importers in packages/; sole consumer migrated to timelineKeyframeTargetFromSelectionKey
  • useGsapKeyframeOps.ts moveKeyframe/resizeKeyframedTween Promise return correctly forwards result.changed !== false; catch branch always returns false + routes to trackGsapSaveFailure; three settlement outcomes covered by test
  • files.ts easeEach threading correctly derives outer envelope's easeEach from parsed original when client sends ease: 'none' — preserves per-segment easing across conversion; test coverage 'replace-with-keyframes preserves per-segment easing for exact temporal keyframes'
  • useGsapSelectionHandlers.ts handleGsapMoveKeyframeToPlayhead animationOverride param correctly threads CLICKED element's anim snapshot
  • TimelineClipDiamonds.tsx replaces useState with CSS group-hover/focus-visible — net reduction in state updates on hover; ease button is now tabbable, satisfying 'accessible ease controls mounted'

Cross-stack notes

R1 from Family A #2674 flagged that Hold ease renders draggable handles in the ease editor — this PR touches ease-related code only at TimelineClipDiamonds.tsx (mid-segment ease button opacity/mount lifecycle), does NOT touch the ease editor. R1 concern does not land here. B7 designation is honest: sits on B6 (#2688 editor-callbacks-v2) whose TimelineEditCallbacks typed surface is what forced the Promise ripple across five files — those files could not have been split without breaking B6's compile in an intermediate state. HF flicker lens: arc-drag temporal-keyframe branch commits softReload: true — mirrors existing waypoint-edit behavior, no re-render cascade added. Combined with #2688 R9 verdict: #2689 IS what completes #2688's stated stale-target fix — but the story would be cleaner if the two shipped together.

Review by Rames D Jusso

return;
}

const tweenStart = resolveTweenStart(anim);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Arc-drag temporal-keyframe path uses resolveTweenDuration (0.5s fallback) — stranded from PR's mixed-basis fix

const tweenStart = resolveTweenStart(anim);
const tweenDuration = resolveTweenDuration(anim);
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
...
  position: roundTo3(tweenStart),
  duration: roundTo3(tweenDuration),

Contrast with applyArcKeyframeAtPlayhead in useEnableKeyframes.ts (added in this PR): const duration = resolveEditableTweenDuration(arcAnim, sel);. And computeElementPercentage (updated in this PR) — called two lines above via computeCurrentPercentage(selection, anim) — routes through resolveEditableTweenDuration(animation, selection). Result: for a duration-less arc anim inside a clip with data-duration=16, pct is computed against 16s (correct) but mutation's duration is written as 0.5s (wrong) — truncates the tween window. gsapRuntimeBridge.test.ts fixture hardcodes duration: 16.055 so this asymmetry is not exercised. Since the whole rest of the PR standardizes on resolveEditableTweenDuration, this call site is stranded.

Fix: Switch to resolveEditableTweenDuration(anim, selection) to match the rest of the fix.

Review by Rames D Jusso

expect(options[1]).not.toHaveProperty("softReload");
expect(options[2]).not.toHaveProperty("skipReload");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Bulk-delete regression coverage narrowed vs deleted keyframeSelection.test.ts

// Deleted test asserted the specific bug:
// 'drops keyframes that belong to other elements'
// New suite only has:
// - 'coalesces all removals and reloads only after the last one' (collapsed keys, single element)
// - 'deletes two expanded lanes through their own animation and tween percentages' (expanded keys, all matching element)

Deleted test asserted: bulk delete on comp#a with a stale comp#b selection must not delete anything from comp#a. Neither new test asserts that a mixed Set([keyForA, keyForB]) on active element A produces exactly one call (dropping B). Cross-element behavior is now only tested at the identity primitive (timelineKeyframeIdentity.test.ts returns null for wrong element), which proves the primitive but not that deleteSelectedKeyframes composes it correctly — exactly the kind of composition bug the deleted test was designed to catch.

Fix: Port the deleted 'drops keyframes that belong to other elements' assertion into timelineEditingHelpers.test.ts.

Review by Rames D Jusso

const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
const ct = usePlayerStore.getState().currentTime;
if (anim.arcPath?.enabled) {
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 activeKeyframePct not cleared when arc drag falls through to temporal-keyframe branch

if (pointIndex >= 0) {
  await callbacks.commitMutation(...);
  setActiveKeyframePct(null);   // cleared
  parkPlayheadOnKeyframe(anim, pct);
  return;
}
...
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
await callbacks.commitMutation(...);
return;   // activeKeyframePct still set

Other keyframed-tween drag helpers in this file (:100, :232, :294, :438) uniformly clear activeKeyframePct after using it. If user selects waypoint at pct 50 but drags to a spot whose computed pct is 50.06 (outside 0.05% tolerance), pointIndex check misses, temporal keyframe committed, activeKeyframePct=50 lingers into next drag — reused as pct = activeKeyframePct. Soft reload usually refreshes state, but invariant asymmetry is new in this PR.

Fix: Add setActiveKeyframePct(null); before the temporal-branch return.

Review by Rames D Jusso

act(() => root.unmount());
});
});
describe("TimelineToolbar — motion path endpoints", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 TimelineToolbar keyboard/button parity: only the 'endpoint disabled' path asserted; 4 new motion-path label branches untested

const button = host.querySelector<HTMLButtonElement>(
  'button[aria-label="Motion path endpoint"]',
);
expect(button?.disabled).toBe(true);

The diff introduces five distinct tooltip/aria-label branches ('Motion path endpoints cannot be removed', 'Extend motion path to playhead (K)', 'Remove waypoint from motion path (K)', 'Add waypoint to motion path (K)', plus the pre-existing non-motion-path branches). The only new test asserts the endpoint case. If a future refactor swaps the arcAnimation-vs-keyframedAnimation resolution order, the wrong label ships silently.

Fix: Add render assertions for each of the four new label branches.

Review by Rames D Jusso


type GsapMutationResult = string | { script: string; skippedSelectors: string[] };

function resolveReplacementEaseEach(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Server-side resolveReplacementEaseEach re-parses the entire script on every request that omits easeEach

function resolveReplacementEaseEach(
  scriptText: string,
  request: { animationId: string; easeEach?: string },
): string | undefined {
  if (request.easeEach !== undefined) return request.easeEach;
  const original = parseGsapScriptAcorn(scriptText).animations.find(
    (animation) => animation.id === request.animationId,
  );
  ...
}

Called from both executeGsapMutationAcorn (:1520) and executeGsapMutationRecast (:1891). During a bulk keyframe edit sequence over an arc with N stops, each request re-parses the entire GSAP script AST just to look up source easeEach. Correctness fine but parse cost scales with N. The new client callers (useEnableKeyframes/useGsapKeyframeOps) send ease: 'none' only — do NOT send easeEach — so this fallback path fires for every real motion-path temporal-keyframe write.

Fix: Client-side: derive easeEach from the local anim record before the fetch, or pass through gsapAnimations lookup. Or server-side: cache the parse across a single request batch.

Review by Rames D Jusso

vanceingalls
vanceingalls previously approved these changes Jul 25, 2026

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

Reviewed at 2b4a520fbf3f.

Correctness bundle (R9-flagged). Rames's R1 landed split_path_exists — ~1500 lines of keyframe-editor correctness are genuinely coupled, but 3 files (text-field auto-focus, useStudioTestHooks typing, e2e fixture) split cleanly. He also flagged parkPlayheadOnKeyframe still using un-scoped resolveTweenDuration while surrounding code standardizes on resolveEditableTweenDuration, plus a coverage gap on the pointer-capture bailout. My additive read verified the pre-existing bugs each fix targets — that was the discipline the task asked for.

Prior review

Rames D Jusso R1: split_path_exists R9 verdict, bug-test coverage matrix (all ✅ except pointer-capture ⚠️), parkPlayheadOnKeyframe mixed-basis, 3-file ridealong split. Overlap: I confirmed the pre-existing bug for the arc-waypoint→temporal-keyframe rewrite and the resolveEditableTweenDuration fix.

🟢 Pre-existing bug verification (per Miguel-flagged R9 for #2689)

For each claimed correctness fix I traced the pre-fix path at the base ref (da9f172254f0, the #2688 head):

  1. Arc-waypoint → temporal-keyframe (applyArcWaypointAtPlayheadapplyArcKeyframeAtPlayhead). Pre-fix path inserted type: "add-motion-path-point" at a spatial location. GSAP MotionPath distributes progress evenly across N+1 segments post-insertion, so adding a waypoint at t=1.0s on a 2s tween with two authored waypoints (0%, 100%) makes it a 3-node arc whose old-t=1.0s waypoint is now at 50% progress — silent time compression. Toolbar's "Add keyframe at playhead" implies temporal preservation. New logic converts to temporal x/y keyframes (type: "replace-with-keyframes") with the drop position preserving duration+position. Bug is real; fix is sound.
  2. resolveEditableTweenDuration. Pre-fix resolveTweenDuration(animation) returned GSAP's 0.5s default for duration-less tweens; the timeline renders such tweens across the clip duration, so mutations targeted a phantom 0.5s window while the UI showed the clip's actual span. Fix threads selection.dataAttributes?.duration in as the fallback. Bug is real.
  3. computeElementPercentage. Old formula: Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)). New: absoluteToPercentage(currentTime, elStart, elDuration). The old clamp was silently wrapping — playhead past the clip returned 100%, playhead before returned 0%. The new unclamped path returns real percentages, letting callers see out-of-range and route through isTimeWithinTween / extend-duration logic instead of being lied to. Consistent with the rest of this PR's move toward "let callers see truth." Fine; downstream callers do handle out-of-range.
  4. Bulk keyframe deletion. deleteSelectedKeyframes.ts Map-based dedupe on ${animation.id}\0${percentage} is unique per (anim, pct) — a duplicate selection key can't cause double-delete. Verified.
  5. Selection identity encoding. timelineKeyframeIdentity.ts uses JSON-array encoding vs the legacy elementId:group[:animId]:percentage — the legacy scheme could collide when elementId contains :. Fallback branch preserves backward compat. Sound.
  6. easeEach threading in files.ts. Server derives outer envelope's easeEach from the parsed original when client sends ease: 'none'. Preserves per-segment easing across convert. Test case replace-with-keyframes preserves per-segment easing for exact temporal keyframes confirms.
  7. Clicked-element selection through useTimelineEditCallbacks. onDeleteKeyframe, onMoveKeyframeToPlayhead, onMoveKeyframe, onDeleteAllKeyframes all now route through buildDomSelectionForTimelineElement(element) before committing — a delete on a non-selected element commits against that element's own selection, not domEditSelection. Patches the exact gap Rames flagged in #2688.

Every claimed bug reproduces at base. No speculative fixes.

🟢 Verified clean

  • Toolbar keyframe-toggle logic: resolveKeyframeToggleState covers active/inactive/none across motion-path vs keyframe tweens; isMotionPathEndpoint correctly skips endpoint deletion; aria-label branches on every state. Lens 4 (ARIA coherence) clean.
  • applyArcKeyframeAtPlayhead's middle-node deletion (timedNodeIndex > 0 && timedNodeIndex < nodes.length - 1) skips endpoints — cannot delete arc start/end. Reasonable semantic; matches the toolbar's "cannot remove endpoints" tooltip.
  • useGsapKeyframeOps.ts moveKeyframe/resizeKeyframedTween Promise<boolean> return threads result.changed !== false up correctly; catch branch returns false and routes to trackGsapSaveFailure. Lens 1 (silent-catch) clean — every catch surfaces to telemetry.

Cross-stack

Rames's coverage-gap finding on Timeline.tsx:477 closest("button, input, select, a") bailout is real — no test dispatches a pointerdown on a nested button to assert scrub is not initiated. Follow-up-able.

Verdict

Approving. Correctness fixes are sound and reproducibly targeted at real pre-existing bugs. R9 verdict is split_path_exists (Rames) — I concur; the three ridealong files could split but the keyframe core is genuinely coupled.

Review by Via

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-editor-callbacks-v2 branch from da9f172 to 69da4b0 Compare July 25, 2026 16:08
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-interaction-hardening-v2 branch from 2b4a520 to 67d2ef1 Compare July 25, 2026 16:08
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-editor-callbacks-v2 branch from 69da4b0 to dd77b83 Compare July 25, 2026 16:38
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-interaction-hardening-v2 branch from 67d2ef1 to 50b6822 Compare July 25, 2026 16:38
@miguel-heygen
miguel-heygen marked this pull request as ready for review July 25, 2026 17:00
@miguel-heygen
miguel-heygen changed the base branch from codex/studio-timeline-b-editor-callbacks-v2 to main July 25, 2026 17:03
@miguel-heygen
miguel-heygen dismissed vanceingalls’s stale review July 25, 2026 17:03

The base branch was changed.

@miguel-heygen
miguel-heygen merged commit 041614d into main Jul 25, 2026
41 of 42 checks passed
@miguel-heygen
miguel-heygen deleted the codex/studio-timeline-b-interaction-hardening-v2 branch July 25, 2026 17:04
@miguel-heygen
miguel-heygen restored the codex/studio-timeline-b-interaction-hardening-v2 branch July 25, 2026 17:27
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.

3 participants