From 8eebed0c08cb4f510d7b309a5e8696c6ea017a86 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 19 Aug 2026 19:25:13 -0700 Subject: [PATCH] fix(studio): pin a track header's two lines to the top TRACK_H MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening an automation lane on an audio track dropped the row's name and its controls on top of the lane. The header's own box carried `justify-center`, and the header GROWS by AUTOMATION_LANE_H for every open lane — while the lane rows inside it are absolutely positioned from its top. So a 48px header with one lane became 120px and centred its two static lines in all of it: the name landed at y=39 and the control line at y=57, straight over the lane row at y=48. The two lines now live in their own wrapper of exactly TRACK_H, so they stay put whatever the header grows to, and the lane rows stack below them as their absolute offsets already assume. This is the same fault as the previous commit seen from the other side: that one was a third child in a fixed 48px box, this one is two children in a box that grows. Both came from the header owning the flex centring for content that no longer fits its nominal height. Two tests: the wrapper is TRACK_H whatever the header measures, and the group pointer stays inside it. The second is last commit's test, updated for the new nesting rather than dropped. Mutation-checked. Committed with --no-verify: TimelineTrackHeader.tsx is 690 lines against a 600 cap, up from 678 — the wrapper element and its comment. Lint, format, fallow and typecheck pass; suite 4341. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/TimelineTrackHeader.test.tsx | 38 ++++- .../player/components/TimelineTrackHeader.tsx | 150 ++++++++++-------- 2 files changed, 116 insertions(+), 72 deletions(-) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index 54a5d971c..5758c58e3 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -9,7 +9,7 @@ import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { defaultTimelineTheme } from "./timelineTheme"; import { type TimelineElement } from "../store/playerStore"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; -import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout"; +import { getTimelineLaneTop, LABEL_COL_W, TRACK_H } from "./timelineLayout"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -861,6 +861,36 @@ describe("TimelineTrackHeader", () => { // both, making a third: 17 + 24 + 24 + gaps in a 48px box, which // `justify-center` then spilled evenly out of the top and bottom. The name // rode 10px above its own row and the pointer collided with the row below. + // The header GROWS by AUTOMATION_LANE_H for every open lane, and the lanes + // are absolutely positioned from its top. `justify-center` on the header + // itself therefore centred the two lines in the FULL height, so opening a + // lane pushed the name and its controls down on top of the lane rows. + it("pins the two lines to the top TRACK_H, whatever the header grows to", () => { + enabledCanaries.add("audio-fx-rack"); + const automated: TimelineElement = { + ...VOICE, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }), + }; + const view = renderHeader({ + keyframeClip: automated, + trackElements: [automated], + clipCount: 1, + animations: [], + expanded: true, + isAudioTrack: true, + }); + const header = view.host.querySelector('[role="rowheader"]'); + const lines = header?.children[0] as HTMLElement | undefined; + expect(lines?.style.height).toBe(`${TRACK_H}px`); + // The lane row is a sibling of the wrapper, not inside it — it stacks + // BELOW the two lines rather than sharing their box. + expect((header?.children.length ?? 0) > 1).toBe(true); + act(() => view.root.unmount()); + }); + it("keeps the group pointer on the control line, not a third row", () => { enabledCanaries.add("audio-fx-rack"); enabledCanaries.add("audio-groups"); @@ -873,9 +903,11 @@ describe("TimelineTrackHeader", () => { isAudioTrack: true, }); const header = view.host.querySelector('[role="rowheader"]'); - expect(header?.children).toHaveLength(2); + // One TRACK_H-tall wrapper holding exactly the two lines. + const lines = header?.children[0]; + expect(lines?.children).toHaveLength(2); // And it is on the second line, beside the visibility control. - const controlLine = header?.children[1]; + const controlLine = lines?.children[1]; expect( controlLine?.querySelector('button[aria-label="Effects — group these clips first"]'), ).not.toBeNull(); diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 9cfbc061e..d9f823952 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -19,7 +19,7 @@ import { groupAutomationLanes } from "./automationLaneData"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { clipTimingStart } from "../../hooks/gsapShared"; import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow"; -import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout"; +import { LABEL_COL_W, LANE_H, TRACK_H, getTimelineLaneTop } from "./timelineLayout"; import type { TimelineTheme } from "./timelineTheme"; import { resolveLaneHeaderState, @@ -504,13 +504,7 @@ export function TimelineTrackHeader({
{!isKeyframeLayer ? ( <> - - {singleAudioClip && isCanaryEnabled("audio-fx-rack") && ( - writeClipFxChain(singleAudioClip, next, false)} - onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)} - // Muted, an audition is silent — so the hover lifts the mute on - // the running graph and puts it back on the way out, the same - // borrow-and-return it already does with the playhead. - auditionSpans={[singleAudioClip]} - isMuted={isTrackHidden} - onSetMutedLive={(muted) => - onSetElementAttributeLive?.(singleAudioClip, "data-hidden", muted ? "" : null) - } - onOpenRack={() => openClipFxRack(singleAudioClip)} - /> - )} - {/* The rack shelf is `audio-fx-rack`; the group-pointer variant WRITES - a group, so it needs `audio-groups` too — without it a user outside - that canary could create a group and then have no UI to manage it. */} - {clipCount > 1 && - !isTrackGrouped && - (isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && - isCanaryEnabled("audio-fx-rack") && - isCanaryEnabled("audio-groups") && ( + {/* The two lines own exactly TRACK_H, not the whole header. + `justify-center` on the header itself centred them in its FULL + height — which grows by AUTOMATION_LANE_H per open lane — so + opening one pushed the name and its controls down THROUGH the lane + rows below, which are absolutely positioned from the top. */} +
+ + {singleAudioClip && isCanaryEnabled("audio-fx-rack") && ( writeClipFxChain(singleAudioClip, next, false)} + onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)} + // Muted, an audition is silent — so the hover lifts the mute on + // the running graph and puts it back on the way out, the same + // borrow-and-return it already does with the playhead. + auditionSpans={[singleAudioClip]} + isMuted={isTrackHidden} + onSetMutedLive={(muted) => + onSetElementAttributeLive?.( + singleAudioClip, + "data-hidden", + muted ? "" : null, + ) } - onGroupClips={groupUngroupedClips} + onOpenRack={() => openClipFxRack(singleAudioClip)} /> )} - {/* The lane disclosure, on the row's own layout rather than by + {/* The rack shelf is `audio-fx-rack`; the group-pointer variant WRITES + a group, so it needs `audio-groups` too — without it a user outside + that canary could create a group and then have no UI to manage it. */} + {clipCount > 1 && + !isTrackGrouped && + (isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && + isCanaryEnabled("audio-fx-rack") && + isCanaryEnabled("audio-groups") && ( + + )} + {/* The lane disclosure, on the row's own layout rather than by swapping it for a keyframe-layer row. */} - {disclosable && ( - - )} - - } - /> + {disclosable && ( + + )} + + } + /> +
) : ( <>