feat(studio): one-line group header, matching the track headers

The group row now reads like every other gutter row: caret, then the name
and its member count, then every control anchored to the right edge — FX
and the automation toggle in one right-aligned group, sharing the same
column as a member track's own.

Same reasoning as the track headers a commit ago. The second line existed
to keep five controls from squeezing the label, but the name truncates on
its own and the controls are `shrink-0`, so they hold the edge and the
name gives way instead.

Two things had to give for `ml-auto` to work:

- `GroupNameButton` lost its `flex-1`, and with it the spacer span that
  used to eat the slack to keep the count beside the name. The row's
  control group owns the slack now; leaving either in place pushed the
  controls — and the count, which rides inside that button — off the edge.
- The name button gained `h-6`. At its natural 17px it centred 4px lower
  than the 24px buttons beside it, so the four controls sat on three
  different baselines. All four now measure top 12, height 24.

Nothing asserted this header's shape, so the group side had none of the
protection the track side got: the test checks three children, the
controls in an `ml-auto` group, and the count NOT in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 02:21:18 -07:00
co-authored by Claude Opus 5
parent 01feecf31a
commit be71b59523
2 changed files with 60 additions and 35 deletions
@@ -52,7 +52,10 @@ function GroupNameButton({
tabIndex={-1}
aria-label={`Open ${label} effects`}
title="Open effects"
className="flex min-w-0 flex-1 items-center gap-1.5 rounded border-0 bg-transparent p-0 text-left text-[11px] text-white hover:text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
// No `flex-1`: the row's control group owns the slack now (`ml-auto`), so
// claiming it here would push the controls off the right edge — and the
// count with them, since it rides inside this button.
className="flex h-6 min-w-0 items-center gap-1.5 rounded border-0 bg-transparent p-0 text-left text-[11px] text-white hover:text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
@@ -70,10 +73,6 @@ function GroupNameButton({
>
{memberCount}
</span>
{/* Eats the slack so the count sits beside the name rather than drifting
to the far edge, while the button itself stays full width — the whole
name line is the target that opens the rack. */}
<span aria-hidden="true" className="min-w-0 flex-1" />
</button>
);
}
@@ -98,7 +97,7 @@ export function TimelineGroupHeader({
<div
role="rowheader"
aria-colindex={1}
className="sticky left-0 z-[12] flex shrink-0 flex-col justify-center gap-0.5 overflow-hidden px-1.5 text-[11px]"
className="sticky left-0 z-[12] flex shrink-0 items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
style={{
width: columnWidth,
height: TRACK_H,
@@ -107,36 +106,36 @@ export function TimelineGroupHeader({
borderRight: `1px solid ${theme.gutterBorder}`,
}}
>
{/* Line one: what the row IS. The caret rides with the name because it
discloses the name's contents. */}
<div className="flex min-w-0 items-center gap-1.5">
<button
type="button"
tabIndex={-1}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
title={`${isExpanded ? "Hide" : "Show"} tracks`}
// 13px mono, matching the property panel's preset-run caret
// (`hf-fx-preset-run-caret`) — the same disclosure, so the same glyph
// at the same size rather than a smaller one unique to this row.
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isExpanded ? "text-white" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleExpanded();
}}
>
{/* Swapped, not rotated: the panel's caret swaps too, and a rotated
{/* 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
`shrink-0`, so they hold the edge and the name gives way — no second
line needed to keep five controls off the label. */}
<button
type="button"
tabIndex={-1}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
title={`${isExpanded ? "Hide" : "Show"} tracks`}
// 13px mono, matching the property panel's preset-run caret
// (`hf-fx-preset-run-caret`) — the same disclosure, so the same glyph
// at the same size rather than a smaller one unique to this row.
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isExpanded ? "text-white" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleExpanded();
}}
>
{/* Swapped, not rotated: the panel's caret swaps too, and a rotated
▸ sits off-centre in its box because the glyph is not square. */}
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
</button>
<GroupNameButton label={label} memberCount={memberCount} onOpenFxRack={onOpenFxRack} />
</div>
{/* Line two: what you can DO to it. Its own row so the name is not
squeezed to a few characters by five controls sharing 232px. */}
<div className="flex w-full items-center gap-1.5">
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
</button>
<GroupNameButton label={label} memberCount={memberCount} onOpenFxRack={onOpenFxRack} />
{/* `ml-auto` 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.5">
<TimelineFxButton
fxChainRaw={fxChain}
onChainChange={onFxChainChange}
@@ -117,4 +117,30 @@ describe("TimelineGroupRow", () => {
});
expect(laneToggle(automated.host)).toBeDefined();
});
// Same shape as a track header: caret and name on the left, every control in
// one right-anchored group. It was two lines — name, then controls — which is
// what let a stray child overflow the 48px box on the track side.
it("keeps the caret, the name and every control on one line", () => {
const { host } = renderRow({
fxChain: JSON.stringify({
version: 1,
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
}),
automation: JSON.stringify({
version: 1,
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
}),
});
const header = host.querySelector<HTMLElement>('[role="rowheader"]');
// Caret, name, control group — no second line.
expect(header?.children).toHaveLength(3);
const controls = header?.lastElementChild as HTMLElement | null;
expect(controls?.className).toContain("ml-auto");
// Both controls live in that group, so they share the right edge.
expect(controls?.querySelectorAll("button")).toHaveLength(2);
// The member count rides with the name, not out with the controls.
expect(controls?.querySelector('[title="2 tracks"]')).toBeNull();
act(() => undefined);
});
});