fix(studio): hide a group's lane toggle when it automates nothing

A track header already gates its own automation toggle on having
something to disclose (`disclosable`). A group's did not, so every group
offered a wave button that opened an empty row — which is how the author
learns the group has no automation, one click too late.

Gated on the same count the badge already used. Nothing becomes
unreachable: automation appears on a group by being written, from the
rack or a keyframe, not by opening this.

The suite had nothing covering the toggle's presence, so the test is new
and was mutation-checked — forcing the condition true fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:40:13 -07:00
co-authored by Claude Opus 5
parent ff55adfcb6
commit 624166f380
2 changed files with 52 additions and 22 deletions
@@ -141,27 +141,33 @@ export function TimelineGroupHeader({
auditionSpans={auditionSpans}
onOpenRack={onOpenFxRack}
/>
<button
type="button"
tabIndex={-1}
aria-expanded={isLaneOpen}
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
// Anchored right, matching every other header's lane toggle.
className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleLanes();
}}
>
<span aria-hidden="true"></span>
{laneCount > 0 && (
{/* No lanes, no control: an author who opens it meets an empty row and
learns nothing. A track header already gates its own `∿` this way
(`disclosable`); the group's was the one that still offered a
disclosure over nothing. Automation appears by being written — from
the rack or a keyframe — not by opening this, so nothing is
unreachable while it is hidden. */}
{laneCount > 0 && (
<button
type="button"
tabIndex={-1}
aria-expanded={isLaneOpen}
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
// Anchored right, matching every other header's lane toggle.
className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleLanes();
}}
>
<span aria-hidden="true"></span>
<span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
)}
</button>
</button>
)}
</div>
</div>
);
@@ -36,7 +36,7 @@ const GROUP: TimelineTrackGroupInfo = {
hidden: false,
};
function renderRow() {
function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
const onSetAudioGroupAttributeQuiet = vi.fn();
const onSetElementAttributeQuiet = vi.fn();
const host = document.createElement("div");
@@ -47,7 +47,7 @@ function renderRow() {
<TimelineGroupRow
index={0}
rowKey={0}
group={GROUP}
group={{ ...GROUP, ...overrides }}
logicalRow={{ id: "g", level: 1, kind: "track" } as never}
top={0}
height={48}
@@ -93,4 +93,28 @@ describe("TimelineGroupRow", () => {
// The members are the point: not one write reaches them.
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
});
// A disclosure over nothing tells the author their group has no automation
// only AFTER they open an empty row. Track headers already gate their own
// toggle on having something to disclose; the group's did not.
it("hides the lane toggle until the group actually automates something", () => {
const laneToggle = (host: HTMLElement) =>
Array.from(host.querySelectorAll("button")).find((b) =>
/lanes$/.test(b.getAttribute("aria-label") ?? ""),
);
expect(laneToggle(renderRow().host)).toBeUndefined();
const automated = 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 }] }],
}),
});
expect(laneToggle(automated.host)).toBeDefined();
});
});