From 82cfb361ef3ba6c9e020532cf38a5b51bab1943f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 19 Aug 2026 00:25:36 -0700 Subject: [PATCH] fix(studio): two-line track headers, and a real solo button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/player/components/Timeline.test.ts | 5 +- .../player/components/TimelineGroupHeader.tsx | 24 ++++--- .../player/components/TimelineSoloButton.tsx | 20 ++++-- .../player/components/TimelineTrackHeader.tsx | 43 +++++++----- .../components/TimelineTrackPlainHeader.tsx | 69 +++++++++++-------- 5 files changed, 100 insertions(+), 61 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 46df3319e..550a9b1ed 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -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); diff --git a/packages/studio/src/player/components/TimelineGroupHeader.tsx b/packages/studio/src/player/components/TimelineGroupHeader.tsx index 6fca6a656..b10f84963 100644 --- a/packages/studio/src/player/components/TimelineGroupHeader.tsx +++ b/packages/studio/src/player/components/TimelineGroupHeader.tsx @@ -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 }); }} > - + {/* 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). */} + event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onToggle({ add: event.metaKey || event.ctrlKey }); }} > - + {/* 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. */} + ); } diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 9c88b7cc9..dba23258f 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -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") && ( + 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") && ( - 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. */} diff --git a/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx b/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx index e5dacfcd9..19a404569 100644 --- a/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx @@ -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 && ( -