@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
@@ -74,6 +74,7 @@ function mountResizeHandler(
74
74
  size: { width: number; height: number },
75
75
  offset?: { x: number; y: number },
76
76
  restore?: () => void,
77
+ route?: { plainTranslate: boolean },
77
78
  ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>)
78
79
  | null = null;
79
80
  let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
@@ -234,6 +235,21 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
234
235
  });
235
236
  });
236
237
 
238
+ describe("useGsapAwareEditing keeps the box route a resize chose at press", () => {
239
+ it.each([
240
+ ["the CSS route, on an element GSAP has since taken over", true],
241
+ ["the GSAP route, on an element that now looks GSAP-free", false],
242
+ ])("%s", async (_, plainTranslate) => {
243
+ mocks.resize.mockResolvedValue({ status: "persisted" });
244
+ const h = mountResizeHandler([], [], plainTranslate);
245
+ const size = { width: 300, height: 200 };
246
+ await act(() => h.resize(h.selection, size, undefined, vi.fn(), { plainTranslate }));
247
+ expect(h.fallback).toHaveBeenCalledTimes(plainTranslate ? 1 : 0);
248
+ expect(mocks.resize).toHaveBeenCalledTimes(plainTranslate ? 0 : 1);
249
+ act(() => h.root.unmount());
250
+ });
251
+ });
252
+
237
253
  describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => {
238
254
  it("writes no member when one CSS-route member has been folded since the press", async () => {
239
255
  const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
@@ -296,6 +312,15 @@ describe("useGsapAwareEditing rotation routing", () => {
296
312
  act(() => h.root.unmount());
297
313
  });
298
314
 
315
+ it("saves a turn the press sent down the GSAP route there, even once the element looks GSAP-free", async () => {
316
+ vi.mocked(tryGsapRotationIntercept).mockResolvedValue({ status: "persisted" });
317
+ const h = rotate(document.createElement("div"));
318
+ await act(() => h.rotationCommit(h.box, { angle: 55, plain: null }));
319
+ expect(tryGsapRotationIntercept).toHaveBeenCalledTimes(1);
320
+ expect(h.handleDomRotationCommit).not.toHaveBeenCalled();
321
+ act(() => h.root.unmount());
322
+ });
323
+
299
324
  it("saves a turn the press drew as CSS by the CSS writer, even once a GSAP tween turns the element", async () => {
300
325
  const element = document.createElement("div");
301
326
  const h = rotate(element);
@@ -30,9 +30,7 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
30
30
  import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures";
31
31
  import { runGestureTransaction } from "./gestureTransaction";
32
32
  import {
33
- gsapWritesBox,
34
- gsapWritesPosition,
35
- gsapWritesRotation,
33
+ editsPlainCss,
36
34
  hasNonHoldTweenForElement,
37
35
  POSITION_CHANNELS,
38
36
  } from "./gsapRuntimeKeyframes";
@@ -151,7 +149,7 @@ export function useGsapAwareEditing({
151
149
  modifiers?: MoveCommitOptions,
152
150
  ) => {
153
151
  const writes = observeGsapGesture(gsapCommitMutation);
154
- if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element)) {
152
+ if (modifiers?.plainTranslate ?? editsPlainCss(selection.element, "move")) {
155
153
  const result = await stageElementPositionOffset(selection, next, true).save();
156
154
  return writes.finish(result?.changed === true);
157
155
  }
@@ -207,9 +205,10 @@ export function useGsapAwareEditing({
207
205
  next: { width: number; height: number },
208
206
  offset?: { x: number; y: number },
209
207
  restore: () => void = () => undefined,
208
+ route?: { plainTranslate: boolean },
210
209
  ) => {
211
210
  const writes = observeGsapGesture(gsapCommitMutation);
212
- if (!gsapWritesBox(selection.element)) {
211
+ if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
213
212
  const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
214
213
  return writes.finish(result?.changed === true);
215
214
  }
@@ -258,8 +257,13 @@ export function useGsapAwareEditing({
258
257
  );
259
258
  // Saved after the size, under its undo key, so the two are one step.
260
259
  await saveMove(dragOutcome, async () => {
261
- const plain = !gsapWritesPosition(selection.element);
262
- anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey);
260
+ const plainAfterSettle = editsPlainCss(selection.element, "move");
261
+ anchorMove = stageElementPositionOffset(
262
+ selection,
263
+ offset,
264
+ plainAfterSettle,
265
+ coalesceKey,
266
+ );
263
267
  });
264
268
  }
265
269
  };
@@ -360,7 +364,7 @@ export function useGsapAwareEditing({
360
364
  const handleGsapAwareRotationCommit = useCallback(
361
365
  async (selection: DomEditSelection, next: RotationCommit) => {
362
366
  const writes = observeGsapGesture(gsapCommitMutation);
363
- if (next.plain || !gsapWritesRotation(selection.element)) {
367
+ if (next.plain === undefined ? editsPlainCss(selection.element, "rotate") : next.plain) {
364
368
  const result = await handleDomRotationCommit(selection, next);
365
369
  return writes.finish(result?.changed === true);
366
370
  }
@@ -5,7 +5,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
5
  import type { CommitMutationCall, CommitMutationOptions } from "./gsapScriptCommitTypes";
6
6
  import type { UseGsapAwareEditingParams } from "./useGsapAwareEditing";
7
7
  import { observeGsapGesture } from "./gsapGestureOutcome";
8
- import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
8
+ import { editsPlainCss } from "./gsapRuntimeKeyframes";
9
9
  import { refuseGsapTakeover } from "./elementOffsetStager";
10
10
  import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
11
11
  import { assertGsapEditPersisted } from "./gsapEditOutcome";
@@ -81,7 +81,7 @@ export function useGsapAwareGroupMove({
81
81
  // write nothing and share one in-flight parse per file, so they run together.
82
82
  const preflightResults = await Promise.allSettled(
83
83
  updates.map(async ({ selection, next, plainTranslate }) => {
84
- if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
84
+ if (plainTranslate ?? editsPlainCss(selection.element, "move")) {
85
85
  refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
86
86
  return void offsetMembers.set(selection, true);
87
87
  }
@@ -30,7 +30,11 @@ vi.mock("../utils/studioTelemetry", () => ({
30
30
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
31
31
 
32
32
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
33
- import type { MutationResult } from "./gsapScriptCommitTypes";
33
+ import type {
34
+ CommitMutationCall,
35
+ CommitMutationOptions,
36
+ MutationResult,
37
+ } from "./gsapScriptCommitTypes";
34
38
  import { persistSdkSerialize } from "../utils/sdkCutover";
35
39
  import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
36
40
  import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
@@ -211,6 +215,34 @@ describe("applyPreviewSync", () => {
211
215
  expect(applySoftReload).toHaveBeenCalledTimes(1);
212
216
  });
213
217
 
218
+ it("carries a deferred write with no instant patch into the final batch render", () => {
219
+ const previewFallbackLatch = { pending: false };
220
+ applySoftReload.mockReturnValue("applied");
221
+ patchRuntimeTweenInPlace.mockReturnValue(true);
222
+ const group = {
223
+ label: "Move animated layer (group)",
224
+ softReload: true,
225
+ previewFallbackLatch,
226
+ };
227
+
228
+ applyPreviewSync(
229
+ FAKE_IFRAME,
230
+ result({ scriptText: "SCRIPT" }),
231
+ { ...group, deferPreviewSync: true },
232
+ vi.fn(),
233
+ );
234
+ expect(previewFallbackLatch.pending).toBe(true);
235
+ expect(applySoftReload).not.toHaveBeenCalled();
236
+
237
+ applyPreviewSync(
238
+ FAKE_IFRAME,
239
+ result({ scriptText: "SCRIPT" }),
240
+ { ...group, instantPatch: { selector: "#final", change: { kind: "set", props: { x: 2 } } } },
241
+ vi.fn(),
242
+ );
243
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
244
+ });
245
+
214
246
  it("falls back immediately when a deferred patch miss has no final-render latch", () => {
215
247
  patchRuntimeTweenInPlace.mockReturnValue(false);
216
248
  applySoftReload.mockReturnValue("applied");
@@ -416,6 +448,16 @@ function renderCommitHook(
416
448
 
417
449
  const selection: DomEditSelection = { id: "a", selector: "#a" } as DomEditSelection;
418
450
 
451
+ async function commitBatch(calls: CommitMutationCall[], options: CommitMutationOptions) {
452
+ const deps = renderCommitHook();
453
+ const batch = deps.api.commitMutation.batch;
454
+ if (!batch) throw new Error("batch capability missing");
455
+ await act(async () => {
456
+ await batch(calls, options);
457
+ });
458
+ return deps;
459
+ }
460
+
419
461
  function mockFetchResult(over: Partial<MutationResult> = {}): void {
420
462
  const body: MutationResult = {
421
463
  ok: true,
@@ -553,6 +595,32 @@ describe("runCommit — instantPatch wiring", () => {
553
595
  expect(deps.reloadPreview).not.toHaveBeenCalled();
554
596
  });
555
597
 
598
+ it("batch where a write without an instant patch soft-reloads instead of patching the rest", async () => {
599
+ // A corner resize saves size (no in-place patch) and position (patched) in one batch.
600
+ patchRuntimeTweenInPlace.mockReturnValue(true);
601
+ applySoftReload.mockReturnValue("applied");
602
+ mockFetchResult({ changed: true });
603
+ const position = { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } };
604
+ const deps = await commitBatch(
605
+ [
606
+ {
607
+ selection,
608
+ mutation: { type: "update-property", property: "width", value: 300 },
609
+ options: { label: "Resize layer" },
610
+ },
611
+ {
612
+ selection,
613
+ mutation: { type: "update-property", property: "x", value: 10 },
614
+ options: { label: "Resize layer", instantPatch: position },
615
+ },
616
+ ],
617
+ { label: "Resize layer", softReload: true },
618
+ );
619
+
620
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
621
+ expect(deps.reloadPreview).not.toHaveBeenCalled();
622
+ });
623
+
556
624
  it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
557
625
  // Server contract: gsap-mutations returns scriptText on EVERY response,
558
626
  // including changed:false — so the fallback re-runs the identical script
@@ -268,7 +268,8 @@ export function applyPreviewSync(
268
268
  nestedFiles?: Map<string, string> | null,
269
269
  ): void {
270
270
  const patches = instantPatchesFor(options);
271
- let needsFallback = options.previewFallbackLatch?.pending === true;
271
+ const writtenWithoutPatch = patches.length === 0;
272
+ let needsFallback = options.previewFallbackLatch?.pending === true || writtenWithoutPatch;
272
273
  if (patches.length > 0) {
273
274
  const deferSeek = options.deferPreviewSync === true;
274
275
  const missed = patches.find(
@@ -410,13 +411,14 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
410
411
  if (!result) return;
411
412
  trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options));
412
413
  options.onResult?.(result);
413
- // Each call brings its own fast-path patch; the batch wrote them all, so the
414
- // preview sync applies them all rather than just the last call's.
415
- const instantPatches = calls
416
- .map(({ options: callOptions }) => callOptions.instantPatch)
417
- .filter((patch) => patch !== undefined);
414
+ // The batch wrote every call, so patch in place only when every call brought a patch; one
415
+ // without (a resize's size write) needs the reload, or the preview keeps the old value.
416
+ const instantPatches = calls.flatMap(({ options: callOptions }) =>
417
+ callOptions.instantPatch ? [callOptions.instantPatch] : [],
418
+ );
418
419
  const { instantPatch: _instantPatch, ...batchOptions } = options;
419
- await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, instantPatches.length > 0 ? { ...batchOptions, instantPatches } : batchOptions);
420
+ const allPatched = instantPatches.length === calls.length;
421
+ await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, allPatched ? { ...batchOptions, instantPatches } : batchOptions);
420
422
  }, [showToast, finalizeSuccessfulMutation]);
421
423
 
422
424
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
@@ -2,9 +2,9 @@
2
2
  // fallow-ignore-file code-duplication
3
3
  import { act, createElement } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
- import { join } from "node:path";
7
+ import { dirname, join } from "node:path";
8
8
  import { afterEach, expect, it, vi } from "vitest";
9
9
  import {
10
10
  createStudioApi,
@@ -316,6 +316,29 @@ it("refuses a second press whose edit is undone once an edit was made after the
316
316
  expect(file()).toBe("B");
317
317
  });
318
318
 
319
+ it("a file the edit made (a freeze's still) is deleted by its Undo and put back by its Redo", async () => {
320
+ const { dir, hook, save, readFile } = await studio();
321
+ const still = join(dir, "assets", "freeze", "talk.png");
322
+ mkdirSync(dirname(still), { recursive: true });
323
+ writeFileSync(still, "png");
324
+ save("B");
325
+ await act(() =>
326
+ hook().recordEdit({
327
+ label: "Freeze frame",
328
+ files: { "index.html": { before: "A", after: "B" } },
329
+ created: ["assets/freeze/talk.png"],
330
+ }),
331
+ );
332
+ await vi.waitFor(() => expect(hook().undoLabel).toBe("Freeze frame"));
333
+
334
+ await act(() => hook().undo({ readFile }));
335
+ expect(existsSync(still)).toBe(false);
336
+
337
+ await vi.waitFor(() => expect(hook().canRedo).toBe(true));
338
+ await act(() => hook().redo({ readFile }));
339
+ expect(readFileSync(still, "utf8")).toBe("png");
340
+ });
341
+
319
342
  it("a drag's edits under one key undo as one step, even before the drag goes idle", async () => {
320
343
  const { hook, file, save, readFile } = await studio();
321
344
  const writes: Array<[before: string, after: string]> = [
@@ -3,13 +3,7 @@ import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server"
3
3
  import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
4
4
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
5
5
  import { studioApiFetch } from "../utils/studioApiFetch";
6
-
7
- interface RecordEditInput {
8
- label: string;
9
- coalesceKey?: string;
10
- coalesceMs?: number;
11
- files: Record<string, { before: string; after: string }>;
12
- }
6
+ import type { RecordEditInput } from "../utils/studioFileHistory";
13
7
 
14
8
  interface ApplyCallbacks {
15
9
  readFile: (path: string) => Promise<string>;
@@ -235,9 +229,9 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
235
229
  }, [refresh, own]);
236
230
 
237
231
  const recordEdit = useCallback(
238
- async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => {
232
+ async ({ label, coalesceKey, coalesceMs, files, created = [] }: RecordEditInput) => {
239
233
  if (!projectId) return;
240
- const paths = Object.keys(files);
234
+ const paths = [...Object.keys(files), ...created];
241
235
  own.overtake();
242
236
  const reply = await post(historyUrl(projectId, "/claim"), {
243
237
  label,
@@ -499,6 +499,54 @@ describe("Timeline provider boundary", () => {
499
499
  act(() => root.unmount());
500
500
  });
501
501
 
502
+ // An agent writes music alternatives muted; the author unmutes one and must be
503
+ // able to mute it again from the same row.
504
+ it("mutes, unmutes and mutes an audio track again from its header", () => {
505
+ const host = createSizedTimelineHost(640);
506
+ usePlayerStore.setState({
507
+ duration: 4,
508
+ timelineReady: true,
509
+ elements: [{ id: "music-b", tag: "audio", start: 0, duration: 4, track: 0, hidden: true }],
510
+ });
511
+ const onToggleTrackHidden = vi.fn((track: number, hidden: boolean) => {
512
+ usePlayerStore.setState({
513
+ elements: usePlayerStore
514
+ .getState()
515
+ .elements.map((el) => (el.track === track ? { ...el, hidden } : el)),
516
+ });
517
+ });
518
+ const root = createRoot(host);
519
+ act(() => {
520
+ root.render(
521
+ React.createElement(
522
+ TimelineEditProvider,
523
+ { value: { onToggleTrackHidden } },
524
+ React.createElement(Timeline),
525
+ ),
526
+ );
527
+ });
528
+ act(() => {});
529
+
530
+ const press = (label: string) => {
531
+ const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
532
+ if (!button) throw new Error(`Expected a "${label}" button`);
533
+ act(() => button.click());
534
+ };
535
+ press("Unmute track 1");
536
+ press("Mute track 1");
537
+ press("Unmute track 1");
538
+ press("Mute track 1");
539
+
540
+ expect(onToggleTrackHidden.mock.calls.map((call) => call[1])).toEqual([
541
+ false,
542
+ true,
543
+ false,
544
+ true,
545
+ ]);
546
+ expect(usePlayerStore.getState().elements[0]?.hidden).toBe(true);
547
+ act(() => root.unmount());
548
+ });
549
+
502
550
  it("splits all tracks once when shift-clicking the timeline with the razor", () => {
503
551
  const host = createSizedTimelineHost(640);
504
552
  usePlayerStore.setState({
@@ -256,43 +256,35 @@ describe("TimelineTrackHeader", () => {
256
256
  act(() => view.root.unmount());
257
257
  });
258
258
 
259
- // The visibility control is the old hide eye. On an audio track it silences
260
- // rather than hides, and the row already says so with a speaker elsewhere —
261
- // so the eye's slot stays empty there. A non-audio track is untouched.
262
- it("keeps the visibility control off audio track headers", () => {
259
+ // `data-hidden` silences an audio track, so its toggle is a mute, offered in
260
+ // both states: an agent writes alternatives muted and the author flips them.
261
+ it.each([
262
+ [false, "Mute track 1"],
263
+ [true, "Unmute track 1"],
264
+ ])("offers the audio track toggle when hidden=%s", (isTrackHidden, label) => {
263
265
  const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
264
266
  const view = renderHeader({
265
267
  keyframeClip: audio,
266
268
  trackElements: [audio],
267
269
  isAudioTrack: true,
270
+ isTrackHidden,
268
271
  animations: [],
269
272
  });
270
- const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
271
- b.getAttribute("aria-label"),
272
- );
273
- expect(labels.some((l) => l && /^(Hide|Show) track/.test(l))).toBe(false);
274
- expect(labels).not.toContain("Mute");
273
+ expect(view.host.querySelector(`button[aria-label="${label}"]`)).not.toBeNull();
275
274
  act(() => view.root.unmount());
276
275
  });
277
276
 
278
- // The escape hatch. `data-hidden` on audio silences it in preview and drops it
279
- // from the render; the panel's "Muted" is the unrelated HTML `muted`
280
- // attribute, and nothing else writes it. Withholding the eye unconditionally
281
- // meant a track hidden by "Hide all" (or by hand, or before that rule existed)
282
- // was silent with no control anywhere to bring it back.
283
- it("offers the eye on an audio track that is already hidden, so it can be restored", () => {
284
- const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
277
+ // Same rule as the undo entry: a track with any visual clip hides, it does not mute.
278
+ it("offers the eye on a track mixing audio and a visual clip", () => {
279
+ const audio: TimelineElement = { ...ELEMENT, id: "vo", tag: "audio" };
285
280
  const view = renderHeader({
286
281
  keyframeClip: audio,
287
- trackElements: [audio],
282
+ trackElements: [audio, ELEMENT],
283
+ clipCount: 2,
288
284
  isAudioTrack: true,
289
- isTrackHidden: true,
290
285
  animations: [],
291
286
  });
292
- const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
293
- b.getAttribute("aria-label"),
294
- );
295
- expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true);
287
+ expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
296
288
  act(() => view.root.unmount());
297
289
  });
298
290
 
@@ -12,6 +12,7 @@ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditConte
12
12
  import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
13
13
  import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
14
14
  import { runtimeAudioId } from "../lib/timelineElementHelpers";
15
+ import { isAudioOnlyTrack } from "../../utils/timelineInspector";
15
16
  import { TimelineFxButton } from "./TimelineFxButton";
16
17
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
17
18
  import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData";
@@ -203,6 +204,7 @@ export function TimelineTrackHeader({
203
204
  // disclosability swapped it for the keyframe-layer row (a `◇`, no indent) the
204
205
  // moment an envelope appeared.
205
206
  const isKeyframeLayer = !!keyframeClip && disclosable && !isAudioTrack;
207
+ const isAudioOnly = isAudioOnlyTrack(trackElements);
206
208
  // What the lane disclosure calls this row. A row of several clips is named
207
209
  // for the TRACK, not for whichever is selected — the lanes are the track's,
208
210
  // shared per property, so "Narration 2 lanes" read as if they were that one
@@ -312,6 +314,7 @@ export function TimelineTrackHeader({
312
314
  showTrackLabel={showTrackLabel}
313
315
  isTrackHidden={isTrackHidden}
314
316
  isAudioTrack={isAudioTrack}
317
+ isAudioOnly={isAudioOnly}
315
318
  onToggleTrackHidden={onToggleTrackHidden}
316
319
  // On the control line rather than a third row of its own.
317
320
  trailing={
@@ -383,8 +386,7 @@ export function TimelineTrackHeader({
383
386
  hidden={isTrackHidden}
384
387
  trackNumber={trackNumber}
385
388
  trackDisplayNumber={trackDisplayNumber}
386
- // Audio: only while hidden — see the plain header.
387
- visible={!isAudioTrack || isTrackHidden}
389
+ asMute={isAudioOnly}
388
390
  onToggle={onToggleTrackHidden}
389
391
  />
390
392
  </LayerDisclosureRow>
@@ -1,17 +1,8 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  /**
4
- * The visibility control's accessible contract.
5
- *
6
- * The wording ran behind the `audio-track-mute` canary at 0%, so it had never
7
- * rendered in any suite: it returned "Muted" / "Mute", which named the CURRENT
8
- * state rather than the action, and dropped the track suffix so every audio row
9
- * shared one accessible name. Pinned here because the label, the icon and the
10
- * callback's arguments are the whole identity of this control.
11
- *
12
- * The mute presentation itself is gone (`remove mute and solo from tracks and
13
- * groups`): the control is the visibility eye it always was, and an audio row
14
- * renders it only when already hidden, which is the way back out.
4
+ * The track toggle's accessible contract: the label names the action, carries
5
+ * the display row, and reads mute on an audio-only track.
15
6
  */
16
7
 
17
8
  import React, { act } from "react";
@@ -27,7 +18,7 @@ afterEach(() => {
27
18
 
28
19
  function renderButton(props: {
29
20
  hidden: boolean;
30
- visible?: boolean;
21
+ asMute?: boolean;
31
22
  trackDisplayNumber: number | null;
32
23
  }): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
33
24
  const host = document.createElement("div");
@@ -40,7 +31,7 @@ function renderButton(props: {
40
31
  hidden: props.hidden,
41
32
  trackNumber: 7,
42
33
  trackDisplayNumber: props.trackDisplayNumber,
43
- visible: props.visible ?? true,
34
+ asMute: props.asMute ?? false,
44
35
  onToggle,
45
36
  }),
46
37
  ),
@@ -80,13 +71,13 @@ describe("VisibilityButton", () => {
80
71
  view.unmount();
81
72
  });
82
73
 
83
- // `visible={false}` renders a SPACER, not nothing: every row's control
84
- // columns have to line up whether or not this row offers the control.
85
- it("holds the column with a spacer when withheld", () => {
86
- const view = renderButton({ hidden: false, visible: false, trackDisplayNumber: 2 });
87
- expect(view.host.querySelector("button")).toBeNull();
88
- expect(view.host.querySelector("span")).toBeTruthy();
89
- view.unmount();
74
+ it("names a mute on an audio-only track", () => {
75
+ const audible = renderButton({ hidden: false, asMute: true, trackDisplayNumber: 2 });
76
+ expect(labelOf(audible.host)).toBe("Mute track 2");
77
+ audible.unmount();
78
+ const muted = renderButton({ hidden: true, asMute: true, trackDisplayNumber: 2 });
79
+ expect(labelOf(muted.host)).toBe("Unmute track 2");
80
+ muted.unmount();
90
81
  });
91
82
  });
92
83
 
@@ -104,6 +95,7 @@ describe("PlainTrackHeader", () => {
104
95
  clipCount: 1,
105
96
  isTrackHidden: false,
106
97
  isAudioTrack: true,
98
+ isAudioOnly: true,
107
99
  onToggleTrackHidden: vi.fn(),
108
100
  showTrackLabel: true,
109
101
  ...overrides,
@@ -113,26 +105,20 @@ describe("PlainTrackHeader", () => {
113
105
  return { host, unmount: () => act(() => root.unmount()) };
114
106
  }
115
107
 
116
- // A row that says what it is with a speaker does not also need the hide
117
- // affordance sitting in the eye's slot.
118
- it("withholds the eye from an audible audio track", () => {
108
+ it("offers a mute on an audible audio track", () => {
119
109
  const view = renderHeader();
120
- expect(view.host.querySelector("button")).toBeNull();
110
+ expect(labelOf(view.host)).toBe("Mute track 1");
121
111
  view.unmount();
122
112
  });
123
113
 
124
- // Withholding it unconditionally withheld the only way back: `data-hidden`
125
- // silences the clip in preview and drops it from the render, and nothing else
126
- // writes it, so a track hidden by hand or by "Hide all" was silent with no
127
- // control anywhere to restore it.
128
- it("offers it back once the audio track is hidden", () => {
114
+ it("offers the unmute on a muted audio track", () => {
129
115
  const view = renderHeader({ isTrackHidden: true });
130
- expect(labelOf(view.host)).toBe("Show track 1");
116
+ expect(labelOf(view.host)).toBe("Unmute track 1");
131
117
  view.unmount();
132
118
  });
133
119
 
134
120
  it("keeps the eye on a visual track", () => {
135
- const view = renderHeader({ isAudioTrack: false });
121
+ const view = renderHeader({ isAudioTrack: false, isAudioOnly: false });
136
122
  expect(labelOf(view.host)).toBe("Hide track 1");
137
123
  view.unmount();
138
124
  });