fix(studio): match the rendered designs' remaining copy

Third pass against the HeyGenVerse design page, reading the mockup markup
rather than the markdown's ASCII.

**"Holds vo-1 and vo-2", not a comma list.** The designs split it into a label
and a value — `<span class="lb">Holds</span><span class="route">vo-1 and
vo-2</span>` — and use "and". A comma list reads as data; this line is a
sentence about what the group holds. Three or more keeps the commas and ends
with "and".

**The preset shelf shows its effect count.** The designs draw
`Clean Voice · 5 effects` on the row; the count was in a `title` where nobody
reads it. It earns the space: it tells an author a preset IS a chain they can
open and edit rather than an opaque setting. Kept `.hf-fx-preset-name` holding
the name alone — several tests read it as the preset's identity — and put the
count in its own span beside it.

**A member's rack says where it goes.** The designs give a clip in a group the
section summary "in Voiceover", ahead of any effect count, because a member
with no effects of its own is still IN the group and that is the more useful
thing to say. It answers "where does this go?" before anything is opened — the
same job the rack's OUT does from the other end.

Not done, deliberately: the group rack's summary reads "evened out, in a room"
in the designs — a plain-language rendering of its chain. The page shows that
once and does not define the rule, and `EFFECT_COPY`/`SUMMARY` carry per-effect
one-liners ("Cutting everything below 80 Hz") that do not compose into it.
Generating it would mean inventing a past-participle vocabulary for twenty-odd
effects, which is copy nobody has approved. Left on the effect count and
flagged.

Also not done: the `BUS` badge the mockups draw on two group rows. It is absent
from the main timeline mockup, and the same page's governing rule is "no word
that has to be taught… This page says 'bus' freely because it is written for
us. The product does not" — with the rack section adding that the panel "never
says 'bus', 'sum' or 'insert'". Read as figure annotation. Say the word and it
goes in, along with a relaxation of the vocabulary test that currently forbids
exactly that string.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, studio suite 4345 green.
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:59 -07:00
parent 19f1adcdff
commit f565cf85ee
6 changed files with 61 additions and 10 deletions
@@ -7,6 +7,7 @@ import { isTextEditableSelection } from "./domEditing";
import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
import { formatPxMetricValue } from "./propertyPanelHelpers";
import { audioFxSummary } from "./audioFxSummary";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
@@ -266,6 +267,17 @@ export function PropertyPanelFlat({
const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive);
// The group this clip belongs to, if any — the Audio FX summary reads
// "in Voiceover" for a member (see `audioFxSummary`). Resolved from the live
// document because membership lives on the members, so the owning group's
// LABEL is not on the selected element.
const audioGroupLabel = ((): string | undefined => {
const doc = element.element?.ownerDocument;
const id = element.id;
if (!doc || !id) return undefined;
return resolveAudioGroups(doc).find((g) => g.memberIds.includes(id))?.label;
})();
const groups: FlatGroupDescriptor[] = [];
if (isTextEditable) {
groups.push({
@@ -441,7 +453,7 @@ export function PropertyPanelFlat({
groups.push({
id: "audio-fx",
title: "Audio FX",
summary: audioFxSummary(element),
summary: audioFxSummary(element, audioGroupLabel),
content: (
<AudioFxGroup
element={element}
@@ -8,6 +8,14 @@ const el = (dataAttributes: Record<string, string>): DomEditSelection =>
const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
describe("audioFxSummary", () => {
// The designs give a member's rack the summary "in Voiceover" — it answers
// "where does this go?" before anything is opened, the same job the rack's
// OUT does from the other end, and it outranks the effect count because a
// member with no effects of its own is still in the group.
it("names the group a clip belongs to, ahead of any effect count", () => {
expect(audioFxSummary(el({}), "Voiceover")).toBe("in Voiceover");
});
it("counts a carve as one module, not as the filters behind it", () => {
// Six bands and a level stage reading "7 effects" is the misreading the
// grouping exists to prevent.
@@ -10,7 +10,13 @@
import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx";
import type { DomEditSelection } from "./domEditingTypes";
export function audioFxSummary(element: DomEditSelection): string {
export function audioFxSummary(element: DomEditSelection, groupLabel?: string): string {
// A clip inside a group reads "in Voiceover" — the designs use this line to
// answer "where does this go?" before the author opens anything, which is
// the same job the rack's OUT does from the other end. It outranks the effect
// count: a member with no effects of its own is still IN the group, and that
// is the more useful thing to say about it.
if (groupLabel) return `in ${groupLabel}`;
const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
const carveAttr = element.dataAttributes?.["fx-carve"];
let handBuilt = 0;
@@ -133,8 +133,18 @@ export function FxPresetMenu({
<span className="hf-fx-preset-problem block truncate text-[10px]">
{PRESET_PROBLEM[preset.id] ?? preset.description}
</span>
<span className="hf-fx-preset-name block truncate font-mono text-[9px] text-panel-text-2">
{preset.label}
{/* "Clean Voice · 5 effects" — the count is on the row in the
designs, not hidden in a tooltip. It is doing real work there:
it tells the author a preset IS a chain they can open and edit,
rather than an opaque setting they cannot follow. The count is
its own span so `.hf-fx-preset-name` stays the NAME — several
tests read it as the preset's identity. */}
<span className="block truncate font-mono text-[9px] text-panel-text-2">
<span className="hf-fx-preset-name">{preset.label}</span>
<span className="hf-fx-preset-count">
{" · "}
{preset.nodes.length} effect{preset.nodes.length === 1 ? "" : "s"}
</span>
</span>
{/* Hovering a preset plays it, and playing is otherwise invisible:
the panel looks identical whether the audition is sounding or
@@ -63,14 +63,21 @@ function setSliderValue(input: HTMLInputElement, value: string) {
}
describe("TimelineGroupBusStrip", () => {
it('renders "Holds …" from the member labels, comma-joined', () => {
// "vo-1 and vo-2", the designs' own phrasing. A comma list reads as data;
// this line is a sentence about what the group holds.
it('renders "Holds …" from the member labels, joined as a sentence', () => {
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
expect(container.textContent).toContain("Holds vo-1, vo-2");
expect(container.textContent).toContain("Holdsvo-1 and vo-2");
});
it("keeps the serial comma out of a two-name list but uses it beyond that", () => {
renderStrip({ memberLabels: ["vo-1", "vo-2", "vo-3"] });
expect(container.textContent).toContain("vo-1, vo-2 and vo-3");
});
it("falls back to a neutral line when a group has no members yet", () => {
renderStrip({ memberLabels: [] });
expect(container.textContent).toContain("Holds nothing yet");
expect(container.textContent).toContain("Holdsnothing yet");
});
it("live-writes on every drag tick, but only commits once on release", () => {
@@ -64,8 +64,13 @@ export function TimelineGroupBusStrip({
const shownVolume = dragValue ?? volume;
const level = Math.min(1, reading?.level ?? 0);
const holdsText =
memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet";
// "vo-1 and vo-2", the designs' own phrasing — a comma list reads as data,
// and this line is a sentence about what the group is holding.
const holds =
memberLabels.length > 1
? `${memberLabels.slice(0, -1).join(", ")} and ${memberLabels[memberLabels.length - 1]}`
: (memberLabels[0] ?? "nothing yet");
const holdsText = `Holds ${holds}`;
return (
<div
@@ -110,8 +115,11 @@ export function TimelineGroupBusStrip({
/>
</div>
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]"> Too loud</span>}
{/* Label and value, as the designs split them: "Holds" is chrome, the
member list is the answer. */}
<span className="shrink-0 text-white/45">Holds</span>
<span className="min-w-0 flex-1 truncate" title={holdsText}>
{holdsText}
{holds}
</span>
</div>
);