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 16:40:24 -07:00
co-authored by Claude Opus 5
parent fa0f12a574
commit db1b81357d
2 changed files with 52 additions and 33 deletions
@@ -891,7 +891,11 @@ describe("TimelineTrackHeader", () => {
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-groups");
const view = renderHeader({
@@ -903,14 +907,20 @@ describe("TimelineTrackHeader", () => {
isAudioTrack: true,
});
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];
expect(lines?.children).toHaveLength(2);
// And it is on the second line, beside the visibility control.
const controlLine = lines?.children[1];
expect(lines?.children).toHaveLength(1);
// The controls live in a right-anchored group at the end of that line,
// 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(
controlLine?.querySelector('button[aria-label="Effects — group these clips first"]'),
controls?.querySelector('button[aria-label="Effects — group these clips first"]'),
).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());
});
});
@@ -80,43 +80,52 @@ export function PlainTrackHeader({
}) {
return (
<>
{/* Line one: what the row IS. Line two (below) is what you can do to it —
the same split the group header uses, for the same reason: a name and
four controls sharing 232px truncated the name to a few characters. */}
{/* One line: the name, then every control pushed to the right edge. The
two-line split this replaced existed to stop four controls truncating
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">
{isAudioTrack && (
<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 && (
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
<span className="min-w-0 truncate text-[11px]" title={trackLabel}>
{trackLabel}
</span>
)}
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
</div>
<div className="flex items-center gap-1">
{/* Not on an audio track. The control is the old visibility eye, and on
audio it silences rather than hides — but a row that already says 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.
{/* `ml-auto` is what anchors the group right: it absorbs the slack the
truncating name leaves, so the controls sit on the edge whatever the
name's length. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
{/* Not on an audio track. The control is the old visibility eye, and
on audio it silences rather than hides — but a row that already says
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
unconditionally withheld the only way back: `data-hidden` silences the
clip in preview and drops it from the render, the panel's "Muted" is
the unrelated HTML `muted` attribute, and nothing else writes it — so a
track hidden before this rule (or by "Hide all", or by hand) was
silent with no control anywhere to restore it. Offering the eye only
in that state keeps the affordance off a normal audio row while
leaving the door open from the inside. */}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible={!isAudioTrack || isTrackHidden}
onToggle={onToggleTrackHidden}
/>
{trailing}
EXCEPT when the audio track is ALREADY hidden. Withholding the
control unconditionally withheld the only way back: `data-hidden`
silences the clip in preview and drops it from the render, the
panel's "Muted" is the unrelated HTML `muted` attribute, and nothing
else writes it — so a track hidden before this rule (or by "Hide
all", or by hand) was silent with no control anywhere to restore it.
Offering the eye only in that state keeps the affordance off a normal
audio row while leaving the door open from the inside. */}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible={!isAudioTrack || isTrackHidden}
onToggle={onToggleTrackHidden}
/>
{trailing}
</div>
</div>
</>
);