Skip to content

fix(studio): author every new tween against one element - #2849

Merged
miguel-heygen merged 21 commits into
mainfrom
fix/studio-new-tween-target
Jul 28, 2026
Merged

fix(studio): author every new tween against one element#2849
miguel-heygen merged 21 commits into
mainfrom
fix/studio-new-tween-target

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

The bare-class write fixed lower in this stack for "add keyframe at playhead" was still reachable from every other writer. This closes each one, after reproducing it individually.

Bugs fixed

1. One edit authors a tween over every sibling sharing a class, reachable from the add-animation button, drag, resize, rotate, gesture recording, and the property panel. Each derived its target from selectorFromSelection, which hands back a bare class for an id-less element, so a single edit wrote a tween covering all five siblings and the timeline collapsed their rows into one. Nine call sites reproduced:

  • ensureElementAddressable (reached from the add-animation button)
  • commitStaticGsapPosition, commitStaticGsapRotation, commitStaticGsapSize
  • commitKeyframedSizeFromResize, replaceKeyframedPositionHold
  • useAnimatedPropertyCommit (both the global static set and the first same-group keyframe tween)
  • useGestureCommit (add-with-keyframes)

2. The same bare-class write from the motion-path overlay, the tenth site, reached from the preview toolbar's "set destination" toggle. selectorFor returned the selection's own selector, which the overlay uses twice: useMotionPathData measured the element's home position by it, and commitCreatePath authored add-motion-path with it. On a class sibling the destination was computed from the FIRST sibling's home position and then written onto all of them.

3. A second, conflicting write is appended on the next nudge. Found by test while fixing bug 1: narrowing the write alone regressed idempotency. The "is there already a write for this element" lookups (findExistingPositionWrite, findRotationSetAnimation, findSizeSetAnimation, the gesture position lookup, and the property panel staticWrites filter) all matched targetSelector by string equality, so the next nudge missed the write it had just made and appended a second one that conflicted with it.

4. An individual nudge silently retargets a group tween. The fix for bug 3 first read the DOM with a bare element.matches, which is true for a target the element merely shares with its siblings. Those lookups feed mutations, so nudging one of five .group siblings rewrote the group's own tween and moved all five.

5. A caller that could not narrow re-authored the bare class anyway. Five new-tween writers fell back to the selection's own selector when no one-element form existed, which is the exact string the narrowing exists to replace.

Why

Same class of defect as the playhead fix below: silent corruption of the user's source file from an ordinary edit gesture. Fixing only the one path the QA report named would have left nine siblings broken.

How

Route every path that authors a NEW tween through the existing writeTargetSelector ladder:

  • ensureElementAddressable now accepts selection.selector only when it addresses exactly one element, so the id-minting fallback directly below it does the work. That fallback already existed and was simply unreachable whenever any selector was present.
  • gsapDragCommit's five new-tween branches go through one newTweenTarget helper. Instant patches reuse the written target so the runtime moves the element the source write names.
  • useGestureCommit and useAnimatedPropertyCommit keep the existing selector for matching and retargeting, and author new tweens with a separate write selector.
  • motionPathSelection.selectorFor goes through the same ladder, so the overlay measures and writes against the same one element. When no one-element form exists the toolbar hides "set destination" rather than arming a press that gets dropped.

The read half falls back to the live DOM (tweenTargetsElement), which fixes idempotency. That fallback requires the target to match exactly one element: a target shared with siblings is a group tween, and only the selection that is the group itself (string equality) may edit it.

Where no one-element form exists at all, every writer now drops the commit instead of falling back to the selection's selector. A gesture that does not persist reverts visibly on the next reload; a tween silently aimed at five elements does not. useGestureCommit surfaces a toast because a recording is expensive to redo.

Retargets of an EXISTING tween are deliberately untouched. They keep anim.targetSelector, so a tween aimed at a whole group stays aimed at it.

Test plan

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

Tests reproduce each site through a real writer, re-parse with the real parser, and resolve through resolveSelectorElementIds (what actually feeds the keyframe cache and the lanes), plus pins for the new-tween versus retarget-existing distinction so a future change cannot collapse the two. Bugs 2, 4 and 5 each get their own lock: motionPathSelection.test.ts for the overlay selector, four tweenTargetsElement cases for group exclusivity, and a commitKeyframeAtTimeImpl case asserting the commit is dropped rather than widened.

Studio suite green on this branch: 3081 passing, 0 failures. Typecheck, lint and format clean.

Not covered

  • Top of a 7-PR stack.
  • Investigated and deliberately excluded, because they retarget an existing tween rather than authoring a new one: commitWholePathOffset, all four gsapDragPositionCommit.ts writes, and replace-with-keyframes in useEnableKeyframes. Narrowing those would silently retarget a tween the author aimed at a whole group.
  • No end-to-end browser test drives the motion-path overlay; bug 2 is locked at the selector helper both halves of the overlay read from, not through a real pointer press.

…me percentages

A ruler press with no pointer movement settled the playhead at t=0 instead
of the clicked time. handlePointerUp replays pendingClientXRef, which only
the pointermove path wrote, so a plain click fell back to the ref's initial
0 and overwrote the correct pointerdown seek. Seed the ref on pointerdown.

The keyframe retime move branch also returned the raw quotient while the
resize branch rounded to 3dp, so values like 74.81203007518799% landed in
the user's source and churned the diff on every drag. Round at the point of
computation so the no-op test and the written value agree.
The 24x24 WCAG 2.5.8 overlay sits on a wrapper that outranks the diamonds,
so on a segment narrower than 24px it overhung them and won their hit test at
fit zoom. Gate the overlay on the clear span between the two diamonds and let
the button keep its 16x16 box below that.

Also renames the pointer target suite to say it asserts the classes that
produce the size, not the measured geometry, which happy-dom cannot see.
…ide press

Split hex-draft ownership so the hex input is the sole author of its
own text while editing (updateColorDraft no longer stamps a canonical
hex back over every keystroke), fixing snap-back on backspace and the
silent wrong-colour clobber on non-repeating hex values. Route hex
typing through the shared gesture transaction so outside-click and
Escape settle/cancel it like the other inspector fields, instead of
relying on a private onBlur commit that never fires once the panel
unmounts on outside-click.
Swap the panel's hand-rolled bubble-phase mousedown listener for the
shared useContextMenuDismiss hook, which adds Escape support and fixes
outside-click dismissal when a canvas gesture (e.g. marquee start)
calls preventDefault on pointerdown, which otherwise suppresses the
mousedown compat event entirely. Also wires up dialog ARIA (role,
aria-modal, id/aria-controls) between the trigger and panel.
The panel does not trap focus and leaves the rest of the editor operable,
so aria-modal would tell assistive tech the whole app is inert while it is
open. role=dialog plus aria-controls and Escape is the correct non-modal
disclosure shape.
The gesture resolver gated on six digits while parseCssColor accepts both
lengths, so #F00 previewed as nothing and committed nothing. The old onBlur
path parsed it, making this a behavioural loss rather than a pre-existing gap.

Also asserts that an incomplete hex is restored on outside-click, not merely
left uncommitted.
A sub-composition tween's resolvedStart is composition-local, while the timeline
element resolved for it is the sub-comp HOST, whose start is main-timeline
absolute. toClipPercentage subtracted the two frames from each other, so a host
mounted at 1.5s cached its 0s tween at -12% and its last tween's end keyframe at
88% instead of 100%. A clip-relative percentage can never be negative.

resolveClipTimingBasis now returns the clip start in the frame the tween's own
times are measured in: the composition mount (expandedParentStart for an
expanded child, the parent composition clip's start otherwise, 0 for a
root-composition element) is subtracted, and a sub-comp inner element that falls
back to its host's window starts at 0 in that window. It moves to gsapShared so
the post-commit cache writer can share it instead of resolving its own basis,
which also gives that writer the sub-comp host fallback it was missing.
buildExpandedElements synthesized DOM-only sub-composition children against
the top-level element rather than the parentHost it resolves immediately
after. Under two-level nesting every child row therefore inherited the
top-level window instead of its own host's, so the rows drew at the wrong
offset and duration.
The sub-composition drill-in replaced the host row with its children. Since
expansion is also driven by the playhead alone (paused auto-expand), an
ordinary seek into a sub-composition made the host row disappear, taking its
keyframe lane with it: diamonds render per row from keyframeCache.get(
elementKey), so no row means no diamonds. Reproduced live with no drag at
all, seek 0 gave 3 diamonds, seek 7.68 gave 0, seek 0.2 gave 3.

Make the expansion additive instead. The host row stays and its children are
appended directly below it. The synthetic fractional lanes already used for
children sit strictly between the host's lane and the next integer, so the
host keeps its own row without colliding with anything.

The time-keyed auto-expand itself is unchanged.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-track-numbers-aria branch from 23bed54 to d6aa760 Compare July 28, 2026 16:09
@miguel-heygen
miguel-heygen force-pushed the fix/studio-new-tween-target branch from 91883d1 to d32b0b7 Compare July 28, 2026 16:09
Drilling two levels deep spared only the top-level row, so the middle host
lost its row and its keyframe lane with it. Spare every host between the
drilled one and the top, and anchor the children under the deepest host that
actually has a row.

Also stops resolveClipTimingBasis handing back a main-timeline start when a
clip names a parent composition that is absent from the element list. The
mount is unknowable there, so the child's own window is the only safe frame.
Two halves of one inversion in the expanded timeline lanes: the tweens
that should show were filtered out, and a tween that should not be there
was the only survivor.

Lane classification read the parser's whole-tween verdict, which is
undefined for anything spanning more than one property group. `{x,
opacity}` is the canonical HyperFrames entrance tween, so five of the
seven tweens in the swiss-grid graphics example had no caret, no
reserved row and no diamonds. Classify per property instead, through one
helper both the rendered lanes and the reserved row heights count
through so they cannot drift again.

Attribution matched an unanchored leading id, so `#stat3 .block` was
filed under `#stat3`. The child's diamonds landed on its ancestor and
collided with the ancestor's own tween at the shared percentage, which
the same-percentage merge then resolved by dropping the ease. Route
attribution through resolveSelectorElementIds, which anchors a
whole-selector id and otherwise resolves through the live preview DOM,
and anchor its no-DOM fallback so a descendant selector resolves to
nothing rather than to its ancestor. The merge rule is unchanged.

Also brings the last property-lane call site onto the shared clip timing
basis: an expanded sub-composition child's start is host-absolute while
its tweens are local to its own file.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-new-tween-target branch from d32b0b7 to 3140c95 Compare July 28, 2026 17:03
@miguel-heygen
miguel-heygen force-pushed the fix/studio-track-numbers-aria branch from d6aa760 to 9cec37c Compare July 28, 2026 17:03
animIdForProp matched on the parser's whole-tween propertyGroup, which is
undefined for a legacy mixed tween such as {x, opacity}. Such a tween never
matched, so an edit to either property fell through to the selection's
default animation, a different tween than the lane the user is editing.
Resolve through animationLaneGroups, the same per-keyframe helper the
rendered lanes and the reserved row heights already count groups with.
"Add keyframe at playhead" on an element with no id authored the bare class
buildStableSelector hands back, so one add on a `.group` wrote
`tl.to(".group", ...)`: a tween that animates all five siblings and that
resolveSelectorElementIds reads back as all five, collapsing their timeline
rows into one. It survived a reload, so the written file stayed un-editable.

writeTargetSelector is the write-side counterpart to selectorFromSelection
(which must keep returning the exact string findTweenAtTime compares against).
It resolves the element's own identity to a selector that addresses exactly
one element: `#id`, else `[data-hf-id="..."]`, else the selection's selector
when it is already unique, else a `:nth-child` path anchored on the nearest
identifiable ancestor (the selector + selectorIndex pair, resolved through the
DOM the index was counted in).

Applied to the two paths that author a NEW tween: the no-animation branch of
useEnableKeyframes and commitKeyframeAtTimeImpl. replace-with-keyframes still
writes the selection's own selector, since retargeting a tween the author
aimed at a whole group is a different decision from adding a keyframe.
writeTargetSelector returned the selection's bare selector whenever the
structural walk failed, including when a live DOM was there to check
against. An element detached between selecting and committing takes that
path, so the add re-authored the exact `.group` string the function exists
to replace. Return null instead: a failed walk against a live DOM is
evidence, not absence of it. Callers that cannot drop a user edit opt back
in with `?? selectorFromSelection` where the trade is visible.

The replace-with-keyframes paths had the mirror defect. The server deletes
and re-adds the tween, so their target string is a full rewrite, and they
derived it from the selection: promoting a set on a tween already narrowed
to `#scene > div:nth-child(3)` widened it back onto every class sibling,
undoing the narrowing an earlier add had made. They now keep the tween's
own authored target, matching what eight sibling commit modules already do.
…he lanes

The timeline's track key is a fractional z-order sort value, and the header
built its visibility label straight from it, so screen readers announced
"Hide track 0.16666666666666666". A track's 1-based display row is now passed
alongside the key: the row number goes in every label, the key keeps routing
every callback (visibility toggle, lane context menu). The same fix covers the
`Track N` fallback used when a track holds no labelled element.

The layer disclosure caret's aria-controls named a div in the sticky label
column. That subtree is not empty, it holds the per-lane keyframe controls, but
its children are all absolutely positioned so the div computes to 0x0, and the
diamonds the caret visibly reveals live on the canvas instead. The caret expands
two disjoint subtrees and was naming the less useful one. TimelinePropertyLanes
now renders one static wrapper (static, not relative, so it establishes no
containing block and the absolutely-positioned lanes keep resolving against the
track-content div with identical geometry) and takes the id. TimelineLanes mints
that id, since it is the only place that sees both ends of the disclosure, and
mounts the wrapper for the track's keyframe clip in both disclosure states so
the reference still resolves while collapsed.

TimelineLaneBaseProps moves to its own module: it is the contract shared by
TimelineCanvas and TimelineLanes, and lifting it out keeps TimelineLanes.tsx
well under the 600-line cap instead of pushing past it.
The timeline track key is a fractional z-order sort key: an expanded
sub-composition child gets `host.track + n / (siblings + 2)`. The undo
history entry for the eye toggle interpolated that key directly, so
hiding an expanded child recorded "Hide track 0.16666666666666666".

Give the key-to-display-row conversion a single owner
(timelineTrackDisplay.ts) and route both the track header labels and the
history label through it, so the two cannot drift apart again. The raw
key still routes the callbacks and lookups that need it.

Adds a regression test that toggles a track keyed 1 / 6; a test on track
0 formats cleanly and proves nothing.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-new-tween-target branch from 3140c95 to 643e56b Compare July 28, 2026 18:39
@miguel-heygen
miguel-heygen force-pushed the fix/studio-track-numbers-aria branch from 9cec37c to 477916c Compare July 28, 2026 18:39

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

Top of the stack. The write/read pairing (writeTargetSelector for the write half + tweenTargetsElement for the read half) is the right shape, and the test surface is thorough at both the writer and the hook layer. One writer got missed, though — same class of bug the PR exists to close.

Blocker

commitCreatePath at packages/studio/src/components/editor/motionPathCommit.ts:80 is an uncovered new-tween writer — reachable from the "set destination" motion-path toolbar toggle in MotionPathOverlay.tsx:190. Traces the same shape the PR fixes for commitStaticGsapPosition et al.:

  • createSelector = selectorFor(selection) at MotionPathOverlay.tsx:133 — falls through to bare sel.selector for an id-less element (see motionPathSelection.ts:9-13).
  • That string is passed straight to commitCreatePath(createSelector, ...), which emits { type: "add-motion-path", targetSelector, ... } — a fresh GSAP tween on the target.
  • Repro: select an id-less element that shares its class with siblings, arm "set destination", click on canvas → the new motion-path tween is authored over every sibling sharing the class. Exact bug the PR's ensureElementAddressable / newTweenTarget machinery exists to prevent, just from the motion-path entry point.

Neither the PR body's "nine call sites reproduced" list nor the "Investigated and deliberately excluded" list mentions this path, so I'm reading it as an oversight rather than a scope carve-out — a motion-path creation on an id-less element is authoring a new tween, not retargeting an existing one. Fix shape mirrors the other new-tween sites: run the selection through writeTargetSelector(selection) ?? createSelector at the call site in MotionPathOverlay.tsx:190, or mint an id via ensureElementAddressable if the element deserves an addressable name in the source (probably yes here — a motion-path anchor is the kind of thing an author will want to reference again).

Concerns

  • Fallback semantics deviate from writeTargetSelector's new contract, silently. writeTargetSelector now returns null when a live DOM is present but the structural walk fails — per its rewritten docblock, refusing to hand back the input is the whole point. Every caller here — newTweenTarget at gsapDragCommit.ts:27, addGlobalStaticSet at useAnimatedPropertyCommit.ts:254, useAnimatedPropertyCommit.ts:517, useGestureCommit.ts:174 — falls back to the same bare selector via ?? selector. In the "no DOM to disambiguate" case that's what the shared contract asks for. In the "DOM present but structural walk failed" case, the fallback re-authors the exact bare selector the function exists to replace. commitKeyframeAtTimeImpl in the base PR has an explicit comment naming this trade ("no graceful no-op to fall to"); the four sites here don't. Promoting newTweenTarget from gsapDragCommit.ts to a shared export in gsapShared.ts — colocated with writeTargetSelector and carrying the same rationale docblock — would keep the whole family on one story.

  • tryGsapDragIntercept first-branch exact match reads as a hot-path optimization but isn't named. gsapRuntimeBridge.ts:214posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector short-circuits before findExistingPositionWrite. When a previous write was narrowed (#scene > div:nth-child(3)) and the new drag's selector is still .group, exact equality fails and the general tweenTargetsElement-driven find recovers correctly. Behaviour is fine — a one-liner comment ("exact-match fast path; general element-match happens in the fallback") saves the next reader a trip through the callee.

  • First-parsed wins between overlapping tweens. findExistingPositionWrite's animations.find(...) returns whichever tween the parser saw first. If a source ever holds BOTH a legacy .group set AND a narrowed #group-2 set that both match the element (mid-migration state), a nudge routes to the first-parsed. Preferring the more-specific match would be nicer UX, but this is a natural follow-up seam if a legacy migration lands, not a regression here.

Nits

  • tweenTargetsElement's try { element.matches(targetSelector) } catch { return false } swallows any error at gsapShared.ts:315-319. Right for malformed selectors; a typeof targetSelector === "string" guard would be more explicit about what it's catching. Defensive-only — all real call sites pass a string.

  • useGsapAnimationOps.addWithKeyframes (line 222) accepts a raw selector, has no callers in packages/studio today (grep \.addWithKeyframes\( returns nothing outside its own definition/test), but the exposed API would bypass the write-target guard if a future caller adopts it. Either wire it through writeTargetSelector internally or delete the surface.

Confidence notes (what I didn't verify)

  • Did not run the Studio suite locally; relying on the "3059 passing, 0 failures" claim in the PR body. The test surface here is thorough enough that this feels safe: every enumerated writer has a "authors against one element" pin, the retarget-existing paths have "keeps the group target" pins, and the idempotency contract has a "finds the write the first nudge authored" pin.
  • The commitCreatePath finding is grep-verified against the tree, not repro'd in a running Studio — but the code path is a straightforward read.

Overall shape is right and the base PR's rewritten writeTargetSelector semantics + this PR's tweenTargetsElement companion are a clean pairing. The motion-path miss needs closing to hit the "every new-tween writer" bar Miguel set in the PR body.

Review by Rames D Jusso

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

Framing

The invariant being enforced: every mutation whose type is "add" or "add-with-keyframes" must carry a targetSelector that addresses exactly one element. writeTargetSelector is the ladder that resolves it; tweenTargetsElement is the read-half so the next nudge finds the write it just made. Retargets of existing tweens (replace-with-keyframes, update-properties, commitWholePathOffset) intentionally keep anim.targetSelector.

Verified at head 643e56b4 against the parent stack (fix/studio-track-numbers-aria). Enumerated every type: "add" / type: "add-with-keyframes" site in packages/studio/src/hooks/:

  • gsapDragCommit.ts — 4 add, 1 add-with-keyframes — routed through newTweenTarget(selection, selector).
  • useAnimatedPropertyCommit.tsaddGlobalStaticSet and the same-group first-keyframe branch — writeTargetSelector(selection) ?? selector.
  • useGestureCommit.ts — two add-with-keyframes branches — writeSelector = writeTargetSelector(sel) ?? selector.
  • useEnableKeyframes.ts:560add-with-keyframeswriteTargetSelector(sel) with a strict null-guard that drops to handleGsapAddAnimation("to") (safer than the ?? selector fallback, see P2 below).
  • gsapDragPositionCommit.ts:446add-with-keyframes — deliberately excluded, uses anim.targetSelector (converting from() to keyframes carries the existing target forward).
  • useGsapAnimationOps.ts:172 "Add animation" button — routed through ensureElementAddressable (mints an id when selection.selector is non-unique, aborts if id-write fails).

Read-half sites updated with tweenTargetsElement: findExistingPositionWrite, findRotationSetAnimation, findSizeSetAnimation, the property panel staticWrites filter, and the gesture position lookup — every caller passes selection.element. No stray callers.

The "9 call sites" claim in the PR body checks out (I counted 10, including both useAnimatedPropertyCommit branches).

Findings

P2 — ?? selector fallback silently re-introduces the widened write on 5 sites

packages/studio/src/hooks/gsapDragCommit.ts:23-25 (newTweenTarget), useAnimatedPropertyCommit.ts:256 and :517, useGestureCommit.ts:180, and the same pattern already in gsapKeyframeCommit.ts from the parent stack.

writeTargetSelector returns null in the case that most needs the narrow write: the element+doc exist, selection.selector is a non-unique bare class, and structuralSelector returns null (parts empty, or the built structural path doesn't matchesExactlyOne). Every one of these callers then falls back to selector — which is selectorFromSelection(selection), i.e. the exact bare class the function exists to replace. The mutation writes tl.set(".group", ...) and the timeline collapses again. Same defect the PR fixes, guarded by a low-probability edge race (detached element between select and commit, shadow-root boundary, structural chain that no longer re-resolves).

writeTargetSelector's own docstring names this trade — "opt back in with ?? selectorFromSelection where the trade is visible" — so the design is intentional: for drag/resize/rotate/gesture we prefer to widen than to drop the edit. Fair. But two things aren't in the current shape:

  1. No test locks the fallback in. newTweenTarget.test.ts exercises only happy paths (id-less siblings, live DOM, structural walk succeeds). Add one test that forces writeTargetSelector to return null (detach the element from its parent before commit) and asserts the currently-intended behavior — either "widens and toasts" or "widens silently". Whichever is chosen, the test locks it and any future narrowing sees the failure surface.

  2. useEnableKeyframes.ts:539 already uses the safer shape (if (!selector) { handleGsapAddAnimation("to"); return; }). Consider whether the 5 fallback sites can share that shape — a toast + drop is often better UX than silently animating four siblings the user didn't touch. If not, at least an observability counter on the fallback hit rate would tell you how often the trade is being made.

Same P2 I raised on #2847 for the same fallback in gsapShared.writeTargetSelector, now replicated at 5 more sites.

P2 — tweenTargetsElement widening the read-half means new individual writes update the wrong tween when a group tween exists

packages/studio/src/hooks/gsapShared.ts:304-317.

The read-half falls back to element.matches(targetSelector). Docstring names this as intentional so a deliberate .group tween still gets found. But the composition is: user has a group tween gsap.to(".group", { x: 100 }) set at 100 (all five siblings). User drags #group-2 individually.

  1. tryGsapDragIntercept calls findExistingPositionWrite(resolvedAnimations, "#group-2", group-2).
  2. String equality against .group fails; element.matches(".group") returns true because #group-2 has the .group class.
  3. Returns the group .set. Passed as existingSet to commitStaticGsapPosition.
  4. Since existingSet && isInstantHold(existingSet), the commit does update-properties on the group tween — rewriting the shared .group set's x/y. All five siblings jump.

This is documented ("still matches a deliberate group tween so merges into it keep working") but it means the invariant the PR is enforcing is only guaranteed for a NEW tween on an element with NO pre-existing group tween covering it. For elements that inherit a group tween, an individual nudge silently retargets the group. Two coverage items would tighten this:

  1. A test scenario where the group tween pre-exists and the user drags one sibling — assert the intended outcome (currently: group tween updates, all five siblings move) so a future "break out on individual nudge" change trips a test rather than an alarm.
  2. If the intent is to preserve group semantics, a follow-up note (or writeTargetSelector docstring extension) calling out that "author every new tween against one element" does NOT imply "every user edit lands on one element" — the two invariants come apart precisely at the group-inherited case.

Rest of the read-half looks right: element.matches with a try/catch is the correct shape for a GSAP selector string that may not be valid CSS syntax.

Nit — matchesExactlyOne now exported without a test

packages/studio/src/hooks/gsapShared.ts:276. The visibility change is what unlocks the fix in ensureElementAddressable. It's a small, well-scoped predicate but no test exercises it directly and the ensureElementAddressable test at newTweenTarget.test.ts:36-64 only asserts one axis (returns unique selector, mints id, no-op on id, no-op on unique class). A one-line test asserting matchesExactlyOne returns false for a non-existent selector and false when the query throws (":not(:foo:bar" or similar unparseable input) would lock the try/catch in.

Nit — posAnim.targetSelector === selector fast-path at gsapRuntimeBridge.ts:214

The pre-check before findExistingPositionWrite still uses raw string equality. Not a defect (falls through to the fuzzy match on miss), but the inconsistency with the surrounding code stands out; if the fast-path fails to catch the case where posAnim IS the just-authored write but selectorFromSelection and writeTargetSelector disagree, you take a slightly longer path. Consider tweenTargetsElement(posAnim.targetSelector, selector, selection.element) for consistency, or a comment noting the equality is intentional.

PR-body honesty check

  • "Nine call sites reproduced" — the diff writes to 10 (useAnimatedPropertyCommit has two, both listed under one bullet). Consistent.
  • "Route every path that authors a NEW tween through the existing writeTargetSelector ladder" — verified against the enumeration above.
  • "Retargets of an EXISTING tween are deliberately untouched" — verified: gsapDragCommit.ts:511, useEnableKeyframes.ts:154/263/365/428/446, useAnimatedPropertyCommit.ts:348, gsapDragPositionCommit.ts:76/329, useGestureCommit.ts:207/253, commitWholePathOffset all keep anim.targetSelector.
  • "Not covered" list — verified: commitWholePathOffset, gsapDragPositionCommit.ts four writes, useEnableKeyframes.ts replace-with-keyframes — all preserve existing targets.
  • "Tests reproduce each site through a real writer, re-parse with the real parser" — verified at newTweenTarget.test.ts:596-614 and :669-677.

Verdict

COMMENTED. Two P2 items — the fallback-widening under edge conditions on 5 new sites, and the read-half's group-tween composition — are worth addressing before merge, but neither blocks. Both are already flagged by design in the code's own comments; the ask is a locked test and a small doc/observability strengthening.

Grade: A-. Thoroughly reasoned, exhaustively tested, PR body maps to the diff, "Not covered" list is honest. The subtraction from A is the ponytail-fallback replication across 5 sites without a test that pins the chosen trade-off.

Review by Via

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

Follow-up — cross-review inventory with @james-russo-rames-d-jusso

Rames posted a review 11 seconds before mine (didn't see it before I submitted). Cross-checking; a P0 he flagged is a genuine miss on my side.

Confirmed at head 643e56b4: commitCreatePath is an uncovered new-tween writer — P0

packages/studio/src/components/editor/motionPathCommit.ts:80commitCreatePath(targetSelector, position, x, y, commit) emits { type: "add-motion-path", targetSelector, position, duration, x, y }. The caller at MotionPathOverlay.tsx:190 passes createSelector = selectorFor(selection), which in motionPathSelection.ts:11 is:

if (sel.id) return `#${CSS.escape(sel.id)}`;
return sel.selector ?? null;

That last rung is the exact bare-class shape the PR exists to close for every other new-tween path — an id-less .group sibling makes a motion path, add-motion-path gets authored with targetSelector: ".group", and the runtime writes the path over every sibling sharing the class. Same collapse-to-one-row shape as the sites this PR narrows for drag / resize / rotate / gesture / property panel.

Why my enumeration missed it: I searched packages/studio/src/hooks/ for type: "add" and type: "add-with-keyframes". add-motion-path is a distinct mutation kind, and the writer lives under components/editor/, outside the tree I walked. A rg 'type: "add-' over the whole package would have caught it. Adding this to my new-tween enumeration checklist.

Fix shape (same as Rames): writeTargetSelector(selection) ?? createSelector at the call site in MotionPathOverlay.tsx:190, or route through ensureElementAddressable if a motion-path anchor deserves a stable id in the source (Rames's suggestion; I agree — an author who set a motion path will likely edit it again).

Overlap inventory

Finding My R1 Rames R1 Alignment
commitCreatePath motion-path writer uncovered Missed P0 blocker Rames caught, I confirm
?? selector fallback re-authors bare class on structural-walk fail P2 (5 sites) Concern (4 sites + docstring drift) Same defect, same 5 sites (I named gsapKeyframeCommit.ts from base; he named the four in this PR)
tweenTargetsElement group-tween composition P2 discussion Not raised My addition — flagging that the "one element" invariant unravels on inherited group tweens
posAnim.targetSelector === selector fast-path (gsapRuntimeBridge.ts:214) Nit — inconsistent shape Concern — reads as opt but unnamed Same site, same read
matchesExactlyOne export untested Nit Not raised My addition
first-parsed wins in findExistingPositionWrite Not raised Concern — mid-migration state Rames's addition, valid follow-up seam
useGsapAnimationOps.addWithKeyframes bypass surface Not raised Nit Rames's addition

Updated verdict

REQUEST-CHANGES equivalent (this is a --comment review by convention here, but the substantive verdict is blocker-present): the motion-path writer needs closing before merge to hit the "every new-tween writer" bar the PR body sets. The two P2s from my original comment stand as follow-ups.

Grade downgrade to B — thorough on nine of ten writers; the tenth is the same class of bug and would ship the "collapse-to-one-row" defect from the motion-path entry point.

Review by Via

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

All three fixed here rather than deferred, at da155bc.

P0, motion path. Confirmed, and it was worse than the write alone. selectorFor feeds the overlay twice: useMotionPathData measures the element's home position by it (MotionPathOverlay.tsx:93) and the live lookup at :177 resolves the node the destination is computed against. With a class sibling, frame.contentDocument.querySelector(".group") returns the FIRST sibling, so the destination was measured off the wrong element before commitCreatePath wrote the path onto all of them. Fixed at the one owner: selectorFor now goes through writeTargetSelector, so both halves address the same single element. When no one-element form exists, canCreate is false and the toolbar hides "set destination" instead of arming a press the effect would drop. Locked by motionPathSelection.test.ts.

P2, the ?? selector fallback. Removed at all five sites. Falling back to the selection's own selector wrote the exact group-collapsing target the narrowing exists to prevent, so each writer now drops the commit: gsapDragCommit's five branches through newTweenTarget, useAnimatedPropertyCommit at both sites, gsapKeyframeCommit, and useGestureCommit. A gesture that does not persist reverts visibly on the next reload; a tween silently aimed at five elements does not. useGestureCommit shows a toast because a recording is expensive to redo. Locked by a commitKeyframeAtTimeImpl case asserting no commit is made.

P2, tweenTargetsElement. Confirmed. The DOM fallback now requires the target to match exactly one element, so a target the element merely shares with its siblings no longer counts. Those lookups feed mutations, so the old element.matches let an individual nudge rewrite the group's own tween and move all five. Deliberate group edits still work through the string-equality rung, where the selection IS the group. Four cases added covering both directions plus an invalid selector.

Studio suite: 3081 passing, 0 failures. Typecheck, lint, format clean. PR body updated with all five bugs.

Two latent defects in the announcement path this branch adds.

- lanesId was keyed by render row alone, so a second TimelineLanes on the
  page (a mini-timeline beside the main one) would mint the same
  timeline-lanes-track-0 and every caret's aria-controls would resolve to
  whichever instance mounted first. The prefix now comes from useId, with the
  colons stripped so the id stays a legal CSS selector.
- trackDisplayNumber returned trackOrder.length + 1 for a key it could not
  find, which is indistinguishable from a real row: the label announced a row
  the user can see is wrong and nothing upstream could tell it had guessed. It
  returns null now, and trackDisplaySuffix drops the number from the label
  rather than inventing one.
U3 fixed "add keyframe at playhead" widening a write to every sibling
sharing a class, but wired writeTargetSelector into only two paths. The
same bug was still reachable from the add-animation button, drag, resize,
rotate, gesture recording, and the property panel: each derived its target
from selectorFromSelection, which hands back a bare class for an id-less
element, so one edit authored a tween over all five siblings and the
timeline collapsed their rows into one.

Route every path that authors a NEW tween through the existing ladder:

- ensureElementAddressable now accepts selection.selector only when it
  addresses exactly one element, so the id-minting fallback right below it
  (previously unreachable whenever any selector was present) does the work.
- gsapDragCommit's five new-tween branches go through one newTweenTarget
  helper; instant patches reuse the written target so the runtime moves the
  element the source write names.
- useGestureCommit and useAnimatedPropertyCommit keep the existing selector
  for matching/retargeting and author new tweens with a separate write
  selector.

Retargets of an EXISTING tween are deliberately untouched: they keep
anim.targetSelector, so a tween aimed at a whole group stays aimed at it.

Narrowing the write alone regressed idempotency, verified by test: the
"is there already a write for this element" lookups matched targetSelector
by string, so the next nudge missed the write it had just made and appended
a second, conflicting one. The read half now falls back to the live DOM
(tweenTargetsElement, same contract as getAnimationsForElement), which also
still matches a deliberate group tween.

Tests reproduce each site through a real writer, re-parse with the real
parser, and resolve through resolveSelectorElementIds (what feeds the
keyframe cache and the lanes), plus pins for the new-tween vs
retarget-existing distinction so a future change cannot collapse the two.
The narrowing this branch adds missed the motion-path overlay, and every
caller that could not narrow fell back to the exact bare class the narrowing
exists to replace.

- motionPathSelection.selectorFor now goes through writeTargetSelector. It
  feeds both the geometry read and the "set destination" write, so a class
  sibling measured its home off the FIRST sibling and then authored
  add-motion-path onto all of them. The toolbar toggle hides when no
  one-element form exists rather than arming a press that is dropped.
- The five new-tween writers that fell back to the selection's own selector
  now drop the commit instead. A gesture that does not persist reverts on the
  next reload; a tween silently aimed at five elements does not.
- tweenTargetsElement only follows the DOM to a target that matches exactly
  one element. A target the element merely shares with its siblings is a group
  tween, and these callers mutate what they find, so an individual nudge was
  rewriting the group's own tween and moving all five.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-new-tween-target branch from da155bc to 1f3fd28 Compare July 28, 2026 19:22

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

R2 reviewed at 1f3fd28 (delta from 643e56b).

Clean R2. Every open R1 finding closed, and Miguel's fix went further than what I'd asked for at every one.

Blocker CLOSEDmotionPathSelection.selectorFor now routes through writeTargetSelector. Bonus over the R1 fix I'd suggested: MotionPathOverlay.tsx:135 guards canCreate on the narrowed selector existing, so the toolbar "set destination" toggle stays hidden rather than arming a press that would silently drop. And the docblock catches a consequence I'd missed — the OLD bare .group was ALSO the string the geometry read used, so a class-sibling read its home position off the FIRST sibling and then wrote the path onto all five. Two bugs closed by one narrowing. motionPathSelection.test.ts pins both the write-side and the null-return semantics.

Concern CLOSED (fallback semantics) — Instead of promoting newTweenTarget to a shared helper with a rationale docblock (my nit), the R2 changes the CONTRACT: newTweenTarget returns string | null, and every caller now drops the commit on null rather than falling back to selector. The rewritten writeTargetSelector docblock spells out the trade — "a gesture that does not persist reverts visibly on the next reload, where a tween silently aimed at five elements does not." useGestureCommit even surfaces a user-visible toast ("Cannot save: element has no unique selector") instead of silently dropping. Stronger fix than what R1 asked for, correctly.

Bug I missed in R1 — Worth calling out so future-me remembers: R1 read tweenTargetsElement's element.matches(targetSelector) fallback as "still matches a deliberate group tween so merges into it keep working." That's wrong: because these callers MUTATE what they find, a per-element nudge that resolves to the group tween (.group matches all five siblings via element.matches) rewrites the group's own tween and moves all five — the same class-collapse bug from a different direction. R2's fix uses matchesExactlyOne in the DOM fallback so only a write narrowed to THIS element hits, and the string-equality path keeps deliberate group edits working. The new "does not hand an individual element the group tween it merely inherits" test pins exactly this. Nice catch by Miguel.

Remaining, small:

  • The tryGsapDragIntercept two-tier match at gsapRuntimeBridge.ts:214 still reads as a hot-path exact-match with the general fallback below. Behaviour is fine; a one-liner naming the split saves a reader the trip. Nit.
  • With the new matchesExactlyOne-based tweenTargetsElement, the "first-parsed wins between overlapping tweens" seam narrows to the legacy .group write coexisting with a narrowed #group-2 write for the same element (which requires both targetSelector === selector in the legacy row AND matchesExactlyOne in the narrowed row). Only a mixed-legacy migration window; still a follow-up seam, not this PR's scope.
  • useGsapAnimationOps.addWithKeyframes is dead in packages/studio today but exposes a raw-selector API that would bypass the guard if a future caller adopts it. Delete or wire through writeTargetSelector. Housekeeping.

Overall: the write/read pair (writeTargetSelector + matchesExactlyOne-based tweenTargetsElement) is a load-bearing invariant now, and R2 pinned it with tests at both halves. Solid execution on a bug class that already burned QA once.

Review by Rames D Jusso

vanceingalls
vanceingalls previously approved these changes Jul 28, 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.

R2 — verifying R1 findings at the new head

Rebuilt at 1f3fd2800cd2e4df9e8a2c2b81ea11ed5b799630, three commits above R1 (643e56b4):

  • ba0d640 fix(studio): scope lane ids per timeline and stop inventing a track row
  • 3d92436 fix(studio): author every new tween against one element
  • 1f3fd28 fix(studio): give the motion path and the fallbacks a one-element target

The last commit's message is the fix for the R1 blocker.

Per-finding verdict

P0 — motion-path overlay authored the bare class (commitCreatePath / MotionPathOverlay.tsx:190). RESOLVED.
motionPathSelection.ts:21-23 now routes selectorFor through the same writeTargetSelector ladder every other new-tween writer uses. Both halves of the overlay read it — the geometry measure at MotionPathOverlay.tsx:93 and the create write at :133 — so the home-position sample and add-motion-path write agree on one element. When no one-element form exists, canCreate at :138 is false and the toolbar hides "set destination" rather than arming a press that gets dropped. Test coverage is direct: motionPathSelection.test.ts mounts five class siblings, drives selectorFor on the third, asserts the result is not .group and addresses exactly one element, plus null-safety cases.

P2 #1 — five ?? selector fallbacks in the drag/property/gesture writers. RESOLVED.
All five new-tween sites in gsapDragCommit.ts (replaceKeyframedPositionHold, commitStaticGsapPosition, commitStaticGsapRotation, commitStaticGsapSize, commitKeyframedSizeFromResize) now go through one newTweenTarget helper (line 60-62) that returns writeTargetSelector(selection) and every caller gates on if (!target) return; — no ?? selector fallback. Same shape in useAnimatedPropertyCommit.ts:256 and :500, and in useGestureCommit.ts:184-188 (with an explicit toast when the recording cannot persist). Locked at newTweenTarget.test.ts and newTweenTargetHooks.test.tsx — 619 test lines that author through the real writer, re-parse with the real parser, and read attribution back through resolveSelectorElementIds. commitKeyframeAtTimeImpl also gets a "drops rather than widens" pin (gsapShared.writeTarget.test.ts:278-289).

P2 #2tweenTargetsElement element.matches fallback silently retargeted group tweens. RESOLVED.
Not deferred, actually fixed. The DOM fallback at gsapShared.ts:311-319 is now matchesExactlyOne, not element.matches. String equality on the target still lets the group edit its own tween ("does the selection IS the group" case), but an individual nudge on one of five .group siblings no longer inherits the group tween — it authors its own write instead. Four pinning tests at gsapShared.writeTarget.test.ts:248-275 — including the direct "does not hand an individual element the group tween it merely inherits" case — lock the outcome one way.

Fresh adversarial pass at new head

  • Bonus fix I missed at R1. ensureElementAddressable in gsapScriptCommitHelpers.ts:19-40 was a tenth writer site (reached from the add-animation button): pre-fix it returned selection.selector unconditionally, aiming the write at every sibling sharing the class. Now it accepts the selector only when matchesExactlyOne and falls through to id-mint otherwise. The id it writes back to the source is a stronger fix than a structural path here because every later lookup on this element becomes exact. Locked at newTweenTarget.test.ts "ensureElementAddressable — add-animation button".
  • Retargets deliberately excluded and verified. gsapDragPositionCommit.ts (three sites), commitWholePathOffset, and useEnableKeyframes.promoteSetToKeyframes write targetSelector: anim.targetSelector, so a tween the author aimed at a group stays aimed at it. Consistent with the PR body's exclusion list.
  • One residual widening path I want on your radar (not a blocker). In useGestureCommit.ts:216 and :262 the replace-with-keyframes mutations pass targetSelector: selector (the read-side selection selector), not existingPositionTween.targetSelector. existingPositionTween is found via tweenTargetsElement, which the P2 #2 fix widened to include narrowed tweens — so a class sibling whose narrowed instant-hold was minted by a prior drag can now be picked up here, and the merge rewrites the tween's target from the narrowed form back to .group. Reachability: (a) prior static drag on .group sibling → narrowed instant hold, (b) gesture recording on the same element, (c) isInstantHold or overlapping-time branch. Existing test "leaves a merged existing group tween aimed at its whole group" pins the intended behaviour when the existing tween is genuinely the group tween (targetSelector = .group), which is consistent with the PR body's "keep existing selector for retargeting" — but the narrowed-existing case isn't covered. Two options: use existingTweenTargetSelector here (the helper's own docstring warns against exactly this widening) or add a test that pins the narrowed-instant-hold case one way or the other. Non-blocking because the primary ten sites are all fixed and this requires a specific setup, but noting because it's the same class of defect the PR is closing.

Envelope

Three commits, all authored by Miguel, no Co-Authored-By in messages. CI: Preflight, Preview parity, player-perf, regression, preview-regression all pass. Perf and regression-shards matrix jobs correctly skipping. Graphite mergeability_check pending as expected for a stack top.

Verdict

Approving. The R1 blocker is closed with test coverage locking the exact bare-class shape at the overlay's selector helper. Both R1 P2s are fully resolved — the ?? selector fallbacks are replaced with a null-return + upstream gate at every site, and the element.matches fallback is narrowed to matchesExactlyOne with four tests pinning the group-exclusivity distinction. The fresh pass turned up one narrow residual worth a follow-up but no new blocker.

Grade: A. Comprehensive site coverage (10 primary writers + the bonus ensureElementAddressable catch), tests that round-trip through the real parser, and prose that names the exact failure at each site. The one point off is the useGestureCommit replace-with-keyframes residual, which is reachable through the PR's own machinery and deserves either a wire-through or a pinning test.

Review by Via

@miguel-heygen
miguel-heygen changed the base branch from fix/studio-track-numbers-aria to main July 28, 2026 19:42
@miguel-heygen
miguel-heygen dismissed vanceingalls’s stale review July 28, 2026 19:42

The base branch was changed.

@miguel-heygen
miguel-heygen merged commit 557569d into main Jul 28, 2026
47 of 57 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-new-tween-target branch July 28, 2026 19:57
dahans-msft2 pushed a commit to dahans-msft2/hyperframes that referenced this pull request Aug 6, 2026
…en-target

fix(studio): author every new tween against one element
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