feat(studio): wrap gutter names instead of truncating them

A truncated name needs a hover to be read at all, and a tooltip is no use
for a name you are scanning a column of. All three gutter labels — the
plain track header, the group header, and the keyframe layer row — now
wrap and drop their `title`.

`break-words` so a long single token breaks rather than forcing the column
wider. Measured at the 232px gutter: a name long enough to wrap takes two
lines (28px) inside the 48px row with room to spare; a third line would
overflow, which is what the row affords either way.

One test located the name by its `title` and now reads the rendered text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 02:21:19 -07:00
co-authored by Claude Opus 5
parent b6e5afd25d
commit 46ca2f3e56
4 changed files with 20 additions and 14 deletions
@@ -95,9 +95,9 @@ export function LayerDisclosureRow({
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40"> <span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
</span> </span>
<span className="min-w-0 flex-1 truncate font-medium" title={name}> {/* Wraps rather than truncating: a truncated name needs a hover to be
{name} read, which a scanned column cannot rely on. */}
</span> <span className="min-w-0 flex-1 break-words font-medium leading-tight">{name}</span>
<TrackClipCount clipCount={clipCount} /> <TrackClipCount clipCount={clipCount} />
{children} {children}
{/* Anchored right, on every header that has one: the lane toggle is the {/* Anchored right, on every header that has one: the lane toggle is the
@@ -65,7 +65,9 @@ function GroupNameButton({
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50"> <span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
</span> </span>
<span className="min-w-0 truncate font-medium">{label}</span> {/* Wraps rather than truncating — a name that needs a hover to be read
is no use in a column you scan. */}
<span className="min-w-0 break-words text-left font-medium leading-tight">{label}</span>
<span <span
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55" className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
aria-hidden="true" aria-hidden="true"
@@ -107,7 +109,7 @@ export function TimelineGroupHeader({
}} }}
> >
{/* One line, like a track header's: caret and name, then every control {/* One line, like a track header's: caret and name, then every control
anchored to the right edge. The name truncates and the controls are anchored to the right edge. The name wraps and the controls are
`shrink-0`, so they hold the edge and the name gives way — no second `shrink-0`, so they hold the edge and the name gives way — no second
line needed to keep five controls off the label. */} line needed to keep five controls off the label. */}
<button <button
@@ -747,7 +747,9 @@ describe("TimelineTrackHeader", () => {
it("names the header for the track, not for one of the clips on it", () => { it("names the header for the track, not for one of the clips on it", () => {
const view = renderHeader({ ...ROW, keyframeClip: NARRATION_2 }); const view = renderHeader({ ...ROW, keyframeClip: NARRATION_2 });
expect(view.host.textContent).not.toContain("narration-2"); expect(view.host.textContent).not.toContain("narration-2");
expect(view.host.querySelector('[title="Track 1"]')?.textContent).toBe("Track 1"); // Asserted on the rendered text, not a `title`: the name wraps now rather
// than truncating, so it no longer carries a tooltip to be found by.
expect(view.host.textContent).toContain("Track 1");
// Alone on the track it is still named for itself. // Alone on the track it is still named for itself.
view.rerender({ view.rerender({
...ROW, ...ROW,
@@ -755,7 +757,7 @@ describe("TimelineTrackHeader", () => {
trackElements: [NARRATION_2], trackElements: [NARRATION_2],
clipCount: 1, clipCount: 1,
}); });
expect(view.host.querySelector('[title="narration-2"]')?.textContent).toBe("narration-2"); expect(view.host.textContent).toContain("narration-2");
act(() => view.root.unmount()); act(() => view.root.unmount());
}); });
}); });
@@ -89,14 +89,16 @@ export function PlainTrackHeader({
{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 {/* No `flex-1`: the name takes only the width it needs, so the clip
truncating at `min-w-0` when the row is narrow) so the clip count count sits against it rather than being pushed out to meet the
sits against it rather than being pushed out to meet the controls. controls. The slack goes to the `ml-auto` group below instead.
The slack goes to the `ml-auto` group below instead. */}
Wraps rather than truncating: a truncated name needs a hover to be
read at all, and a tooltip is no use to a name you are scanning a
column of. `break-words` so a long single token breaks instead of
forcing the column wider. */}
{showTrackLabel && ( {showTrackLabel && (
<span className="min-w-0 truncate text-[11px]" title={trackLabel}> <span className="min-w-0 break-words text-[11px] leading-tight">{trackLabel}</span>
{trackLabel}
</span>
)} )}
{showTrackLabel && <TrackClipCount clipCount={clipCount} />} {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
{/* `ml-auto` is what anchors the group right: it absorbs the slack the {/* `ml-auto` is what anchors the group right: it absorbs the slack the