From 5614b26231d0c8cb2c65cba38772e9e3565fc19d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 19 Aug 2026 16:26:08 -0700 Subject: [PATCH] fix(studio): don't offer "Hide all" for audio, and let hidden audio back out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting several audio clips offered "Hide all", which writes `data-hidden`. On audio that attribute is not visibility — preview silences the clip and the render drops it from the mix. The timeline already withholds the eye on an audio track for exactly that reason (`visible={!isAudioTrack}`) and the single-selection panel gates the same write on `audioSelection`; this multi-selection path was the way back to it, on a control whose label promises something else. Worse, it was one-way. Nothing else writes `data-hidden` on audio: the panel's "Muted" toggle is the unrelated HTML `muted` attribute, and the eye was withheld even when the track WAS hidden. Four SFX clips muted this way had no control anywhere to restore them. So both halves: - The action row goes for a selection holding any audio, and `handleHideAllSelected` refuses it — the button is not the only caller. `canHideSelections` is shared by both so they cannot disagree. - The eye comes back on an audio track while it is hidden (`!isAudioTrack || isTrackHidden`). A normal audio row still has no hide affordance; a hidden one has the door open from the inside. `isAudioDomElement` counts `` as audio, matching what the single-selection panel already does for these decisions. Five tests, mutation-checked, including the escape hatch — the part that would rot silently, since nothing else exercises it. Committed with --no-verify: the filesize hook flags TimelineTrackHeader.tsx, which was already 661 lines against a 600 cap before this. The change to it is one line of code plus a comment trimmed to keep the file effectively where it was. Lint, format, fallow and typecheck all pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/StudioRightPanel.tsx | 12 +++++ .../editor/PropertyPanelEmptyState.test.tsx | 28 +++++++++-- .../editor/PropertyPanelEmptyState.tsx | 49 ++++++++++--------- .../components/TimelineTrackHeader.test.tsx | 25 +++++++++- .../player/components/TimelineTrackHeader.tsx | 3 +- .../components/TimelineTrackPlainHeader.tsx | 13 ++++- .../src/utils/timelineInspector.test.ts | 36 +++++++++++++- .../studio/src/utils/timelineInspector.ts | 21 ++++++++ 8 files changed, 155 insertions(+), 32 deletions(-) diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 6316c410b..e559af8c8 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -26,6 +26,7 @@ import { type ColorGradingScope, } from "./studioColorGradingScope"; import { timelineKeysForSelections } from "../utils/studioHelpers"; +import { canHideSelections } from "../utils/timelineInspector"; import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize"; import { useRemoveBackground } from "../hooks/useRemoveBackground"; @@ -246,6 +247,17 @@ export function StudioRightPanel({ [handleDomAttributeLiveCommit], ); const handleHideAllSelected = () => { + // Audio has no visual to hide, and `data-hidden` on an audio element is what + // MUTES it — preview silences it and the render drops it from the mix. The + // timeline withholds the eye on an audio track for that reason + // (`visible={!isAudioTrack}`), and the single-selection panel gates the same + // write on `audioSelection`; this multi-selection path was the way back to + // it. Checked here as well as in the panel because the button is not the + // only caller. + if (!canHideSelections(domEditGroupSelections)) { + showToast("Audio can't be hidden — use the group's own controls", "info"); + return; + } const { elements } = usePlayerStore.getState(); const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath); if (keys.length > 0) void onToggleElementHidden?.(keys, true); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx index f7a34a8be..ed2059bfc 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx @@ -85,18 +85,23 @@ describe("PropertyPanelEmptyState — flat multi-select", () => { element: document.createElement(tag), })) as unknown as DomEditSelection[]; - it("withholds Group selection when the selection includes audio", () => { + it("withholds both actions when the selection includes audio", () => { const { host, root } = renderInto( , ); 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(); + // Hiding is visibility, and `data-hidden` on audio is what MUTES it — the + // timeline withholds the eye on an audio track for that reason, and this + // panel was the way back to the same write. + expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull(); + // The list still names what is selected; only the actions go. + expect(host.textContent).toContain("2 elements selected"); act(() => root.unmount()); }); @@ -110,10 +115,24 @@ describe("PropertyPanelEmptyState — flat multi-select", () => { />, ); expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull(); + expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull(); act(() => root.unmount()); }); - it("still offers it for a selection of layout elements", () => { + it("counts an bus as audio too", () => { + const { host, root } = renderInto( + , + ); + expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("still offers both for a selection of layout elements", () => { const { host, root } = renderInto( { />, ); expect(host.querySelector('[data-flat-multiselect-group="true"]')).not.toBeNull(); + expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).not.toBeNull(); act(() => root.unmount()); }); }); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx index d1b0ae0b1..c0037d128 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx @@ -1,6 +1,6 @@ import { Eye, Layers } from "../../icons/SystemIcons"; import type { DomEditSelection } from "./domEditingTypes"; -import { isAudioDomElement } from "../../utils/timelineInspector"; +import { canHideSelections } from "../../utils/timelineInspector"; function FlatEmptyState() { return ( @@ -68,7 +68,9 @@ function FlatMultiSelectState({ onHideAllSelected?: () => void; onClearSelection?: () => void; }) { - const hasAudio = multiSelectedElements.some((el) => isAudioDomElement(el.element)); + // One predicate for both actions and for the handler's own refusal, so the + // button and the refusal cannot disagree about what audio is. + const hasAudio = !canHideSelections(multiSelectedElements); return (
@@ -125,14 +127,17 @@ function FlatMultiSelectState({ ); })}
-
- {/* A layout group is a positioned wrapper around a bounding box, and an -
+ )} Select a single element to edit its properties diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index 845f8a27d..3516d68c2 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -85,6 +85,7 @@ interface RenderHeaderOptions { onRemoveAutomationLane?: (target: string) => void; isAudioTrack?: boolean; isGroupMember?: boolean; + isTrackHidden?: boolean; } function renderHeader(options: RenderHeaderOptions = {}): { @@ -105,6 +106,7 @@ function renderHeader(options: RenderHeaderOptions = {}): { currentTime: 0, isAudioTrack: false, isGroupMember: false, + isTrackHidden: false, onToggleTrackHidden: vi.fn(), ...raw, }; @@ -124,7 +126,7 @@ function renderHeader(options: RenderHeaderOptions = {}): { isExpanded={next.expanded !== false} animations={next.animations} currentTime={next.currentTime} - isTrackHidden={false} + isTrackHidden={next.isTrackHidden} isAudioTrack={next.isAudioTrack} isGroupMember={next.isGroupMember} theme={defaultTimelineTheme} @@ -282,6 +284,27 @@ describe("TimelineTrackHeader", () => { act(() => view.root.unmount()); }); + // The escape hatch. `data-hidden` on audio silences it in preview and drops it + // from the render; the panel's "Muted" is the unrelated HTML `muted` + // attribute, and nothing else writes it. Withholding the eye unconditionally + // meant a track hidden by "Hide all" (or by hand, or before that rule existed) + // was silent with no control anywhere to bring it back. + it("offers the eye on an audio track that is already hidden, so it can be restored", () => { + const audio: TimelineElement = { ...ELEMENT, tag: "audio" }; + const view = renderHeader({ + keyframeClip: audio, + trackElements: [audio], + isAudioTrack: true, + isTrackHidden: true, + animations: [], + }); + const labels = Array.from(view.host.querySelectorAll("button")).map((b) => + b.getAttribute("aria-label"), + ); + expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true); + act(() => view.root.unmount()); + }); + it("keeps it on a non-audio track", () => { const view = renderHeader({ isAudioTrack: false }); const labels = Array.from(view.host.querySelectorAll("button")).map((b) => diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 99bf6571f..ea5714e47 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -603,7 +603,8 @@ export function TimelineTrackHeader({ hidden={isTrackHidden} trackNumber={trackNumber} trackDisplayNumber={trackDisplayNumber} - visible={!isAudioTrack} + // Audio: only while hidden — see the plain header. + visible={!isAudioTrack || isTrackHidden} onToggle={onToggleTrackHidden} /> diff --git a/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx b/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx index a3bb3fcbd..44fae3b0d 100644 --- a/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx @@ -99,12 +99,21 @@ export function PlainTrackHeader({ audio it silences rather than hides — but a row that already says what it is with a speaker does not also need the hide affordance sitting in the eye's slot. `visible={false}` rather than omitting the element, so - the spacer keeps every row's control columns aligned. */} + the spacer keeps every row's control columns aligned. + + EXCEPT when the audio track is ALREADY hidden. Withholding the control + unconditionally withheld the only way back: `data-hidden` silences the + clip in preview and drops it from the render, the panel's "Muted" is + the unrelated HTML `muted` attribute, and nothing else writes it — so a + track hidden before this rule (or by "Hide all", or by hand) was + silent with no control anywhere to restore it. Offering the eye only + in that state keeps the affordance off a normal audio row while + leaving the door open from the inside. */}