fix(studio): drop the hide control from audio elements in the property panel

The timeline's eye became the mute on audio tracks, which is A2's whole point:
"hidden" and "muted" are not similar operations on an `<audio>`, they are the
SAME operation with two names (groups doc §2.1). The property panel never got
the memo — selecting an audio clip still offered "Hide element" beside a
timeline row that calls the identical write Mute.

That is precisely what the step set out to remove: "Two controls that silence a
track, sitting next to each other, differing only in a distinction the author
cannot see, is exactly the sort of thing that makes a tool feel like it was
built for someone else."

Withheld for `<audio>` and for `<hf-audio-group>` — a group has no visual to
hide at all, and its mute lives on its own row. Every other element keeps it
unchanged.

Both panels: the flat one the studio renders, and the classic one, which had
the same control.

Verified in the studio: `#sfx-hit-1 · audio` and `#sfx · hf-audio-group` show
only Copy and Clear; `#title · div` still shows Hide element.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, studio suite 4347 green.
This commit is contained in:
Vance Ingalls
2026-08-20 02:17:51 -07:00
parent 001c992ca9
commit 1b671ae227
3 changed files with 50 additions and 3 deletions
@@ -36,6 +36,7 @@ import { type PropertyPanelProps } from "./propertyPanelHelpers";
import { GestureRecordPanelButton } from "./GestureRecordControl";
import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
// Re-export helpers that external consumers import from this module
export {
@@ -119,6 +120,20 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
/**
* An audio element gets no hide control here.
*
* On an audio track "hidden" and "muted" are not similar operations, they are
* the SAME operation with two names (groups doc §2.1) — which is why the
* timeline's eye became the mute rather than growing a sibling. A second copy
* in the panel, still called "Hide element", is precisely the thing that step
* removed: "Two controls that silence a track, sitting next to each other,
* differing only in a distinction the author cannot see." An
* `<hf-audio-group>` has no visual to hide at all, and its mute lives on its
* own row.
*/
const selectedTag = element?.tagName?.toLowerCase();
const audioSelection = selectedTag === "audio" || selectedTag === HF_AUDIO_GROUP_TAG;
// Live during playback, the store's when paused — see the hook. Shared with the
// audio FX panel, which follows the playhead for the same reason: a value the
// timeline drives has to be shown moving, not frozen at what the attribute says.
@@ -301,7 +316,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
selectedElementId={selectedElementId}
selectedElementHidden={selectedElementHidden}
visibilityLabel={visibilityToggleLabel}
onToggleHidden={onToggleElementHidden}
onToggleHidden={audioSelection ? undefined : onToggleElementHidden}
/>
</div>
</div>
@@ -7,7 +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 { HF_AUDIO_GROUP_TAG, resolveAudioGroups } from "@hyperframes/core/audio-groups";
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
@@ -278,6 +278,9 @@ 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({
@@ -509,8 +512,16 @@ export function PropertyPanelFlat({
meta={`${sourceLabel} · ${element.tagName}`}
elementKind={elementKind}
hidden={selectedElementHidden}
// Audio gets no hide control here. On an audio track "hidden" and
// "muted" are not similar operations, they are the SAME operation
// with two names (groups doc §2.1) — which is why the timeline's eye
// BECAME the mute rather than growing a sibling. A second copy in
// the panel, still called "Hide element", is exactly what that step
// set out to remove: "Two controls that silence a track, sitting
// next to each other, differing only in a distinction the author
// cannot see." An `<hf-audio-group>` has no visual to hide at all.
onToggleHidden={
selectedElementId && onToggleElementHidden
selectedElementId && onToggleElementHidden && !audioSelection
? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden)
: undefined
}
@@ -76,4 +76,25 @@ describe("PropertyPanelFlatHeader", () => {
const { host: withUngroup } = renderHeader({ showUngroup: true, onUngroup: vi.fn() });
expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull();
});
// The panel's hide control is withheld for audio by its caller — on an audio
// track "hidden" and "muted" are the same operation with two names (groups
// doc §2.1), and the timeline already carries it, correctly labelled. This
// pins the header's half of that contract: no handler, no button.
it("renders no visibility control when its caller withholds the handler", () => {
const { host } = renderHeader({ onToggleHidden: undefined });
const labels = Array.from(host.querySelectorAll("button")).map((b) =>
b.getAttribute("aria-label"),
);
expect(labels).not.toContain("Hide element");
expect(labels).not.toContain("Show element");
});
it("renders it when the handler is supplied", () => {
const { host } = renderHeader({ onToggleHidden: vi.fn() });
const labels = Array.from(host.querySelectorAll("button")).map((b) =>
b.getAttribute("aria-label"),
);
expect(labels).toContain("Hide element");
});
});