fix(studio,core): audio has timing, not motion — and a bus has neither

Selecting an audio bus showed a Motion section offering tween editors. A
bus has no transform, opacity or box, so every effect on that list moves
nothing; the same is true of an `<audio>` clip.

The panel's gate was "are the GSAP handlers wired", and `App.tsx` always
wires them, so it was true for every selection. It now also asks what was
selected.

Audio keeps its timing — an `<audio>` clip is placed on the timeline like
anything else — but the section is called Timing there and summarises its
span instead of an effect count, because "Motion: 0 effects" on a sound
is a category error. A bus loses timing too: it has no `data-start` and
no duration, and its automation clock is composition time, so Start /
Duration / End would be editing nothing.

Gated on the TAG, not on `sections.animation`: a `div` with no tweens yet
must still offer "+ Add", and keying the rename on `animationCount > 0`
renamed the section for exactly that div — which the existing panel test
caught. Keying it on `showMotionEffects` renamed it for an `img`, which
wires no GSAP handlers. Both halves belong to the tag.

`affordances.ts` carries the same two rules for anything reading the
section list rather than the flat panel. The label pair is
`motionSectionLabel`, in the module that owns the section it names, which
is also what keeps `PropertyPanelFlat.tsx` under the 600-line ceiling.
This commit is contained in:
Vance Ingalls
2026-08-23 02:06:58 -07:00
parent df7ba162e5
commit 553ea25931
5 changed files with 167 additions and 9 deletions
@@ -177,6 +177,39 @@ function sixGroupElement() {
};
}
/** An `<audio>` clip: placed on the timeline, but nothing a tween could move. */
function audioClipElement() {
return {
...baseElement(),
id: "vo-1",
selector: "#vo-1",
label: "Vo 1",
tagName: "audio",
textFields: [],
dataAttributes: { start: "1", duration: "3" },
};
}
/**
* A mixer bus: no clip range at all, and no box either.
*
* Carries a `data-start` on purpose. A real bus has none — its automation clock
* is composition time — but the timing gate has to refuse the TAG rather than
* merely fall out of a missing attribute, or something writing one would put
* Start/Duration back on a thing that has no range.
*/
function audioBusElement() {
return {
...baseElement(),
id: "voiceover",
selector: "#voiceover",
label: "Voiceover",
tagName: "hf-audio-group",
textFields: [],
dataAttributes: { start: "0", duration: "8" },
};
}
const INFERRED_TIMING_ANIMATION = {
id: "a1",
targetSelector: "#inferred-anim",
@@ -951,3 +984,39 @@ describe("PropertyPanel — flat group entrance animation scoping (fix round)",
RENDER_TIMEOUT_MS,
);
});
describe("PropertyPanel — Motion is for things that move", () => {
it(
"calls the section Timing on an audio clip, and offers no tween editor",
async () => {
const { host, root } = await renderPanel(true, audioClipElement());
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((el) => el.textContent ?? "");
// The clip's placement survives — it is still a clip on a track.
expect(titles.some((t) => t.includes("Timing"))).toBe(true);
// "Motion" named the tween editor, which an <audio> element has no
// transform, opacity or box for. Showing it was the panel gating on
// handler presence rather than on the element.
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"offers a bus neither — it has no clip range to edit",
async () => {
const { host, root } = await renderPanel(true, audioBusElement());
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((el) => el.textContent ?? "");
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
expect(titles.some((t) => t.includes("Timing"))).toBe(false);
// It is still a mixer bus: the reason to select one at all.
expect(titles.some((t) => t.includes("Audio FX"))).toBe(true);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
@@ -15,7 +15,7 @@ import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
import { FlatMotionSection, motionSectionLabel } from "./propertyPanelFlatMotionSection";
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
import { useVolumeAutomation } from "./useVolumeAutomation";
import { useAudioFxRevealSection } from "./useAudioFxRevealSection";
@@ -282,7 +282,16 @@ export function PropertyPanelFlat({
onSetAllKeyframeEases,
}
: null;
const showMotionEffects = gsapEffectHandlers !== null;
const selectedTag = element.tagName?.toLowerCase();
const audioSelection = selectedTag === "audio" || selectedTag === HF_AUDIO_GROUP_TAG;
// Handlers being wired is necessary but not sufficient: App.tsx always passes
// them, so this alone showed the tween editor for every selection — including
// an `<audio>` clip and an `<hf-audio-group>` bus, neither of which has a
// transform, an opacity or a box for a tween to move. Gated on the TAG, not on
// `sections.animation` (`animationCount > 0`): a div with no tweens yet must
// still offer "+ Add", so "has none" and "can have none" are different
// questions and only the second one belongs here.
const showMotionEffects = gsapEffectHandlers !== null && !audioSelection;
const showMotionGroup = showMotionTiming || showMotionEffects;
const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive);
@@ -298,9 +307,6 @@ export function PropertyPanelFlat({
return resolveAudioGroups(doc).find((g) => g.memberIds.includes(id))?.label;
})();
const selectedTag = element.tagName?.toLowerCase();
const audioSelection = selectedTag === "audio" || selectedTag === HF_AUDIO_GROUP_TAG;
const groups: FlatGroupDescriptor[] = [];
if (isTextEditable) {
groups.push({
@@ -388,8 +394,12 @@ export function PropertyPanelFlat({
if (showMotionGroup) {
groups.push({
id: "motion",
title: "Motion",
summary: `${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`,
...motionSectionLabel({
timingOnly: audioSelection,
start: elStart,
duration: elDuration,
effectCount: gsapAnimations.length,
}),
content: (
<FlatMotionSection
element={element}
@@ -172,3 +172,34 @@ export function FlatMotionSection({
</div>
);
}
/**
* What the Motion section is called, and what its collapsed line says.
*
* "Motion" names the tween editor. On audio the section is Start/Duration/End
* and nothing else, so the label would promise what it no longer offers — and
* "Motion: 0 effects" on a sound is a category error, hence the span instead of
* a count.
*
* Keyed on the TAG by its caller, not on whether the effects half is showing:
* that half also disappears when a host simply has not wired the GSAP handlers,
* and a div in that state is still a thing that moves — renaming its section
* would be describing the host's wiring rather than the element.
*/
export function motionSectionLabel(args: {
timingOnly: boolean;
start: number;
duration: number;
effectCount: number;
}): { title: string; summary: string } {
if (args.timingOnly) {
return {
title: "Timing",
summary: `${formatTimingValue(args.start)} ${formatTimingValue(args.start + args.duration)}`,
};
}
return {
title: "Motion",
summary: `${args.effectCount} effect${args.effectCount === 1 ? "" : "s"}`,
};
}