fix(studio): settle boundary retimes, delete every keyframed tween, tighten the test locks

Review follow-ups on the expanded keyframe lanes.

Writer:
- `onMoveKeyframe`'s flat-tween boundary branch answered `true` the moment it
  dispatched update-meta, so a rejected write left the diamond parked at its drop
  position. `observeGsapMutation` now resolves to whether the mutation landed and
  the boundary branch returns it, matching the other branches.
- "Delete All Keyframes" cleared only the first keyframed tween on the layer, so
  a layer with position AND opacity keyframes kept half of them. It now walks
  every keyframed tween, serially, through the clicked element's selection.
- The post-convert lookup in `commitFlatViaKeyframes` matched by target selector,
  which picks an arbitrary tween when a target carries several. Match by id first.

Interaction and a11y:
- A rejected retime whose commit settled after a newer drag reverted the
  selection to its own source keyframe, undoing a retime the user could see. The
  revert now only runs while it is still the lane's latest gesture.
- Diamonds key on the authored identity instead of index plus rendered clip-%, so
  a neighbour's retime no longer remounts the button mid-drag.
- The disclosure caret gets `aria-controls` on an always-mounted lanes container,
  and both it and the property-group toggle grow to the 24x24 WCAG 2.2 minimum.
- `LayerDisclosureRow` takes the same adaptive `columnWidth` as its sibling lane
  rows instead of hardcoding LABEL_COL_W over the canvas.

Test locks:
- The timeline callbacks harness resolves a DISTINCT selection per element, so
  the clicked-element writes are actually pinned; three assertions that passed
  either way now name the clicked element's selection.
- New: null-selection aborts every mutation, delete-all covers both tweens, a
  rejected boundary retime reports `false`, and a stale revert leaves selection.
- The playhead-percentage assertion checks 25, not `expect.any(Number)` (which
  also accepts NaN); ease segments assert their label ORDER, not just that the
  three curves differ; the collapsed-diamond callback asserts the whole target.
- Dropped a duplicate `selection override` describe left by a rebase.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 01:19:05 +02:00
parent c20c5366da
commit 8470b88aa1
10 changed files with 325 additions and 74 deletions
@@ -16,11 +16,11 @@ const mocks = vi.hoisted(() => ({
handleGsapMoveKeyframeToPlayhead: vi.fn(),
handleGsapMoveKeyframe: vi.fn().mockResolvedValue(true),
handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true),
handleGsapUpdateMeta: vi.fn(),
handleGsapUpdateMeta: vi.fn().mockResolvedValue(true),
handleGsapAddKeyframe: vi.fn(),
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveAllKeyframes: vi.fn(),
handleGsapRemoveAllKeyframes: vi.fn().mockResolvedValue(true),
handleGsapDeleteAnimation: vi.fn(),
buildDomSelectionForTimelineElement: vi.fn(),
},
@@ -116,6 +116,19 @@ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => v
return { callbacks, unmount: () => act(() => root.unmount()) };
}
// One selection PER element, so a callback that resolves the selection for the
// wrong element gets a visibly different object. A single mockResolvedValue
// hands every element the same selection, which passes just as happily when the
// write is committed through whatever happens to be selected.
function selectionForElement(el: TimelineElement): {
id: string;
selector: string;
sourceFile: string;
} {
if (el.id === "box") return mocks.selection;
return { id: el.id, selector: `#${el.id}`, sourceFile: el.sourceFile ?? "index.html" };
}
function arrangeClickedCircle(): {
circle: TimelineElement;
selection: { id: string; selector: string; sourceFile: string };
@@ -128,19 +141,19 @@ function arrangeClickedCircle(): {
domId: "circle",
sourceFile: "scenes/main.html",
};
const selection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([[elementKey, [otherKeyframedAnimation]]]),
});
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(selection);
return { circle, selection };
return { circle, selection: selectionForElement(circle) };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.animations = [flatAnimation];
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(mocks.selection);
mocks.actions.buildDomSelectionForTimelineElement.mockImplementation((el: TimelineElement) =>
Promise.resolve(selectionForElement(el)),
);
usePlayerStore.setState({
currentTime: 0.5,
elements: [element],
@@ -212,6 +225,27 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
view.unmount();
});
it("reports an unsettled flat-boundary retime as uncommitted", async () => {
mocks.actions.handleGsapUpdateMeta.mockResolvedValueOnce(false);
const view = renderCallbacks();
// The diamond snaps back on `false`. Answering `true` the moment update-meta
// was dispatched left a rejected boundary drag rendered at its drop position.
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: flatAnimation.id,
},
25,
),
).resolves.toBe(false);
view.unmount();
});
it("refuses a non-selected element flat boundary instead of deleting the tween", async () => {
const circle: TimelineElement = {
...element,
@@ -242,7 +276,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
otherFlatAnimation.id,
0,
undefined,
mocks.selection,
selectionForElement(circle),
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
@@ -276,7 +310,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
otherKeyframedAnimation.id,
100,
undefined,
mocks.selection,
selectionForElement(circle),
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
@@ -298,6 +332,65 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
view.unmount();
});
it("deletes all keyframes on every keyframed tween of the layer, not just the first", async () => {
const opacityAnimation: GsapAnimation = {
...otherKeyframedAnimation,
id: "circle-to-0-visual",
propertyGroup: "visual",
};
const { circle } = arrangeClickedCircle();
usePlayerStore.setState({
gsapAnimations: new Map([
["scenes/main.html#circle", [otherKeyframedAnimation, opacityAnimation]],
]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteAllKeyframes?.(circle);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveAllKeyframes.mock.calls.map((call) => call[0])).toEqual([
otherKeyframedAnimation.id,
opacityAnimation.id,
]);
view.unmount();
});
it("aborts every mutation when the clicked element resolves no selection", async () => {
const { circle } = arrangeClickedCircle();
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteAllKeyframes?.(circle);
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
await Promise.resolve();
await Promise.resolve();
});
// No selection for the clicked element means there is nothing safe to write
// to: falling back to the current selection would edit a different file.
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
const { circle, selection } = arrangeClickedCircle();
const view = renderCallbacks();
@@ -398,7 +491,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
otherFlatAnimation.id,
0,
undefined,
mocks.selection,
selectionForElement(circle),
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
@@ -194,10 +194,18 @@ export function useTimelineEditCallbacks({
// than deleting the whole animation — deleting strands a stale GSAP base
// that the next drag adds to, flinging the element off-screen.
const elementKey = getTimelineElementIdentity(element);
const anim = resolveElementAnimations(elementKey).find((animation) => animation.keyframes);
if (!anim) return;
void buildDomSelectionForTimelineElement(element).then((selection) => {
if (selection) handleGsapRemoveAllKeyframes(anim.id, selection);
// Every keyframed tween on the layer, not just the first: a layer with
// position AND opacity keyframes left the second one keyframed, so
// "Delete All Keyframes" visibly did half the job.
const anims = resolveElementAnimations(elementKey).filter(
(animation) => animation.keyframes,
);
if (anims.length === 0) return;
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
if (!selection) return;
// Serial: each removal rewrites the same source file, so dispatching
// them together would have the later writes read a pre-edit document.
for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
});
},
onDeleteKeyframe: (elId, keyframe) => {
@@ -287,12 +295,14 @@ export function useTimelineEditCallbacks({
// keyframes form as a side effect of a pure position/duration change, so
// dispatch update-meta and leave the tween as the author wrote it.
if (decision.pctRemap.length === 0) {
handleGsapUpdateMeta(
// Report the write's real settlement, like every other branch here:
// answering `true` while the meta update is still in flight tells the
// diamond the retime landed, so a rejected write never snaps back.
return handleGsapUpdateMeta(
target.animId,
{ position: decision.position, duration: decision.duration },
sel,
);
return true;
}
return handleGsapResizeKeyframedTween(
target.animId,