feat(studio): open a group's rack from its row header, and let the rack say what it is

A group is an element carrying `data-fx-chain`, so selecting one IS opening its
rack — but nothing on the row said so, and the FX popover's footer was the only
route in. Clicking the group's name now does it.

Three things had to be true for that click to land somewhere useful:

**The name is a button.** Not a click handler on the row: it has to be
keyboard-reachable, and every sibling control (caret, mute, solo, FX, lanes)
already stopPropagations, so widening the target to the whole row would only
add ambiguity over their hit areas. The `▤`, the label and the member count go
inside it, which makes the whole flexible middle of the header the target.

**The panel opens on the rack.** `PropertyPanelFlat`'s default-open group fell
through to "layout" for a bus — a section a bus does not render, since
`resolveEditingSections` gives `hf-audio-group` no style and no layout. So the
selection landed on a panel with everything collapsed. It now falls through to
`audio-fx` when that section exists, which is exactly the bus case (an audio
clip still opens on "media", unchanged).

**The rack stops calling a group a track.** Its `In`/`Out` lines were hardcoded
to a clip's answer. The design doc's §5 mockup gives both columns:

    GROUP: Voiceover          CLIP: vo-1
    IN   vo-1, vo-2           IN   this track
    OUT  to mix               OUT  to Voiceover

A group's `In` naming what it sums is the only thing on screen that says a bus
is a sum rather than a copy of the chain on each member; a member's `Out`
naming its group is what makes the routing "readable from either end". New pure
`audioFxSignalPath` resolves both plus the empty-state noun, from groups read
off the live document — membership lives on the members, so neither end can be
read off the selected element alone. Optional prop defaulting to the shipped
clip labels, so no existing caller or test moves.

Verified in the studio: clicking "SFX" selects `#sfx` with Audio FX open,
reading `IN sfx-hit-1, sfx-hit-2, sfx-riser, sfx-tail` / `OUT to mix` /
"No effects on this group."; selecting a member reads `IN this track` /
`OUT to SFX`.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, studio suite 4328 green.
This commit is contained in:
Vance Ingalls
2026-08-20 02:16:31 -07:00
parent 94f3e5efa7
commit 17c623e4c2
8 changed files with 178 additions and 19 deletions
@@ -136,7 +136,13 @@ export function PropertyPanelFlat({
? "style"
: sections.media
? "media"
: "layout",
: // An `<hf-audio-group>` has no style, no layout and no media — its
// chain is the only reason to select one. Without this the fallback
// landed on "layout", a section a bus does not render, so opening the
// rack on a group produced a panel with everything collapsed.
sections.audioFx
? "audio-fx"
: "layout",
);
// Tracks which group(s) are actively transitioning this toggle cycle, so
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
import { audioFxSignalPath } from "./audioFxSignalPath";
const group = (over: Partial<HfAudioGroup> = {}): HfAudioGroup => ({
id: "voiceover",
label: "Voiceover",
memberIds: ["vo-1", "vo-2"],
volume: 1,
hidden: false,
...over,
});
describe("audioFxSignalPath", () => {
// The design doc's §5 mockup, both columns.
it("names what a group sums, and sends it to the mix", () => {
expect(audioFxSignalPath("hf-audio-group", "voiceover", [group()])).toEqual({
inLabel: "vo-1, vo-2",
outLabel: "to mix",
subject: "group",
});
});
it("names the group a member feeds, so routing reads from either end", () => {
expect(audioFxSignalPath("audio", "vo-1", [group()])).toEqual({
inLabel: "this track",
outLabel: "to Voiceover",
subject: "track",
});
});
it("leaves an ungrouped clip on the shipped clip labels", () => {
expect(audioFxSignalPath("audio", "music-bed", [group()])).toEqual({
inLabel: "this track",
outLabel: "to mix",
subject: "track",
});
});
// The state an author is in the instant after making a group. It must not
// read as a failure to resolve.
it("says a memberless group holds nothing yet", () => {
expect(
audioFxSignalPath("hf-audio-group", "empty", [group({ id: "empty", memberIds: [] })]),
).toMatchObject({ inLabel: "nothing yet", subject: "group" });
});
});
@@ -0,0 +1,59 @@
/**
* What the rack's `IN` and `OUT` lines say, per selected element.
*
* The rack brackets its chain with the signal path because the ORDER is the
* point (see `propertyPanelFxRackChain`). Those two lines were hardcoded to a
* clip's answer — "in this track", "out to mix" — which is wrong at both ends
* once groups exist, and the design doc's §5 mockup spells out both:
*
* GROUP: Voiceover CLIP: vo-1
* IN vo-1, vo-2 IN this track
* OUT to mix OUT to Voiceover
*
* A group's IN names what it sums, which is the only thing on screen that says
* a bus is a sum rather than a copy of the chain on each member. A member's OUT
* names the group it feeds, "so the routing is readable from either end".
*/
import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
export interface AudioFxSignalPath {
/** After the word "In". */
inLabel: string;
/** After the word "Out". */
outLabel: string;
/** The thing the empty-state sentence is about: "No effects on this …". */
subject: string;
}
/** What a plain, ungrouped clip has always said, and the default everywhere. */
export const CLIP_SIGNAL_PATH: AudioFxSignalPath = {
inLabel: "this track",
outLabel: "to mix",
subject: "track",
};
/**
* `groups` is the resolved set from the composition; `elementId` and `tag` come
* from the selection. Pure so the labels can be asserted without a DOM.
*/
export function audioFxSignalPath(
tag: string | undefined,
elementId: string | undefined,
groups: readonly HfAudioGroup[],
): AudioFxSignalPath {
if (tag === "hf-audio-group") {
const group = groups.find((g) => g.id === elementId);
// A group with no members yet still reads as a group — "nothing yet" is the
// honest answer, and it is also the state the author is in right after
// making one, so it must not look like a bug.
const members = group?.memberIds ?? [];
return {
inLabel: members.length > 0 ? members.join(", ") : "nothing yet",
outLabel: "to mix",
subject: "group",
};
}
const owner = elementId ? groups.find((g) => g.memberIds.includes(elementId)) : undefined;
return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `to ${owner.label}` } : CLIP_SIGNAL_PATH;
}
@@ -7,7 +7,7 @@
* budget, and self-contained enough to test on its own.
*/
import { useState } from "react";
import { useMemo, useState } from "react";
import {
HF_AUDIO_FX_ATTR,
HF_AUDIO_FX_DATA_KEY,
@@ -44,6 +44,8 @@ import { FxSection } from "./propertyPanelFxSection.js";
import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
import { useFxChainObserved } from "./useFxChainObserved.js";
import { useFxCarve } from "./useFxCarve.js";
import { audioFxSignalPath } from "./audioFxSignalPath.js";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
import { useFxLevelling } from "./useFxLevelling.js";
/**
@@ -226,6 +228,18 @@ export function AudioFxGroup({
const [analysing, setAnalysing] = useState(false);
// The rack's In/Out lines. Resolved from the live document because a group's
// membership lives on the members, so neither end of the routing can be read
// off the selected element alone.
const signalPath = useMemo(() => {
const doc = element.element?.ownerDocument;
return audioFxSignalPath(
element.tagName?.toLowerCase(),
element.id ?? undefined,
doc ? resolveAudioGroups(doc) : [],
);
}, [element]);
const { carvedAgainstBy, sourceOptions, setCarve } = useFxCarve(
element,
chain,
@@ -273,6 +287,7 @@ export function AudioFxGroup({
next.nodes.length ? serializeAudioFxChain(next) : null,
)
}
signalPath={signalPath}
onAuditionTransport={auditionTransport}
onChainPreview={(next) =>
// Live writes skip the preview refresh entirely, so dragging a knob no
@@ -1,7 +1,8 @@
/**
* The rack's own signal path: the carve, the Tone EQ modules, and every
* hand-built effect or preset run in between — bracketed by the "In this
* track" / "Out to mix" labels that say the order is the point.
* hand-built effect or preset run in between — bracketed by the `In` / `Out`
* labels that say the order is the point. What those two name depends on what
* is selected; see `audioFxSignalPath`.
*
* Split out of `propertyPanelFxSection.tsx`, which owned this whole chain
* before the file grew past a size where the chain and the add/pick menus
@@ -15,8 +16,11 @@ import { trackEqChanged, trackPresetAmount } from "./audioFxTelemetry.js";
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
import { FxEqModule } from "./propertyPanelFxEqModule.js";
import { FxPresetRun } from "./propertyPanelFxPresetRun.js";
import type { AudioFxSignalPath } from "./audioFxSignalPath.js";
export interface FxRackChainProps {
/** What the `In`/`Out` lines name — a clip's answer differs from a bus's. */
signalPath: AudioFxSignalPath;
chain: HfAudioFxChain;
showCarve: boolean;
carveNodes: HfAudioFxNode[];
@@ -103,6 +107,7 @@ export function FxRackChain({
presetAutomated,
presetAutomateHandler,
presetRemoveAutomationHandler,
signalPath,
}: FxRackChainProps) {
return (
<div className="hf-fx-chain space-y-1">
@@ -111,7 +116,7 @@ export function FxRackChain({
"move up" look cosmetic — it is the most consequential control here. */}
<p className="hf-fx-term flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">In</span>
<span>this track</span>
<span>{signalPath.inLabel}</span>
</p>
{/* Carve leads the rack, which is also where its effects sit in the signal
path — corrective work before anything the author added. Present
@@ -151,7 +156,9 @@ export function FxRackChain({
))}
{handBuiltCount === 0 && eqIds.length === 0 ? (
<p className="hf-fx-empty py-1 text-[11px] text-panel-text-2">
{showCarve ? "No other effects on this track." : "No effects on this track."}
{showCarve
? `No other effects on this ${signalPath.subject}.`
: `No effects on this ${signalPath.subject}.`}
</p>
) : (
runs.map((run) => {
@@ -195,7 +202,7 @@ export function FxRackChain({
)}
<p className="hf-fx-term hf-fx-term-out flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">Out</span>
<span>to mix</span>
<span>{signalPath.outLabel}</span>
</p>
</div>
);
@@ -25,6 +25,7 @@ import { applyAudioFxProfile, getAudioFxProfile } from "@hyperframes/core/audio-
import { audioFxJobNode, type HfAudioFxJob } from "@hyperframes/core/audio-fx-jobs";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
import { FxRackChain } from "./propertyPanelFxRackChain.js";
import { CLIP_SIGNAL_PATH } from "./audioFxSignalPath.js";
import { FxAddMenu } from "./propertyPanelFxAddMenu.js";
import { useFxAudition } from "./useFxAudition.js";
import {
@@ -103,6 +104,7 @@ export function FxSection({
onRemovePresetAutomation,
automatedPresets,
onAuditionTransport,
signalPath,
}: FxSectionProps) {
const presetAutomated = automatedPresets ?? new Set<string>();
// Falls back to the persisting write when no preview handler is supplied, which
@@ -417,6 +419,7 @@ export function FxSection({
onKeyDown={closeMenus}
>
<FxRackChain
signalPath={signalPath ?? CLIP_SIGNAL_PATH}
chain={chain}
showCarve={showCarve}
carveNodes={carveNodes}
@@ -6,8 +6,12 @@
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import type { HfAudioNameKind, HfCarveSettings } from "@hyperframes/core/audio-carve";
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
import type { AudioFxSignalPath } from "./audioFxSignalPath.js";
export interface FxSectionProps {
/** What the rack's `In`/`Out` lines name see `audioFxSignalPath`. Absent
* means an ungrouped clip, which is what those lines said before groups. */
signalPath?: AudioFxSignalPath;
chain: HfAudioFxChain;
/** Targets this track already automates, as `fx.<nodeId>.<param>` strings. */
automatedTargets?: ReadonlySet<string>;
@@ -91,19 +91,37 @@ export function TimelineGroupHeader({
</span>
</button>
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
</span>
<span className="min-w-0 flex-1 truncate font-medium" title={label}>
{label}
</span>
<span
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
aria-hidden="true"
title={`${memberCount} tracks`}
{/* The group's name IS the way into its rack. A group is an element
carrying `data-fx-chain`, so selecting it is what puts the chain in
the property panel but nothing on this row said so, and the FX
popover's footer was the only route to it. A button rather than a
click handler on the row: it has to be reachable by keyboard, and the
sibling controls each stopPropagation already, so widening the target
to the whole row would only add ambiguity over their hit areas. */}
<button
type="button"
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]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onOpenFxRack();
}}
>
{memberCount}
</span>
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
</span>
<span className="min-w-0 flex-1 truncate font-medium">{label}</span>
<span
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
aria-hidden="true"
title={`${memberCount} tracks`}
>
{memberCount}
</span>
</button>
<button
type="button"
tabIndex={-1}