diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 9b14d83a6..a0e9300bb 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -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 + * `` 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} /> diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index c632128f8..0d9289ff6 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -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 `` has no visual to hide at all. onToggleHidden={ - selectedElementId && onToggleElementHidden + selectedElementId && onToggleElementHidden && !audioSelection ? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden) : undefined } diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx index 2d467db82..51c43ad26 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx @@ -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"); + }); });