fix(studio): don't offer layout grouping for a selection of audio clips

Selecting two audio clips offered "Group selection", which is the layout
grouper: it wraps the members in a positioned <div> at their bounding
box, rebases each child's left/top against that origin, and adopts the
topmost member's z-index.

None of that means anything for audio. An <audio> clip has no box —
offsetWidth/Height are 0 — so the wrapper came out `width: 0px; height:
0px` with inline left/top written onto elements that are never laid out,
and the composition gained a <div> standing for nothing audible.
Confirmed against `wrapElementsInHtml` directly: it matched and wrapped,
producing exactly that.

Refused in `handleGroupSelection`, which is where the G shortcut also
lands — a hidden button cannot gate a keystroke. The panel withholds the
button as well, so the refusal is not the first the author hears of it.

The message names the alternative rather than only declining: audio's
answer to "these clips belong together" is an <hf-audio-group> bus, which
the timeline's own FX pointer already creates. A mixed selection is
refused too — the wrapper would take the audio in with the rest.

`isAudioDomElement` sits beside `isAudioTimelineElement` and delegates to
it, so the selection layer and the timeline cannot drift into disagreeing
about what counts as audio.

Six tests across both entry points, each mutation-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 02:20:03 -07:00
co-authored by Claude Opus 5
parent c3daa90ba7
commit b801da5a94
5 changed files with 168 additions and 12 deletions
@@ -71,4 +71,58 @@ describe("PropertyPanelEmptyState — flat multi-select", () => {
expect(onClearSelection).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
// A layout group is a positioned wrapper around a bounding box; an <audio>
// clip has none (offsetWidth/Height are 0), so grouping audio produced a 0x0
// div with inline left/top on elements that are never laid out. Withheld
// rather than offered-then-refused.
const audioElements = (tags: string[]) =>
tags.map((tag, i) => ({
id: `el-${i}`,
selector: `#el-${i}`,
label: `El ${i}`,
tagName: tag,
element: document.createElement(tag),
})) as unknown as DomEditSelection[];
it("withholds Group selection when the selection includes audio", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["audio", "audio"])}
onGroupSelection={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
// Hide all still applies — hiding an audio clip is what mutes it.
expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).not.toBeNull();
act(() => root.unmount());
});
it("withholds it for a mixed selection too, since the wrapper would still take audio in", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["div", "audio"])}
onGroupSelection={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
act(() => root.unmount());
});
it("still offers it for a selection of layout elements", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["div", "span"])}
onGroupSelection={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).not.toBeNull();
act(() => root.unmount());
});
});
@@ -1,5 +1,6 @@
import { Eye, Layers } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditingTypes";
import { isAudioDomElement } from "../../utils/timelineInspector";
function FlatEmptyState() {
return (
@@ -67,6 +68,7 @@ function FlatMultiSelectState({
onHideAllSelected?: () => void;
onClearSelection?: () => void;
}) {
const hasAudio = multiSelectedElements.some((el) => isAudioDomElement(el.element));
return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex items-center gap-3 rounded-xl border border-panel-border bg-panel-surface p-3">
@@ -124,20 +126,30 @@ function FlatMultiSelectState({
})}
</div>
<div className="flex gap-2">
<button
type="button"
data-flat-multiselect-group="true"
onClick={onGroupSelection}
className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
>
<Layers size={13} />
Group selection
</button>
{/* A layout group is a positioned wrapper around a bounding box, and an
<audio> clip has none — grouping audio produced a 0x0 div with inline
left/top on elements that are never laid out. `handleGroupSelection`
refuses the same case (it also owns the G shortcut, which no hidden
button can gate); withholding the button is so the refusal is not
the first the author hears of it. */}
{!hasAudio && (
<button
type="button"
data-flat-multiselect-group="true"
onClick={onGroupSelection}
className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
>
<Layers size={13} />
Group selection
</button>
)}
<button
type="button"
data-flat-multiselect-hide-all="true"
onClick={onHideAllSelected}
className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
className={`flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2 ${
hasAudio ? "flex-1 justify-center" : ""
}`}
>
<Eye size={13} />
Hide all