fix(studio): address family B timeline review findings

- revert diamond selection when a rejected retime leaves the source in place
- clear project-local ease focus and expansion on player store reset
- share one static-position-hold predicate across the tween cache
- invalidate the GSAP cache even when a group timing rewrite throws
- stamp each lane keyframe's ease from its own source tween
- memoize property lanes and row offsets so memo'd diamond lanes hold
- use the editable tween duration for drag position commits
- restore the pre-t=0 pad in the all-collapsed content origin
- clamp the drag ghost and drop placeholder to the collapsed clip height
- aria-expanded on the layer disclosure, aria-pressed plus state-specific
  labels on the keyframe toggle, 24px chevron targets, focus-visible parity
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:40:42 +02:00
parent 4a12eb9d9c
commit 282cc883e5
16 changed files with 284 additions and 103 deletions
@@ -32,6 +32,7 @@ function animation(
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>,
): GsapAnimation {
return {
@@ -126,7 +127,7 @@ describe("TimelineTrackHeader", () => {
const onTogglePropertyGroupKeyframe = vi.fn();
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe");
click(view.host, "Add Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT,
expect.objectContaining({
@@ -139,7 +140,7 @@ describe("TimelineTrackHeader", () => {
);
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe");
click(view.host, "Remove Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT,
expect.objectContaining({
@@ -214,13 +215,13 @@ describe("TimelineTrackHeader", () => {
it("fills the toggle diamond exactly at that group's keyframe", () => {
const view = renderHeader({ currentTime: 0.5 });
const positionToggle = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle Position keyframe"]',
'button[aria-label="Add Position keyframe"]',
);
expect(positionToggle?.textContent).toBe("◇");
view.rerender({ currentTime: 1 });
expect(
view.host.querySelector<HTMLButtonElement>('button[aria-label="Toggle Position keyframe"]')
view.host.querySelector<HTMLButtonElement>('button[aria-label="Remove Position keyframe"]')
?.textContent,
).toBe("◆");
act(() => view.root.unmount());
@@ -241,6 +242,36 @@ describe("TimelineTrackHeader", () => {
act(() => view.root.unmount());
});
it("samples mid-segment values along the segment's ease, not linearly", () => {
// GSAP hangs a segment's ease on the keyframe it arrives at, so 0% -> 50%
// runs power2.in. Half way through that segment power2.in(0.5) = 0.125, so
// the readout is 12.5/6.25 and NOT the linear 50/25.
const eased = animation("eased-position", "position", [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 50, properties: { x: 100, y: 50 }, ease: "power2.in" },
{ percentage: 100, properties: { x: 200, y: 100 }, ease: "power2.in" },
]);
const onTogglePropertyGroupKeyframe = vi.fn();
const view = renderHeader({
animations: [eased],
currentTime: 0.5,
onTogglePropertyGroupKeyframe,
});
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
"12.5, 6.25",
);
// The same sampled value is what an added keyframe gets stamped with, so a
// header insert lands on the existing curve instead of deforming it.
click(view.host, "Add Position keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenCalledOnce();
expect(onTogglePropertyGroupKeyframe.mock.calls[0][1]).toMatchObject({
properties: { x: 12.5, y: 6.25 },
});
act(() => view.root.unmount());
});
it("disables the previous chevron at or before the group's first keyframe", () => {
const view = renderHeader({ currentTime: 0 });
const prevAt0 = view.host.querySelector<HTMLButtonElement>(