feat(studio): one-line track header, controls right-aligned

The name, the clip count and every control now share one line, with the
controls anchored to the right edge: eye, then FX, then the automation
toggle.

The two-line split this replaces existed to stop four controls truncating
the name to a few characters. It does not need a second line to do that:
the name already truncates on its own, and the controls are `shrink-0`, so
they hold the edge and the name gives way instead. `ml-auto` on the control
group absorbs whatever slack the name leaves, which is what keeps the
buttons on the edge at any name length.

The clip count sits against the name rather than out with the controls.
That took dropping `flex-1` from the name — with it, the name claimed all
the free width and pushed the badge across the row to meet the buttons.
Now the badge tracks the name's own width: measured 4px after it on every
row, whatever the name's length.

Also folds away the class of bug the last two commits fixed: with one line
and one right-aligned group there is no second line for a control to be
misfiled onto, and nothing to centre in a box that grows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 02:21:17 -07:00
co-authored by Claude Opus 5
parent b0bbbad95b
commit 4da0009bdd
2 changed files with 52 additions and 33 deletions
@@ -891,7 +891,11 @@ describe("TimelineTrackHeader", () => {
act(() => view.root.unmount()); act(() => view.root.unmount());
}); });
it("keeps the group pointer on the control line, not a third row", () => { // The header is ONE line: name, clip count, then every control anchored to
// the right edge. It was two — a name line and a control line — which is
// what let a stray third child overflow the 48px box; now there is a single
// row and the controls share one right-aligned group.
it("keeps the name and every control on one line, controls to the right", () => {
enabledCanaries.add("audio-fx-rack"); enabledCanaries.add("audio-fx-rack");
enabledCanaries.add("audio-groups"); enabledCanaries.add("audio-groups");
const view = renderHeader({ const view = renderHeader({
@@ -903,14 +907,20 @@ describe("TimelineTrackHeader", () => {
isAudioTrack: true, isAudioTrack: true,
}); });
const header = view.host.querySelector<HTMLElement>('[role="rowheader"]'); const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
// One TRACK_H-tall wrapper holding exactly the two lines. // One TRACK_H-tall wrapper holding one line.
const lines = header?.children[0]; const lines = header?.children[0];
expect(lines?.children).toHaveLength(2); expect(lines?.children).toHaveLength(1);
// And it is on the second line, beside the visibility control. // The controls live in a right-anchored group at the end of that line,
const controlLine = lines?.children[1]; // after the name and the clip count — `ml-auto` is what holds the edge.
const line = lines?.children[0];
const controls = line?.lastElementChild as HTMLElement | null;
expect(controls?.className).toContain("ml-auto");
expect( expect(
controlLine?.querySelector('button[aria-label="Effects — group these clips first"]'), controls?.querySelector('button[aria-label="Effects — group these clips first"]'),
).not.toBeNull(); ).not.toBeNull();
// And the clip count is beside the name, not out with the controls.
expect(controls?.querySelector('[aria-label="2 clips"]')).toBeNull();
expect(line?.querySelector('[aria-label="2 clips"]')).not.toBeNull();
act(() => view.root.unmount()); act(() => view.root.unmount());
}); });
}); });
@@ -80,35 +80,43 @@ export function PlainTrackHeader({
}) { }) {
return ( return (
<> <>
{/* Line one: what the row IS. Line two (below) is what you can do to it — {/* One line: the name, then every control pushed to the right edge. The
the same split the group header uses, for the same reason: a name and two-line split this replaced existed to stop four controls truncating
four controls sharing 232px truncated the name to a few characters. */} the name — but the name already truncates on its own (`min-w-0` plus
`truncate`), and the controls are `shrink-0`, so they hold the edge
and the name gives way instead. */}
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
{isAudioTrack && ( {isAudioTrack && (
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" /> <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)} )}
{/* No `flex-1`: the name takes only the width it needs (still
truncating at `min-w-0` when the row is narrow) so the clip count
sits against it rather than being pushed out to meet the controls.
The slack goes to the `ml-auto` group below instead. */}
{showTrackLabel && ( {showTrackLabel && (
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}> <span className="min-w-0 truncate text-[11px]" title={trackLabel}>
{trackLabel} {trackLabel}
</span> </span>
)} )}
{showTrackLabel && <TrackClipCount clipCount={clipCount} />} {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
</div> {/* `ml-auto` is what anchors the group right: it absorbs the slack the
<div className="flex items-center gap-1"> truncating name leaves, so the controls sit on the edge whatever the
{/* Not on an audio track. The control is the old visibility eye, and on name's length. */}
audio it silences rather than hides — but a row that already says what <div className="ml-auto flex shrink-0 items-center gap-1">
it is with a speaker does not also need the hide affordance sitting in {/* Not on an audio track. The control is the old visibility eye, and
the eye's slot. `visible={false}` rather than omitting the element, so on audio it silences rather than hides — but a row that already says
the spacer keeps every row's control columns aligned. what it is with a speaker does not also need the hide affordance
sitting in the eye's slot. `visible={false}` rather than omitting the
element, so the spacer keeps every row's control columns aligned.
EXCEPT when the audio track is ALREADY hidden. Withholding the control EXCEPT when the audio track is ALREADY hidden. Withholding the
unconditionally withheld the only way back: `data-hidden` silences the control unconditionally withheld the only way back: `data-hidden`
clip in preview and drops it from the render, the panel's "Muted" is silences the clip in preview and drops it from the render, the
the unrelated HTML `muted` attribute, and nothing else writes it — so a panel's "Muted" is the unrelated HTML `muted` attribute, and nothing
track hidden before this rule (or by "Hide all", or by hand) was else writes it — so a track hidden before this rule (or by "Hide
silent with no control anywhere to restore it. Offering the eye only all", or by hand) was silent with no control anywhere to restore it.
in that state keeps the affordance off a normal audio row while Offering the eye only in that state keeps the affordance off a normal
leaving the door open from the inside. */} audio row while leaving the door open from the inside. */}
<VisibilityButton <VisibilityButton
hidden={isTrackHidden} hidden={isTrackHidden}
trackNumber={trackNumber} trackNumber={trackNumber}
@@ -118,6 +126,7 @@ export function PlainTrackHeader({
/> />
{trailing} {trailing}
</div> </div>
</div>
</> </>
); );
} }