fix(studio): pin a track header's two lines to the top TRACK_H

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) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 02:20:32 -07:00
co-authored by Claude Opus 5
parent 4ce9605014
commit 8f4504081d
2 changed files with 116 additions and 72 deletions
@@ -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<HTMLElement>('[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<HTMLElement>('[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();
@@ -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({
<div
role="rowheader"
aria-colindex={1}
className={`sticky left-0 z-[12] shrink-0 ${
!isKeyframeLayer
? showTrackLabel
? "flex flex-col justify-center gap-0.5 px-1.5 text-white/55"
: "flex flex-col items-center justify-center gap-0.5"
: ""
}`}
className="sticky left-0 z-[12] shrink-0"
style={{
width: showTrackLabel ? LABEL_COL_W : contentOrigin,
background: gutterFill(theme.gutterBackground, isGroupMember),
@@ -531,72 +525,90 @@ export function TimelineTrackHeader({
>
{!isKeyframeLayer ? (
<>
<PlainTrackHeader
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
trackLabel={trackLabel}
clipCount={clipCount}
showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
onToggleTrackHidden={onToggleTrackHidden}
// On the control line rather than a third row of its own.
trailing={
<>
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
<TimelineFxButton
variant="chain"
fxChainRaw={singleAudioClip.fxChain}
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
onChainChange={(next) => 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. */}
<div
className={
showTrackLabel
? "flex flex-col justify-center gap-0.5 px-1.5 text-white/55"
: "flex flex-col items-center justify-center gap-0.5"
}
style={{ height: TRACK_H }}
>
<PlainTrackHeader
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
trackLabel={trackLabel}
clipCount={clipCount}
showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
onToggleTrackHidden={onToggleTrackHidden}
// On the control line rather than a third row of its own.
trailing={
<>
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
<TimelineFxButton
variant="group-pointer"
clipCount={trackElements.length}
defaultLabel={trackLabel}
// Groups are audio-only in v1 (§1.4). A video track showing no
// button at all is the silent limit §5 forbids, so it gets the
// button and a reason instead.
refusal={
isAudioTrack
? undefined
: "Video audio can't be grouped yet — only audio clips can join a group."
variant="chain"
fxChainRaw={singleAudioClip.fxChain}
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
onChainChange={(next) => 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") && (
<TimelineFxButton
variant="group-pointer"
clipCount={trackElements.length}
defaultLabel={trackLabel}
// Groups are audio-only in v1 (§1.4). A video track showing no
// button at all is the silent limit §5 forbids, so it gets the
// button and a reason instead.
refusal={
isAudioTrack
? undefined
: "Video audio can't be grouped yet — only audio clips can join a group."
}
onGroupClips={groupUngroupedClips}
/>
)}
{/* The lane disclosure, on the row's own layout rather than by
swapping it for a keyframe-layer row. */}
{disclosable && (
<LaneToggleButton
name={laneOwnerName}
isExpanded={isExpanded}
lanesId={lanesId}
onToggle={onToggleClipExpanded}
/>
)}
</>
}
/>
{disclosable && (
<LaneToggleButton
name={laneOwnerName}
isExpanded={isExpanded}
lanesId={lanesId}
onToggle={onToggleClipExpanded}
/>
)}
</>
}
/>
</div>
</>
) : (
<>