@hyperframes/studio 0.8.117 → 0.8.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/assets/{hyperframes-player-5fcgSstJ.js → hyperframes-player-7Icl2uVN.js} +1 -1
  2. package/dist/assets/{index-XJSawwYe.js → index-7YcUqkoh.js} +1 -1
  3. package/dist/assets/{index-C5qKdpgl.js → index-DOO2cYhl.js} +189 -189
  4. package/dist/assets/{index-tg2LXUeu.js → index-Dc8FEIEU.js} +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2289 -2241
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/StudioRightPanels.tsx +3 -6
  11. package/src/components/TimelineToolbar.test.tsx +14 -0
  12. package/src/components/TimelineToolbar.tsx +4 -1
  13. package/src/components/editor/PropertyPanel.test.tsx +149 -25
  14. package/src/components/editor/PropertyPanel.tsx +1 -12
  15. package/src/components/editor/PropertyPanelEmptyState.test.tsx +2 -3
  16. package/src/components/editor/PropertyPanelEmptyState.tsx +3 -5
  17. package/src/components/editor/PropertyPanelFlat.tsx +9 -22
  18. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +7 -4
  19. package/src/components/editor/PropertyPanelFlatHeader.tsx +5 -3
  20. package/src/components/editor/usePanelHiddenToggle.ts +44 -0
  21. package/src/contexts/DomEditContext.tsx +3 -0
  22. package/src/hooks/timelineTrackVisibility.ts +7 -17
  23. package/src/player/components/Timeline.test.ts +48 -0
  24. package/src/player/components/TimelineTrackHeader.test.tsx +14 -22
  25. package/src/player/components/TimelineTrackHeader.tsx +4 -2
  26. package/src/player/components/TimelineTrackPlainHeader.test.tsx +17 -31
  27. package/src/player/components/TimelineTrackPlainHeader.tsx +9 -32
  28. package/src/player/components/hiddenToggle.tsx +20 -0
  29. package/src/utils/gsapSoftReload.test.ts +12 -2
  30. package/src/utils/gsapSoftReload.ts +4 -10
  31. package/src/utils/timelineInspector.ts +10 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.117",
3
+ "version": "0.8.119",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.117",
64
- "@hyperframes/parsers": "0.8.117",
65
- "@hyperframes/sdk": "0.8.117",
66
- "@hyperframes/shader-transitions": "0.8.117",
67
- "@hyperframes/studio-server": "0.8.117",
68
- "@hyperframes/player": "0.8.117"
63
+ "@hyperframes/parsers": "0.8.119",
64
+ "@hyperframes/shader-transitions": "0.8.119",
65
+ "@hyperframes/studio-server": "0.8.119",
66
+ "@hyperframes/sdk": "0.8.119",
67
+ "@hyperframes/player": "0.8.119",
68
+ "@hyperframes/core": "0.8.119"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.117"
84
+ "@hyperframes/producer": "0.8.119"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -170,12 +170,9 @@ export const StudioRightPanels = memo(function StudioRightPanels({
170
170
  );
171
171
  const handleHideAllSelected = useCallback(() => {
172
172
  // Audio has no visual to hide, and `data-hidden` on an audio element is what
173
- // MUTES it — preview silences it and the render drops it from the mix. The
174
- // timeline withholds the eye on an audio track for that reason
175
- // (`visible={!isAudioTrack}`), and the single-selection panel gates the same
176
- // write on `audioSelection`; this multi-selection path was the way back to
177
- // it. Checked here as well as in the panel because the button is not the
178
- // only caller.
173
+ // mutes it — preview silences it and the render drops it from the mix; the
174
+ // timeline offers that write as a mute. Checked here as well as in the panel
175
+ // because the button is not the only caller.
179
176
  if (!canHideSelections(domEditGroupSelections)) {
180
177
  showToast("Audio can't be hidden — use the group's own controls", "info");
181
178
  return;
@@ -422,3 +422,17 @@ describe("TimelineToolbar Linked Selection", () => {
422
422
  act(() => useLinkedClipPreferences.getState().setSyncIndicatorsVisible(true));
423
423
  });
424
424
  });
425
+
426
+ describe("TimelineToolbar — host right actions", () => {
427
+ it("draws the host's controls immediately before the thumbnails toggle", () => {
428
+ const { host, root } = renderToolbar(undefined, {
429
+ rightActions: <button type="button">Script</button>,
430
+ });
431
+ const buttons = [...host.querySelectorAll("button")];
432
+ const script = buttons.findIndex((b) => b.textContent === "Script");
433
+ const thumbs = buttons.findIndex((b) => b.getAttribute("aria-label")?.includes("thumbnails"));
434
+ expect(script).toBeGreaterThan(-1);
435
+ expect(thumbs).toBe(script + 1);
436
+ act(() => root.unmount());
437
+ });
438
+ });
@@ -1,4 +1,4 @@
1
- import { memo, useEffect, useRef } from "react";
1
+ import { memo, useEffect, useRef, type ReactNode } from "react";
2
2
  import {
3
3
  ArrowsOutLineHorizontal,
4
4
  Image,
@@ -50,6 +50,7 @@ export interface TimelineToolbarProps {
50
50
  showAddBeat?: boolean;
51
51
  /** Hides Add keyframe and auto-record, and turns off auto-record and the K shortcut with them. */
52
52
  showKeyframes?: boolean;
53
+ rightActions?: ReactNode;
53
54
  }
54
55
 
55
56
  interface KeyframeToggleState {
@@ -149,6 +150,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
149
150
  showSelectAroundPlayhead = true,
150
151
  showAddBeat = true,
151
152
  showKeyframes = true,
153
+ rightActions,
152
154
  }: TimelineToolbarProps) {
153
155
  const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
154
156
  const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
@@ -460,6 +462,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
460
462
  })()}
461
463
  </div>
462
464
  <div className="flex items-center gap-0.5">
465
+ {rightActions}
463
466
  <Tooltip
464
467
  label={
465
468
  thumbnailsVisible
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { PropertyPanelProps } from "./propertyPanelHelpers";
7
+ import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
7
8
 
8
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
10
 
@@ -235,7 +236,13 @@ async function renderPanel(
235
236
  flatEnabled: boolean,
236
237
  elementOverride: NonNullable<PropertyPanelProps["element"]> = baseElement(),
237
238
  propsOverride: Partial<PropertyPanelProps> = {},
238
- currentTime?: number,
239
+ options: {
240
+ currentTime?: number;
241
+ selectedElementId?: string;
242
+ timelineEdit?: TimelineEditCallbacks;
243
+ refreshSelection?: (selection: unknown) => void;
244
+ selectionRef?: { current: unknown };
245
+ } = {},
239
246
  ) {
240
247
  vi.resetModules();
241
248
  vi.doMock("./manualEditingAvailability", async () => {
@@ -244,32 +251,57 @@ async function renderPanel(
244
251
  );
245
252
  return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled };
246
253
  });
247
- // Seed the playhead on the SAME store instance PropertyPanel.tsx will read via
248
- // usePlayerStore (module-fresh since the resetModules() above) — must happen
249
- // before PropertyPanel is imported/rendered so its initial render sees it.
250
- if (currentTime !== undefined) {
251
- const { usePlayerStore } = await import("../../player/store/playerStore");
252
- usePlayerStore.getState().setCurrentTime(currentTime);
254
+ vi.doMock("../../contexts/DomEditContext", async () => {
255
+ const actual = await vi.importActual<typeof import("../../contexts/DomEditContext")>(
256
+ "../../contexts/DomEditContext",
257
+ );
258
+ const { refreshSelection: refresh, selectionRef } = options;
259
+ return refresh && selectionRef
260
+ ? {
261
+ ...actual,
262
+ useDomEditActionsContextOptional: () => ({
263
+ refreshDomEditSelectionFromPreview: refresh,
264
+ domEditSelectionRef: selectionRef,
265
+ }),
266
+ }
267
+ : actual;
268
+ });
269
+ // Seed the playhead and selection on the same store instance PropertyPanel.tsx
270
+ // reads (module-fresh after the resetModules() above), before it is imported so
271
+ // its first render sees them.
272
+ const { usePlayerStore } = await import("../../player/store/playerStore");
273
+ if (options.currentTime !== undefined) {
274
+ usePlayerStore.getState().setCurrentTime(options.currentTime);
253
275
  }
276
+ if (options.selectedElementId)
277
+ usePlayerStore.setState({ selectedElementId: options.selectedElementId });
254
278
  const { PropertyPanel } = await import("./PropertyPanel");
279
+ const { TimelineEditProvider } = await import("../../contexts/TimelineEditContext");
255
280
  const host = document.createElement("div");
256
281
  document.body.append(host);
257
282
  const root = createRoot(host);
258
- // Only the props the render path touches are supplied; the rest are unused at
259
- // mount (handlers fire on interaction), so cast a minimal object to the full
260
- // props shape rather than stubbing all ~15 required fields.
261
- const props = {
262
- element: elementOverride,
263
- assets: [],
264
- onSetStyle: vi.fn(),
265
- onSetText: vi.fn(),
266
- onSetAttributeLive: vi.fn(),
267
- ...propsOverride,
268
- } as unknown as PropertyPanelProps;
269
- act(() => {
270
- root.render(<PropertyPanel {...props} />);
271
- });
272
- return { host, root };
283
+ const render = (element: NonNullable<PropertyPanelProps["element"]>) => {
284
+ // Only the props the render path touches are supplied; the rest are unused at
285
+ // mount (handlers fire on interaction), so cast a minimal object to the full
286
+ // props shape rather than stubbing all ~15 required fields.
287
+ const props = {
288
+ element,
289
+ assets: [],
290
+ onSetStyle: vi.fn(),
291
+ onSetText: vi.fn(),
292
+ onSetAttributeLive: vi.fn(),
293
+ ...propsOverride,
294
+ } as unknown as PropertyPanelProps;
295
+ act(() => {
296
+ root.render(
297
+ <TimelineEditProvider value={options.timelineEdit ?? {}}>
298
+ <PropertyPanel {...props} />
299
+ </TimelineEditProvider>,
300
+ );
301
+ });
302
+ };
303
+ render(elementOverride);
304
+ return { host, root, render };
273
305
  }
274
306
 
275
307
  // renderPanel resetModules()+dynamic-imports PropertyPanel (needed for a fresh
@@ -561,7 +593,7 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe
561
593
  true,
562
594
  inferredMotionElement(),
563
595
  { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
564
- 2,
596
+ { currentTime: 2 },
565
597
  );
566
598
  openFlatGroup(host, "Layout");
567
599
  const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
@@ -606,7 +638,7 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f
606
638
  true,
607
639
  inferredMotionElement(),
608
640
  { gsapAnimations: [INFERRED_TIMING_ANIMATION] },
609
- 3.5,
641
+ { currentTime: 3.5 },
610
642
  );
611
643
  openFlatGroup(host, "Layout");
612
644
  const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
@@ -643,7 +675,7 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f
643
675
  true,
644
676
  inferredMotionElement(),
645
677
  { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
646
- 3.5,
678
+ { currentTime: 3.5 },
647
679
  );
648
680
  openFlatGroup(host, "Layout");
649
681
  const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
@@ -1038,6 +1070,98 @@ describe("PropertyPanel — Motion is for things that move", () => {
1038
1070
  RENDER_TIMEOUT_MS,
1039
1071
  );
1040
1072
 
1073
+ it(
1074
+ "offers a mute on an audible audio clip and toggles it through the timeline",
1075
+ async () => {
1076
+ const onToggleElementHidden = vi.fn();
1077
+ const { host, root } = await renderPanel(
1078
+ true,
1079
+ audioClipElement(),
1080
+ { onToggleElementHidden },
1081
+ { selectedElementId: "vo-1" },
1082
+ );
1083
+ act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
1084
+ expect(onToggleElementHidden).toHaveBeenCalledWith("vo-1", true);
1085
+ act(() => root.unmount());
1086
+ },
1087
+ RENDER_TIMEOUT_MS,
1088
+ );
1089
+
1090
+ // A bus has no timeline row, so the panel is its only mute; it must go both ways.
1091
+ it(
1092
+ "mutes, unmutes and mutes an audio group from the panel header",
1093
+ async () => {
1094
+ const bus = (hidden: boolean) => {
1095
+ const element = audioBusElement();
1096
+ return {
1097
+ ...element,
1098
+ dataAttributes: { ...element.dataAttributes, ...(hidden ? { hidden: "" } : {}) },
1099
+ };
1100
+ };
1101
+ let hidden = true;
1102
+ const onSetAudioGroupAttributeQuiet = vi.fn(
1103
+ async (_id: string, _attr: string, value: string | null, _label: string) => {
1104
+ hidden = value !== null;
1105
+ },
1106
+ );
1107
+ // The panel re-reads its selection after the write; nothing else re-renders it here.
1108
+ const { host, root, render } = await renderPanel(
1109
+ true,
1110
+ bus(true),
1111
+ {},
1112
+ {
1113
+ timelineEdit: { onSetAudioGroupAttributeQuiet },
1114
+ selectionRef: { current: bus(true) },
1115
+ refreshSelection: () => render(bus(hidden)),
1116
+ },
1117
+ );
1118
+ const press = async (label: string) => {
1119
+ const button = host.querySelector<HTMLElement>(`button[aria-label="${label}"]`);
1120
+ if (!button) throw new Error(`Expected a "${label}" button`);
1121
+ await act(async () => button.click());
1122
+ };
1123
+ await press("Unmute element");
1124
+ await press("Mute element");
1125
+ await press("Unmute element");
1126
+ expect(onSetAudioGroupAttributeQuiet.mock.calls).toEqual([
1127
+ ["voiceover", "data-hidden", null, "Unmute element"],
1128
+ ["voiceover", "data-hidden", "", "Mute element"],
1129
+ ["voiceover", "data-hidden", null, "Unmute element"],
1130
+ ]);
1131
+ act(() => root.unmount());
1132
+ },
1133
+ RENDER_TIMEOUT_MS,
1134
+ );
1135
+
1136
+ it(
1137
+ "re-reads the current selection, not the muted bus, once the write lands",
1138
+ async () => {
1139
+ let land = () => {};
1140
+ const onSetAudioGroupAttributeQuiet = vi.fn(
1141
+ () => new Promise<void>((resolve) => (land = resolve)),
1142
+ );
1143
+ const selectionRef: { current: unknown } = { current: audioBusElement() };
1144
+ const refreshSelection = vi.fn();
1145
+ const { host, root } = await renderPanel(
1146
+ true,
1147
+ audioBusElement(),
1148
+ {},
1149
+ {
1150
+ timelineEdit: { onSetAudioGroupAttributeQuiet },
1151
+ selectionRef,
1152
+ refreshSelection,
1153
+ },
1154
+ );
1155
+ act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
1156
+ const next = audioClipElement();
1157
+ selectionRef.current = next;
1158
+ await act(async () => land());
1159
+ expect(refreshSelection.mock.calls).toEqual([[next]]);
1160
+ act(() => root.unmount());
1161
+ },
1162
+ RENDER_TIMEOUT_MS,
1163
+ );
1164
+
1041
1165
  it(
1042
1166
  "offers a bus neither — it has no clip range to edit",
1043
1167
  async () => {
@@ -119,18 +119,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
119
119
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
120
120
  const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
121
121
  const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
122
- /**
123
- * An audio element gets no hide control here.
124
- *
125
- * On an audio track "hidden" and "muted" are not similar operations, they are
126
- * the SAME operation with two names (groups doc §2.1) — which is why the
127
- * timeline's eye became the mute rather than growing a sibling. A second copy
128
- * in the panel, still called "Hide element", is precisely the thing that step
129
- * removed: "Two controls that silence a track, sitting next to each other,
130
- * differing only in a distinction the author cannot see." An
131
- * `<hf-audio-group>` has no visual to hide at all, and its mute lives on its
132
- * own row.
133
- */
122
+ // Audio gets no visibility toggle in this classic panel; the timeline and the flat panel offer it as a mute.
134
123
  const audioSelection = isAudioDomElement(element?.element);
135
124
  // Live during playback, the store's when paused — see the hook. Shared with the
136
125
  // audio FX panel, which follows the playhead for the same reason: a value the
@@ -96,9 +96,8 @@ describe("PropertyPanelEmptyState — flat multi-select", () => {
96
96
  />,
97
97
  );
98
98
  expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
99
- // Hiding is visibility, and `data-hidden` on audio is what MUTES it — the
100
- // timeline withholds the eye on an audio track for that reason, and this
101
- // panel was the way back to the same write.
99
+ // Hiding is visibility, and `data-hidden` on audio is what mutes it; the
100
+ // timeline owns that write as a mute.
102
101
  expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
103
102
  // The list still names what is selected; only the actions go.
104
103
  expect(host.textContent).toContain("2 elements selected");
@@ -133,11 +133,9 @@ function FlatMultiSelectState({
133
133
  an empty frame. Grouping is the LAYOUT grouper — a positioned wrapper
134
134
  around a bounding box, and an <audio> clip has none (grouping two
135
135
  produced a 0x0 div with inline left/top on elements that are never
136
- laid out). Hiding is visibility, which for audio doubles as mute; the
137
- timeline already withholds the eye on an audio track
138
- (`visible={!isAudioTrack}`) and this panel was the way back to the
139
- same write. Both handlers refuse it too — they own keyboard paths no
140
- hidden button can gate. */}
136
+ laid out). Hiding is visibility, which for audio is mute, and the
137
+ timeline owns that as a mute control. Both handlers refuse it too —
138
+ they own keyboard paths no hidden button can gate. */}
141
139
  {!hasAudio && (
142
140
  <div className="flex gap-2">
143
141
  <button
@@ -9,6 +9,7 @@ import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits
9
9
  import { formatPxMetricValue } from "./propertyPanelHelpers";
10
10
  import { audioFxSummary } from "./audioFxSummary";
11
11
  import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
12
+ import { usePanelHiddenToggle } from "./usePanelHiddenToggle";
12
13
  import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
13
14
  import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
14
15
  import { closedGroupHeader, isSelectionHidden } from "./propertyPanelFlatClosedGroup";
@@ -206,6 +207,12 @@ export function PropertyPanelFlat({
206
207
  * selected the clip and then appeared to do nothing.
207
208
  */
208
209
  const hiddenNow = isSelectionHidden(selectedElementHidden, element);
210
+ const toggleHidden = usePanelHiddenToggle({
211
+ element,
212
+ hidden: hiddenNow,
213
+ selectedElementId,
214
+ onToggleElementHidden,
215
+ });
209
216
 
210
217
  const reveal = useAudioFxRevealSection({
211
218
  elementId: element?.id,
@@ -531,28 +538,8 @@ export function PropertyPanelFlat({
531
538
  meta={`${sourceLabel} · ${element.tagName}`}
532
539
  elementKind={elementKind}
533
540
  hidden={hiddenNow}
534
- // Audio gets no hide control here. On an audio track "hidden" and
535
- // "muted" are not similar operations, they are the SAME operation
536
- // with two names (groups doc §2.1) — which is why the timeline's eye
537
- // BECAME the mute rather than growing a sibling. A second copy in
538
- // the panel, still called "Hide element", is exactly what that step
539
- // set out to remove: "Two controls that silence a track, sitting
540
- // next to each other, differing only in a distinction the author
541
- // cannot see." An `<hf-audio-group>` has no visual to hide at all.
542
- //
543
- // EXCEPT while it is already hidden — the same door-from-the-inside
544
- // the timeline's eye keeps for an audio track
545
- // (`TimelineTrackPlainHeader`). Withholding it unconditionally
546
- // withheld the only way back: a `data-hidden` group is silent in
547
- // preview (the bus's mute gain) and absent from the render (every
548
- // member dropped), and the group header carries no visibility
549
- // control of its own now that mute and solo are gone. Only
550
- // hand-editing the HTML brought the audio back.
551
- onToggleHidden={
552
- selectedElementId && onToggleElementHidden && (!audioSelection || hiddenNow)
553
- ? () => void onToggleElementHidden(selectedElementId, !hiddenNow)
554
- : undefined
555
- }
541
+ asMute={audioSelection}
542
+ onToggleHidden={toggleHidden}
556
543
  copied={clipboardCopied}
557
544
  onCopy={onCopyElementInfo}
558
545
  onClear={onClearSelection}
@@ -77,10 +77,6 @@ describe("PropertyPanelFlatHeader", () => {
77
77
  expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull();
78
78
  });
79
79
 
80
- // The panel's hide control is withheld for audio by its caller — on an audio
81
- // track "hidden" and "muted" are the same operation with two names (groups
82
- // doc §2.1), and the timeline already carries it, correctly labelled. This
83
- // pins the header's half of that contract: no handler, no button.
84
80
  it("renders no visibility control when its caller withholds the handler", () => {
85
81
  const { host } = renderHeader({ onToggleHidden: undefined });
86
82
  const labels = Array.from(host.querySelectorAll("button")).map((b) =>
@@ -90,6 +86,13 @@ describe("PropertyPanelFlatHeader", () => {
90
86
  expect(labels).not.toContain("Show element");
91
87
  });
92
88
 
89
+ it("names a mute on an audio selection", () => {
90
+ const { host } = renderHeader({ onToggleHidden: vi.fn(), asMute: true });
91
+ expect(host.querySelector('button[aria-label="Mute element"]')).not.toBeNull();
92
+ const { host: muted } = renderHeader({ onToggleHidden: vi.fn(), asMute: true, hidden: true });
93
+ expect(muted.querySelector('button[aria-label="Unmute element"]')).not.toBeNull();
94
+ });
95
+
93
96
  it("renders it when the handler is supplied", () => {
94
97
  const { host } = renderHeader({ onToggleHidden: vi.fn() });
95
98
  const labels = Array.from(host.querySelectorAll("button")).map((b) =>
@@ -1,4 +1,4 @@
1
- import { Eye, EyeSlash } from "@phosphor-icons/react";
1
+ import { HiddenToggleIcon, hiddenToggleVerb } from "../../player/components/hiddenToggle";
2
2
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
3
3
  import { ClipboardList, Film, Square, Type, X } from "../../icons/SystemIcons";
4
4
 
@@ -14,6 +14,7 @@ export function PropertyPanelFlatHeader({
14
14
  meta,
15
15
  elementKind,
16
16
  hidden,
17
+ asMute = false,
17
18
  onToggleHidden,
18
19
  copied,
19
20
  onCopy,
@@ -25,6 +26,7 @@ export function PropertyPanelFlatHeader({
25
26
  meta: string;
26
27
  elementKind: "text" | "media" | "other";
27
28
  hidden: boolean;
29
+ asMute?: boolean;
28
30
  onToggleHidden?: () => void;
29
31
  copied: boolean;
30
32
  onCopy: () => void;
@@ -34,7 +36,7 @@ export function PropertyPanelFlatHeader({
34
36
  }) {
35
37
  const track = useTrackDesignInput();
36
38
  const Icon = ICON_BY_KIND[elementKind];
37
- const visibilityLabel = hidden ? "Show element" : "Hide element";
39
+ const visibilityLabel = `${hiddenToggleVerb(asMute, hidden)} element`;
38
40
 
39
41
  return (
40
42
  <div className="flex items-center gap-2.5 border-b border-panel-hairline px-4 py-3">
@@ -81,7 +83,7 @@ export function PropertyPanelFlatHeader({
81
83
  onToggleHidden();
82
84
  }}
83
85
  >
84
- {hidden ? <EyeSlash size={13} weight="bold" /> : <Eye size={13} weight="bold" />}
86
+ <HiddenToggleIcon asMute={asMute} hidden={hidden} size={13} />
85
87
  </button>
86
88
  )}
87
89
  <button
@@ -0,0 +1,44 @@
1
+ import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
2
+ import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
3
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
4
+ import { hiddenToggleVerb } from "../../player/components/hiddenToggle";
5
+ import type { DomEditSelection } from "./domEditingTypes";
6
+
7
+ /** The panel header's hide toggle, or undefined when this selection has no writer for it. */
8
+ export function usePanelHiddenToggle({
9
+ element,
10
+ hidden,
11
+ selectedElementId,
12
+ onToggleElementHidden,
13
+ }: {
14
+ element: Pick<DomEditSelection, "id" | "tagName">;
15
+ hidden: boolean;
16
+ selectedElementId: string | null | undefined;
17
+ onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
18
+ }): (() => void) | undefined {
19
+ const { onSetAudioGroupAttributeQuiet } = useTimelineEditContextOptional();
20
+ const domEditActions = useDomEditActionsContextOptional();
21
+ // A bus has no timeline row, so it mutes through the group's own writer.
22
+ const audioGroupId = element.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ? element.id : null;
23
+ const write = audioGroupId
24
+ ? onSetAudioGroupAttributeQuiet &&
25
+ (() =>
26
+ onSetAudioGroupAttributeQuiet(
27
+ audioGroupId,
28
+ "data-hidden",
29
+ hidden ? null : "",
30
+ `${hiddenToggleVerb(true, hidden)} element`,
31
+ ))
32
+ : selectedElementId && onToggleElementHidden
33
+ ? () => onToggleElementHidden(selectedElementId, !hidden)
34
+ : undefined;
35
+ // The label reads the selection snapshot, so re-read whatever is selected once the write lands.
36
+ return (
37
+ write &&
38
+ (() =>
39
+ void Promise.resolve(write()).then(() => {
40
+ const selection = domEditActions?.domEditSelectionRef.current;
41
+ if (selection) void domEditActions?.refreshDomEditSelectionFromPreview(selection);
42
+ }))
43
+ );
44
+ }
@@ -77,6 +77,7 @@ const SAVES_AN_EDIT = {
77
77
  handleUnroll: true,
78
78
  invalidateGsapCache: false,
79
79
  previewIframeRef: false,
80
+ domEditSelectionRef: false,
80
81
  commitMutation: true,
81
82
  applyMarqueeSelection: false,
82
83
  handleUpdateKeyframeEase: true,
@@ -329,6 +330,7 @@ export function DomEditProvider({
329
330
  handleUnroll,
330
331
  invalidateGsapCache,
331
332
  previewIframeRef,
333
+ domEditSelectionRef,
332
334
  commitMutation: stableCommitMutation,
333
335
  applyMarqueeSelection,
334
336
  handleUpdateKeyframeEase,
@@ -406,6 +408,7 @@ export function DomEditProvider({
406
408
  handleUnroll,
407
409
  invalidateGsapCache,
408
410
  previewIframeRef,
411
+ domEditSelectionRef,
409
412
  stableCommitMutation,
410
413
  applyMarqueeSelection,
411
414
  handleUpdateKeyframeEase,
@@ -8,7 +8,8 @@ import {
8
8
  trackDisplaySuffix,
9
9
  } from "../player/components/timelineTrackDisplay";
10
10
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
11
- import { isAudioTimelineElement } from "../utils/timelineInspector";
11
+ import { isAudioOnlyTrack } from "../utils/timelineInspector";
12
+ import { hiddenToggleVerb } from "../player/components/hiddenToggle";
12
13
  import type { PatchOperation } from "../utils/sourcePatcher";
13
14
  import {
14
15
  findTimelineElementInIframe,
@@ -206,14 +207,8 @@ export async function toggleTimelineTrackHidden({
206
207
  displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
207
208
  );
208
209
  const trackElements = timelineElements.filter((element) => element.track === track);
209
- const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
210
- const label = isAudioOnlyTrack
211
- ? hidden
212
- ? `Mute track${suffix}`
213
- : `Unmute track${suffix}`
214
- : hidden
215
- ? `Hide track${suffix}`
216
- : `Show track${suffix}`;
210
+ const hiddenBefore = !hidden;
211
+ const label = `${hiddenToggleVerb(isAudioOnlyTrack(trackElements), hiddenBefore)} track${suffix}`;
217
212
  return setElementsHidden({
218
213
  projectId,
219
214
  activeCompPath,
@@ -240,19 +235,14 @@ export async function toggleTimelineElementHidden({
240
235
  }: ToggleTimelineElementHiddenInput): Promise<string[]> {
241
236
  const keys = new Set(typeof elementKey === "string" ? [elementKey] : elementKey);
242
237
  const elements = timelineElements.filter((item) => keys.has(item.key ?? item.id));
238
+ const hiddenBefore = !hidden;
239
+ const verb = hiddenToggleVerb(isAudioOnlyTrack(elements), hiddenBefore);
243
240
  return setElementsHidden({
244
241
  projectId,
245
242
  activeCompPath,
246
243
  elements,
247
244
  hidden,
248
- label:
249
- elements.length > 1
250
- ? hidden
251
- ? `Hide ${elements.length} elements`
252
- : `Show ${elements.length} elements`
253
- : hidden
254
- ? "Hide element"
255
- : "Show element",
245
+ label: elements.length > 1 ? `${verb} ${elements.length} elements` : `${verb} element`,
256
246
  previewIframe,
257
247
  writeProjectFile,
258
248
  recordEdit,