feat(studio): the three pieces of §5 copy the routing shipped without

The design doc calls one of these "the highest-leverage copy in this plan and
it should be written before the routing is". The routing shipped; the copy did
not.

**Naming a group.** Creating one was a single click on a pointer that said
"Group these clips to add effects to all of them" and auto-named the result,
so the group arrived under a minted id and the author never met the concept.
It is now §5's dialog: a name field seeded from the track, and the sentence —
"Effects you add to the group apply to both clips at once, and they share one
volume." That is a submix bus explained without the word, which is the whole
point. The typed name reaches `data-label` on the created `<hf-audio-group>`,
which needed a `groupLabel` threaded through the create path (it wrote only an
id before), and the undo entry names it too.

**The video limit, said out loud.** Groups are audio-only in v1 (§1.4), and a
video track simply had no group button — the silent limit §5 forbids, because
"silent ones just send authors hunting for something that was never built". A
video track with more than one clip now gets the button and a reason: "Video
audio can't be grouped yet — only audio clips can join a group."

**Two curves that multiply.** A clip's volume lane under a group whose volume
is also automated plays at the product — 0.42 × 0.80 = 0.34 — and nothing said
so. The clip's lane now reads "Voiceover is also fading this." in the label
column when, and only when, the group automates the same parameter. Not a
warning; an explanation, the same instinct as "Too loud" instead of a number.

Not built, deliberately: §1.7's peak meter and resettable peak-hold. The
runbook step that implements §1.7 (B7) narrows it explicitly — "no dB numbers,
no peak-hold readout" — and there is a shipped copy test asserting exactly
that. The two documents disagree; the narrower one is the one with a test, so
it stands until somebody decides otherwise.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, studio suite 4342 green.
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:55 -07:00
parent e9baba66cf
commit 1f4bccd646
7 changed files with 357 additions and 42 deletions
@@ -144,14 +144,80 @@ describe("TimelineFxButton", () => {
expect(presets.size).toBe(1);
});
it("group-pointer variant offers Group instead of a popover", () => {
// The design doc calls the sentence this dialog carries "the highest-leverage
// copy in this plan": it is the concept of a submix bus delivered without the
// word, to an author who has never met one. The old pointer auto-named the
// group on one click and never mentioned the shared volume.
it("group-pointer variant names the group and explains what one is", () => {
const onGroupClips = vi.fn();
const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={onGroupClips} />);
const host = mount(
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
);
act(() => byTextButton(host, "FX")?.click());
const groupButton = document.body.querySelectorAll("button");
const group = Array.from(groupButton).find((b) => b.textContent === "Group");
expect(group).toBeDefined();
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain(
"Effects you add to the group apply to both clips at once, and they share one volume.",
);
const group = Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent === "Group",
);
act(() => group?.click());
expect(onGroupClips).toHaveBeenCalledTimes(1);
expect(onGroupClips).toHaveBeenCalledWith("Voiceover");
});
// Three or more must not read "both".
it("counts the clips in the explanation", () => {
mount(<TimelineFxButton variant="group-pointer" clipCount={3} onGroupClips={vi.fn()} />);
act(() => byTextButton(document.body as HTMLElement, "FX")?.click());
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("all 3 clips at once");
});
// §1.4 keeps groups audio-only in v1, and §5 is explicit that a deliberate
// limit must be stated: "silent ones just send authors hunting for something
// that was never built."
it("states the video limit instead of offering a name field", () => {
const onGroupClips = vi.fn();
const host = mount(
<TimelineFxButton
variant="group-pointer"
clipCount={2}
refusal="Video audio can't be grouped yet — only audio clips can join a group."
onGroupClips={onGroupClips}
/>,
);
act(() => byTextButton(host, "FX")?.click());
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Video audio can't be grouped yet");
expect(document.querySelector('input[aria-label="Group name"]')).toBeNull();
expect(
Array.from(document.body.querySelectorAll("button")).some((b) => b.textContent === "Group"),
).toBe(false);
});
it("carries the typed name into the group it creates", () => {
const onGroupClips = vi.fn();
const host = mount(
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
);
act(() => byTextButton(host, "FX")?.click());
const input = document.querySelector<HTMLInputElement>('input[aria-label="Group name"]');
expect(input).not.toBeNull();
// React tracks the input's value on the node, so assigning `.value`
// directly is swallowed — the native setter is what makes onChange fire.
const setValue = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
act(() => {
if (input && setValue) {
setValue.call(input, "SFX");
input.dispatchEvent(new Event("input", { bubbles: true }));
}
});
const group = Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent === "Group",
);
act(() => group?.click());
expect(onGroupClips).toHaveBeenCalledWith("SFX");
});
});