@hyperframes/studio 0.8.116 → 0.8.118

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 (58) hide show
  1. package/dist/assets/{hyperframes-player-mGYvPVpw.js → hyperframes-player-CnpIuNRl.js} +1 -1
  2. package/dist/assets/{index-DYNaXPVf.js → index-B4SbAADe.js} +196 -194
  3. package/dist/assets/{index-Dr3hNpvS.js → index-BZ23-h1_.js} +1 -1
  4. package/dist/assets/{index-BWHt0-ir.js → index-FeRU6G3G.js} +1 -1
  5. package/dist/index.d.ts +29 -19
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2366 -2305
  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/DomEditOverlay.tsx +1 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +149 -25
  15. package/src/components/editor/PropertyPanel.tsx +1 -12
  16. package/src/components/editor/PropertyPanelEmptyState.test.tsx +2 -3
  17. package/src/components/editor/PropertyPanelEmptyState.tsx +3 -5
  18. package/src/components/editor/PropertyPanelFlat.tsx +9 -22
  19. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +7 -4
  20. package/src/components/editor/PropertyPanelFlatHeader.tsx +5 -3
  21. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  22. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  23. package/src/components/editor/manualOffsetDrag.ts +3 -3
  24. package/src/components/editor/plainTranslate.ts +5 -3
  25. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  26. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  27. package/src/components/editor/rotationDraft.ts +2 -1
  28. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  29. package/src/components/editor/usePanelHiddenToggle.ts +44 -0
  30. package/src/contexts/DomEditContext.tsx +3 -0
  31. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  32. package/src/hooks/gsapScriptCommitTypes.ts +1 -1
  33. package/src/hooks/plainRotation.ts +5 -1
  34. package/src/hooks/timelineTrackVisibility.ts +7 -17
  35. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  36. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  37. package/src/hooks/useDomGeometryCommit.ts +6 -9
  38. package/src/hooks/useFreezeFrame.test.ts +39 -3
  39. package/src/hooks/useFreezeFrame.ts +4 -1
  40. package/src/hooks/useGsapAwareEditing.test.tsx +25 -0
  41. package/src/hooks/useGsapAwareEditing.ts +12 -8
  42. package/src/hooks/useGsapAwareGroupMove.ts +2 -2
  43. package/src/hooks/useGsapScriptCommits.test.tsx +69 -1
  44. package/src/hooks/useGsapScriptCommits.ts +9 -7
  45. package/src/hooks/usePersistentEditHistory.test.ts +25 -2
  46. package/src/hooks/usePersistentEditHistory.ts +3 -9
  47. package/src/player/components/Timeline.test.ts +48 -0
  48. package/src/player/components/TimelineTrackHeader.test.tsx +14 -22
  49. package/src/player/components/TimelineTrackHeader.tsx +4 -2
  50. package/src/player/components/TimelineTrackPlainHeader.test.tsx +17 -31
  51. package/src/player/components/TimelineTrackPlainHeader.tsx +9 -32
  52. package/src/player/components/hiddenToggle.tsx +20 -0
  53. package/src/utils/gsapSoftReload.test.ts +51 -2
  54. package/src/utils/gsapSoftReload.ts +11 -21
  55. package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
  56. package/src/utils/softReloadTargets.ts +4 -3
  57. package/src/utils/studioFileHistory.ts +1 -0
  58. 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.116",
3
+ "version": "0.8.118",
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/parsers": "0.8.116",
64
- "@hyperframes/player": "0.8.116",
65
- "@hyperframes/core": "0.8.116",
66
- "@hyperframes/sdk": "0.8.116",
67
- "@hyperframes/studio-server": "0.8.116",
68
- "@hyperframes/shader-transitions": "0.8.116"
63
+ "@hyperframes/core": "0.8.118",
64
+ "@hyperframes/sdk": "0.8.118",
65
+ "@hyperframes/studio-server": "0.8.118",
66
+ "@hyperframes/parsers": "0.8.118",
67
+ "@hyperframes/player": "0.8.118",
68
+ "@hyperframes/shader-transitions": "0.8.118"
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.116"
84
+ "@hyperframes/producer": "0.8.118"
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
@@ -94,6 +94,7 @@ export interface DomEditOverlayProps {
94
94
  next: { width: number; height: number },
95
95
  offset?: { x: number; y: number },
96
96
  restore?: () => void,
97
+ route?: { plainTranslate: boolean },
97
98
  ) => Promise<unknown> | void;
98
99
  onRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<unknown> | void;
99
100
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
@@ -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
@@ -275,6 +275,7 @@ export type UseDomEditOverlayGesturesOptions = {
275
275
  n: { width: number; height: number },
276
276
  offset?: { x: number; y: number },
277
277
  restore?: () => void,
278
+ route?: { plainTranslate: boolean },
278
279
  ) => Promise<unknown> | void
279
280
  >;
280
281
  onRotationCommitRef: RefObject<
@@ -32,7 +32,7 @@ import {
32
32
  type UseDomEditOverlayGesturesOptions,
33
33
  } from "./domEditOverlayGestures";
34
34
  import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
35
- import { gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
35
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
36
36
  import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
37
37
  import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
38
38
 
@@ -234,7 +234,7 @@ export function startGesture(
234
234
  try {
235
235
  // Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
236
236
  // move, never asks GSAP: reading a property makes it bake the CSS into its transform.
237
- const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
237
+ const plain = !!pathOffsetMember?.plainTranslate || editsPlainCss(sel.element, "rotate");
238
238
  const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
239
239
  const rotation = { angle: readRotationBase(sel.element, plain) };
240
240
  const overlayBounds = overlayEl?.getBoundingClientRect();
@@ -11,7 +11,7 @@ import {
11
11
  type StudioPathOffsetSnapshot,
12
12
  } from "./manualEdits";
13
13
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
- import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
14
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
15
15
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16
16
  import type { StudioEditRevert } from "../../utils/studioPendingEdits";
17
17
 
@@ -318,8 +318,8 @@ export function createManualOffsetDragMember(input: {
318
318
  rect: ManualOffsetDragRect;
319
319
  gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
320
320
  }): ManualOffsetDragMemberResult {
321
- const gsapOwns = input.gesture === "resize" ? gsapWritesBox : gsapWritesPosition;
322
- const plainTranslate = !!input.gesture && !gsapOwns(input.element);
321
+ const plainTranslate =
322
+ !!input.gesture && editsPlainCss(input.element, input.gesture === "resize" ? "resize" : "move");
323
323
  // The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
324
324
  const initialOffset = plainTranslate
325
325
  ? readTranslatePx(input.element)
@@ -1,5 +1,5 @@
1
1
  import { roundTo3 } from "../../utils/rounding";
2
- import { gsapWritesPosition, gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
2
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
3
3
  import { readCssRotation } from "../../hooks/draggedGsapPosition";
4
4
  import { readStudioPathOffset, readStudioRotation, styleUsesStudioOffset } from "./manualEditsDom";
5
5
  import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
@@ -142,10 +142,12 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void {
142
142
  }
143
143
 
144
144
  /** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
145
- export function readMoveOffset(el: HTMLElement, plainTranslate = !gsapWritesPosition(el)): Point {
145
+ export function readMoveOffset(el: HTMLElement, plainTranslate = editsPlainCss(el, "move")): Point {
146
146
  return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el);
147
147
  }
148
148
 
149
149
  export function readShownRotation(el: HTMLElement): { angle: number } {
150
- return gsapWritesRotation(el) ? readStudioRotation(el) : { angle: roundTo3(readCssRotation(el)) };
150
+ return editsPlainCss(el, "rotate")
151
+ ? { angle: roundTo3(readCssRotation(el)) }
152
+ : readStudioRotation(el);
151
153
  }
@@ -9,7 +9,7 @@ import {
9
9
  playbackStartAttributeForElement,
10
10
  readPlaybackStartAttributes,
11
11
  } from "../../player/lib/timelineElementHelpers";
12
- import { GSAP_TRANSFORM_KEYS, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
12
+ import { editsPlainCss, GSAP_TRANSFORM_KEYS } from "../../hooks/gsapRuntimeKeyframes";
13
13
 
14
14
  export type {
15
15
  BackgroundRemovalProgress,
@@ -496,7 +496,7 @@ export function readGsapRuntimeValuesForPanel(
496
496
  const el = findPreviewNode(iframe.contentDocument, element);
497
497
  if (!el) return null;
498
498
  const propKeys = collectPanelPropKeys(gsapAnimations);
499
- const readsTransform = gsapWritesPosition(el);
499
+ const readsTransform = !editsPlainCss(el, "move");
500
500
  const result: Record<string, number> = {};
501
501
  for (const prop of propKeys) {
502
502
  if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue;
@@ -1,4 +1,4 @@
1
- import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
1
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
2
2
  import type { DomEditSelection } from "./domEditingTypes";
3
3
  import { readStudioBoxSize } from "./manualEdits";
4
4
  import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
@@ -75,7 +75,7 @@ export function createTransformCommitHandlers({
75
75
  )
76
76
  )
77
77
  return;
78
- const plainTranslate = !gsapWritesPosition(element.element);
78
+ const plainTranslate = editsPlainCss(element.element, "move");
79
79
  const current = readMoveOffset(element.element, plainTranslate);
80
80
  if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
81
81
  return void showToast?.(UNREADABLE_TRANSLATE);
@@ -51,7 +51,8 @@ export interface CssRotationTarget {
51
51
  inline: boolean;
52
52
  }
53
53
 
54
- export type RotationCommit = { angle: number; plain?: CssRotationTarget };
54
+ export type PressChoseGsap = null;
55
+ export type RotationCommit = { angle: number; plain?: CssRotationTarget | PressChoseGsap };
55
56
 
56
57
  const TRANSLATE = /^translate(?:3d|X|Y|Z)?\(/i;
57
58
  const OWN_TURN = /^rotate\(\s*-?[\d.]+(?:e[+-]?\d+)?deg\s*\)$/i;
@@ -442,7 +442,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
442
442
  }
443
443
  // Hold the final angle while the commit lands.
444
444
  applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
445
- const commit = g.plainRotation ? { ...finalRotation, plain: g.plainRotation } : finalRotation;
445
+ const commit = { ...finalRotation, plain: g.plainRotation };
446
446
  void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
447
447
  .then((result) => trackPreviewEditResult("rotate", "drag", result))
448
448
  .catch((error) => {
@@ -536,7 +536,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
536
536
  if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
537
537
  };
538
538
  void Promise.resolve(
539
- opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
539
+ opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member),
540
540
  )
541
541
  .then((result) => trackPreviewEditResult("resize", "drag", result))
542
542
  .catch((error) => {