fix(studio): two-line track headers, and a real solo button

Extends the group header's split to every track row: line one is what the row
IS (the audio glyph, the name, the clip count), line two is what you can do to
it (mute, solo, FX). Same reason as the group — a name and four controls
sharing 232px truncated the name to a few characters.

The FX entry points were rendered by the parent AFTER the header component, so
they would have landed on a third line. `PlainTrackHeader` takes a `trailing`
slot for them instead: the caller still owns them, because only it knows the
clip they act on, but they sit on the control line where they belong.

Solo is a boxed `S` rather than `⌗`. That glyph is not solo anywhere — the
letter in a box is what an author who has met a DAW is looking for, and it is
what the designs draw beside `M` and `FX`. Filled when on, outlined when off,
because a colour change alone does not read at a glance across the track
column. The group's copy keeps three states, not two: filled when the group
itself is soloed, half-lit when a MEMBER is — the affordance for "this bus is
passing audio, but I did not solo it" (groups doc §2.2).

Verified live: soloing Vo 1 gives the member `border-[#F5C542] bg-[#F5C542]`,
its group `border-[#F5C542]/60 bg-[#F5C542]/25`, and an unrelated sibling
`border-white/30`.

One test walked `button.parentElement.parentElement` to reach the row and broke
on the extra level; it climbs from the rowheader now instead of counting.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, studio suite 4349 green.
This commit is contained in:
Vance Ingalls
2026-08-20 16:40:06 -07:00
parent 1524f4e57d
commit 82cfb361ef
5 changed files with 100 additions and 61 deletions
@@ -433,7 +433,10 @@ describe("Timeline provider boundary", () => {
button.click();
});
const row = button.parentElement?.parentElement;
// Up from the rowheader rather than counting parents: the header now lays
// its name and its controls out on two lines, so the button sits one level
// deeper than it used to.
const row = button.closest('[role="rowheader"]')?.parentElement;
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
const trackContent = row?.children.item(1);
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
@@ -190,20 +190,28 @@ export function TimelineGroupHeader({
aria-pressed={isSoloed}
aria-label="Hear only this"
title="Hear only this"
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
isSoloed
? "text-[#F5C542] hover:text-white"
: isHalfLitSolo
? "text-[#F5C542]/50 hover:text-white"
: "text-white/35 hover:text-white/75"
}`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleSolo({ add: event.metaKey || event.ctrlKey });
}}
>
<span aria-hidden="true"></span>
{/* Three states, not two: filled when this group is soloed, and
HALF-lit when a member is — the affordance for "this bus is
passing audio, but I did not solo it" (groups doc §2.2). */}
<span
aria-hidden="true"
className={`flex h-[15px] w-[15px] items-center justify-center rounded-[3px] border text-[10px] font-bold leading-none transition-colors ${
isSoloed
? "border-[#F5C542] bg-[#F5C542] text-black"
: isHalfLitSolo
? "border-[#F5C542]/60 bg-[#F5C542]/25 text-[#F5C542]"
: "border-white/30 text-white/45 hover:border-white/60 hover:text-white/80"
}`}
>
S
</span>
</button>
<TimelineFxButton
fxChainRaw={fxChain}
@@ -1,5 +1,6 @@
/**
* "Hear only this" — the `⌗` toggle beside a track's mute control. Session
* "Hear only this" — the boxed `S` beside a track's mute control, which is what
* a solo button looks like in every DAW an author might have met. Session
* state only (see `audioSoloSlice`): a plain click is exclusive, ⌘/Ctrl-click
* toggles membership without disturbing the rest of the set.
*/
@@ -17,16 +18,25 @@ export function TimelineSoloButton({
aria-pressed={isSoloed}
aria-label="Hear only this"
title="Hear only this"
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
isSoloed ? "text-[#F5C542] hover:text-white" : "text-white/35 hover:text-white/75"
}`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggle({ add: event.metaKey || event.ctrlKey });
}}
>
<span aria-hidden="true"></span>
{/* Filled when on, outlined when off — the state has to read at a glance
from across the track column, and a colour change alone does not. */}
<span
aria-hidden="true"
className={`flex h-[15px] w-[15px] items-center justify-center rounded-[3px] border text-[10px] font-bold leading-none transition-colors ${
isSoloed
? "border-[#F5C542] bg-[#F5C542] text-black"
: "border-white/30 text-white/45 hover:border-white/60 hover:text-white/80"
}`}
>
S
</span>
</button>
);
}
@@ -493,7 +493,7 @@ export function TimelineTrackHeader({
className={`sticky left-0 z-[12] shrink-0 ${
!isKeyframeLayer
? showTrackLabel
? "flex items-center gap-1 px-1.5 text-white/55"
? "flex flex-col justify-center gap-0.5 px-1.5 text-white/55"
: "flex flex-col items-center justify-center gap-0.5"
: ""
}`}
@@ -529,25 +529,30 @@ export function TimelineTrackHeader({
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
onToggleTrackHidden={onToggleTrackHidden}
// On the control line, beside mute and solo — not a third row.
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)}
/>
)}
</>
}
/>
{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. */}
@@ -1,3 +1,4 @@
import type React from "react";
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
import { isCanaryEnabled } from "../../telemetry/canary";
import { Music } from "../../icons/SystemIcons";
@@ -72,6 +73,7 @@ export function PlainTrackHeader({
isSoloed,
onToggleSolo,
onToggleTrackHidden,
trailing,
}: {
trackNumber: number;
trackDisplayNumber: number | null;
@@ -84,41 +86,52 @@ export function PlainTrackHeader({
isGroupMuted: boolean;
isSoloed: boolean;
onToggleSolo?: (options?: { add?: boolean }) => void;
/** Trailing controls that belong on the control line the FX entry points,
* which the caller owns because only it knows the clip they act on. */
trailing?: React.ReactNode;
}) {
return (
<>
{isAudioTrack && (
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)}
{showTrackLabel && (
<span
className={`min-w-0 flex-1 truncate text-[11px] ${
isAudioTrack && (isTrackHidden || isGroupMuted) && isCanaryEnabled("audio-track-mute")
? "line-through"
: ""
}`}
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
>
{trackLabel}
</span>
)}
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
{/* Not on an audio track. The control is the old visibility eye, and on
{/* 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. */}
<div className="flex min-w-0 items-center gap-1">
{isAudioTrack && (
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)}
{showTrackLabel && (
<span
className={`min-w-0 flex-1 truncate text-[11px] ${
isAudioTrack && (isTrackHidden || isGroupMuted) && isCanaryEnabled("audio-track-mute")
? "line-through"
: ""
}`}
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : 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. */}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible={!isAudioTrack}
isAudioTrack={isAudioTrack}
onToggle={onToggleTrackHidden}
/>
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
)}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible={!isAudioTrack}
isAudioTrack={isAudioTrack}
onToggle={onToggleTrackHidden}
/>
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
)}
{trailing}
</div>
</>
);
}