@hyperframes/studio 0.8.11 → 0.8.13

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-BP9rShvS.js} +1 -1
  2. package/dist/assets/{index-1vMlv05L.js → index-CBm9bYHQ.js} +1 -1
  3. package/dist/assets/{index-BX0piiWc.js → index-CDerRsMw.js} +1 -1
  4. package/dist/assets/index-CQMHN4nk.js +431 -0
  5. package/dist/assets/index-yGhfxxoL.css +1 -0
  6. package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
  7. package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
  8. package/dist/index.d.ts +84 -43
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +7646 -6514
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +0 -2
  14. package/src/components/StudioRightPanel.tsx +15 -57
  15. package/src/components/editor/PropertyPanel.test.tsx +101 -3
  16. package/src/components/editor/PropertyPanel.tsx +15 -1
  17. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  18. package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
  19. package/src/components/editor/PropertyPanelFlat.tsx +82 -21
  20. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
  21. package/src/components/editor/TimelineFxPopover.tsx +24 -4
  22. package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
  23. package/src/components/editor/audioFxRevealTarget.ts +113 -0
  24. package/src/components/editor/audioFxSignalPath.test.ts +50 -0
  25. package/src/components/editor/audioFxSignalPath.ts +71 -0
  26. package/src/components/editor/audioFxSummary.test.ts +8 -0
  27. package/src/components/editor/audioFxSummary.ts +28 -16
  28. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
  29. package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
  30. package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
  31. package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
  32. package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
  33. package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
  34. package/src/components/editor/propertyPanelFxControls.tsx +10 -2
  35. package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
  36. package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
  37. package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
  38. package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
  39. package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
  40. package/src/components/editor/propertyPanelFxSection.tsx +63 -4
  41. package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
  42. package/src/components/editor/useAudioFxRevealSection.ts +64 -0
  43. package/src/components/editor/useAuditionTransport.test.ts +36 -0
  44. package/src/components/editor/useAuditionTransport.ts +78 -0
  45. package/src/components/editor/useFxAudition.ts +12 -1
  46. package/src/components/editor/useFxCarve.ts +0 -0
  47. package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
  48. package/src/components/editor/useFxCarveGrouping.ts +139 -4
  49. package/src/components/editor/useFxCarveNodes.ts +153 -0
  50. package/src/components/editor/useFxLevelling.ts +5 -34
  51. package/src/components/nle/NLEContext.tsx +3 -10
  52. package/src/components/nle/PreviewPane.tsx +0 -1
  53. package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
  54. package/src/contexts/DesignPanelInputContext.tsx +6 -5
  55. package/src/contexts/DomEditContext.tsx +10 -3
  56. package/src/contexts/FileManagerContext.tsx +3 -2
  57. package/src/contexts/PanelLayoutContext.tsx +3 -2
  58. package/src/contexts/StudioContext.tsx +7 -3
  59. package/src/contexts/TimelineEditContext.tsx +6 -2
  60. package/src/contexts/VariablePromoteContext.tsx +6 -2
  61. package/src/contexts/ViewModeContext.tsx +2 -2
  62. package/src/hooks/domEditDeleteMembers.ts +34 -0
  63. package/src/hooks/domSelectionTimelineMirror.ts +12 -3
  64. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  65. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  66. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  67. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  68. package/src/hooks/timelineEditingHelpers.ts +19 -7
  69. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  70. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  71. package/src/hooks/timelineTrackVisibility.ts +6 -182
  72. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  73. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  74. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  75. package/src/hooks/useDomEditSession.test.tsx +57 -2
  76. package/src/hooks/useDomEditSession.ts +23 -24
  77. package/src/hooks/useDomSelectionSelectionGuards.test.ts +115 -0
  78. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  79. package/src/hooks/useLivePlayheadTime.ts +3 -1
  80. package/src/hooks/useRemoveBackground.ts +70 -0
  81. package/src/hooks/useTimelineEditing.ts +5 -19
  82. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  83. package/src/player/components/AutomationEnvelopePaths.tsx +54 -0
  84. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  85. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  86. package/src/player/components/PlayerControls.tsx +0 -38
  87. package/src/player/components/Timeline.test.ts +40 -1
  88. package/src/player/components/Timeline.tsx +5 -2
  89. package/src/player/components/TimelineAutomationLane.test.tsx +180 -0
  90. package/src/player/components/TimelineAutomationLane.tsx +129 -36
  91. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  92. package/src/player/components/TimelineFxButton.test.tsx +167 -21
  93. package/src/player/components/TimelineFxButton.tsx +156 -31
  94. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  95. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  96. package/src/player/components/TimelineGroupLaneLabels.tsx +99 -0
  97. package/src/player/components/TimelineGroupRow.test.tsx +222 -0
  98. package/src/player/components/TimelineGroupRow.tsx +140 -71
  99. package/src/player/components/TimelineLanes.test.tsx +3 -3
  100. package/src/player/components/TimelineLanes.tsx +25 -23
  101. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  102. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  103. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  104. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  105. package/src/player/components/automationLaneData.test.ts +94 -0
  106. package/src/player/components/automationLaneData.ts +23 -0
  107. package/src/player/components/automationLaneDragMath.ts +28 -0
  108. package/src/player/components/automationLaneGeometry.ts +52 -13
  109. package/src/player/components/groupAutomationElement.test.ts +51 -0
  110. package/src/player/components/groupAutomationElement.ts +37 -0
  111. package/src/player/components/timelineCallbacks.ts +6 -2
  112. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  113. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  114. package/src/player/components/timelineLayout.ts +0 -1
  115. package/src/player/components/timelineViewModel.ts +30 -1
  116. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  117. package/src/player/components/useAutomationLaneGestures.ts +66 -18
  118. package/src/player/components/useAutomationSegmentDrag.ts +110 -0
  119. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  120. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  121. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  122. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  123. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  124. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  125. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  126. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  127. package/src/player/hooks/previewMessageRouter.ts +4 -11
  128. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  129. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  130. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  131. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  132. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  133. package/src/player/lib/automationStoreSync.test.ts +26 -0
  134. package/src/player/lib/automationStoreSync.ts +28 -4
  135. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  136. package/src/player/lib/timelineDOM.test.ts +115 -0
  137. package/src/player/lib/timelineDOM.ts +3 -32
  138. package/src/player/lib/timelineElementHelpers.ts +27 -0
  139. package/src/player/lib/timelineGroupInfo.ts +134 -0
  140. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  141. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  142. package/src/player/store/keyframeSlice.ts +79 -5
  143. package/src/player/store/playerStore.ts +37 -47
  144. package/src/player/store/playerStoreDevHandle.ts +22 -0
  145. package/src/player/store/playerStoreSelection.ts +41 -0
  146. package/src/player/store/timelineElement.ts +32 -0
  147. package/src/utils/hmrStableContext.ts +64 -0
  148. package/src/utils/timelineInspector.test.ts +35 -1
  149. package/src/utils/timelineInspector.ts +38 -0
  150. package/dist/assets/index-Ba9zbpT9.css +0 -1
  151. package/dist/assets/index-DoW5v5eS.js +0 -428
  152. package/src/hooks/useAudioSoloBridge.ts +0 -61
  153. package/src/hooks/useGroupLevel.ts +0 -36
  154. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  155. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  156. package/src/player/components/TimelineSoloButton.tsx +0 -32
  157. package/src/player/store/audioSoloSlice.test.ts +0 -113
  158. package/src/player/store/audioSoloSlice.ts +0 -43
  159. package/src/player/store/groupLevels.ts +0 -33
  160. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  161. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -7,9 +7,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
8
8
  import { TimelineTrackHeader } from "./TimelineTrackHeader";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
- import type { TimelineElement } from "../store/playerStore";
10
+ import { type TimelineElement } from "../store/playerStore";
11
11
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
12
- import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout";
12
+ import { getTimelineLaneTop, LABEL_COL_W, TRACK_H } from "./timelineLayout";
13
13
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
14
14
 
15
15
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -72,6 +72,9 @@ interface RenderHeaderOptions {
72
72
  onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
73
73
  onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
74
74
  onRemoveAutomationLane?: (target: string) => void;
75
+ isAudioTrack?: boolean;
76
+ isGroupMember?: boolean;
77
+ isTrackHidden?: boolean;
75
78
  }
76
79
 
77
80
  function renderHeader(options: RenderHeaderOptions = {}): {
@@ -82,7 +85,20 @@ function renderHeader(options: RenderHeaderOptions = {}): {
82
85
  const host = document.createElement("div");
83
86
  document.body.append(host);
84
87
  const root = createRoot(host);
85
- const render = (next: RenderHeaderOptions) => {
88
+ const render = (raw: RenderHeaderOptions) => {
89
+ // Defaults resolved once, up front, rather than as a `??` per prop in the
90
+ // JSX — a dozen of those is a dozen branches through one arrow.
91
+ const next = {
92
+ keyframeClip: ELEMENT,
93
+ clipCount: 1,
94
+ animations: [POSITION, OPACITY],
95
+ currentTime: 0,
96
+ isAudioTrack: false,
97
+ isGroupMember: false,
98
+ isTrackHidden: false,
99
+ onToggleTrackHidden: vi.fn(),
100
+ ...raw,
101
+ };
86
102
  act(() => {
87
103
  root.render(
88
104
  <TimelineTrackHeader
@@ -93,17 +109,18 @@ function renderHeader(options: RenderHeaderOptions = {}): {
93
109
  trackLabel="Hero card"
94
110
  lanesId="timeline-lanes-track-0"
95
111
  contentOrigin={LABEL_COL_W}
96
- keyframeClip={next.keyframeClip ?? ELEMENT}
97
- trackElements={next.trackElements ?? [next.keyframeClip ?? ELEMENT]}
98
- clipCount={next.clipCount ?? 1}
112
+ keyframeClip={next.keyframeClip}
113
+ trackElements={next.trackElements ?? [next.keyframeClip]}
114
+ clipCount={next.clipCount}
99
115
  isExpanded={next.expanded !== false}
100
- animations={next.animations ?? [POSITION, OPACITY]}
101
- currentTime={next.currentTime ?? 0}
102
- isTrackHidden={false}
103
- isAudioTrack={false}
116
+ animations={next.animations}
117
+ currentTime={next.currentTime}
118
+ isTrackHidden={next.isTrackHidden}
119
+ isAudioTrack={next.isAudioTrack}
120
+ isGroupMember={next.isGroupMember}
104
121
  theme={defaultTimelineTheme}
105
122
  onToggleClipExpanded={vi.fn()}
106
- onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
123
+ onToggleTrackHidden={next.onToggleTrackHidden}
107
124
  onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
108
125
  onRemoveAutomationLane={next.onRemoveAutomationLane}
109
126
  onSeek={next.onSeek}
@@ -122,6 +139,63 @@ function click(host: HTMLElement, label: string) {
122
139
  }
123
140
 
124
141
  describe("TimelineTrackHeader", () => {
142
+ // §5: gain stages multiply. A group fading to 0.42 under a clip fading to
143
+ // 0.80 plays at 0.34, and an author who drew both hears something quieter
144
+ // than either with nothing on screen to say why. Not a warning; an
145
+ // explanation.
146
+ it("says so when the clip's group is fading the same parameter", () => {
147
+ const automation = JSON.stringify({
148
+ version: 1,
149
+ lanes: [
150
+ {
151
+ target: "volume",
152
+ points: [
153
+ { t: 0, v: 1 },
154
+ { t: 2, v: 0.4 },
155
+ ],
156
+ },
157
+ ],
158
+ });
159
+ const clip: TimelineElement = {
160
+ ...ELEMENT,
161
+ tag: "audio",
162
+ automation,
163
+ audioGroup: "voiceover",
164
+ audioGroupLabel: "Voiceover",
165
+ audioGroupAutomation: automation,
166
+ };
167
+ const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] });
168
+ expect(view.host.textContent).toContain("Voiceover is also fading this.");
169
+ act(() => view.root.unmount());
170
+ });
171
+
172
+ // The same clip with an un-automated group must stay quiet — the note is
173
+ // only honest when the two curves actually multiply.
174
+ it("stays quiet when the group automates nothing", () => {
175
+ const automation = JSON.stringify({
176
+ version: 1,
177
+ lanes: [
178
+ {
179
+ target: "volume",
180
+ points: [
181
+ { t: 0, v: 1 },
182
+ { t: 2, v: 0.4 },
183
+ ],
184
+ },
185
+ ],
186
+ });
187
+ const clip: TimelineElement = {
188
+ ...ELEMENT,
189
+ tag: "audio",
190
+ automation,
191
+ audioGroup: "voiceover",
192
+ audioGroupLabel: "Voiceover",
193
+ };
194
+ const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] });
195
+ expect(view.host.textContent).not.toContain("is also fading this");
196
+ act(() => view.root.unmount());
197
+ });
198
+
125
199
  // An expanded sub-composition child sits on the MASTER timeline at a
126
200
  // host-absolute start, but its tweens are parsed from its own file and are
127
201
  // local to it. Feeding the raw start straight into the clip-% math put every
@@ -180,6 +254,55 @@ describe("TimelineTrackHeader", () => {
180
254
  act(() => view.root.unmount());
181
255
  });
182
256
 
257
+ // The visibility control is the old hide eye. On an audio track it silences
258
+ // rather than hides, and the row already says so with a speaker elsewhere —
259
+ // so the eye's slot stays empty there. A non-audio track is untouched.
260
+ it("keeps the visibility control off audio track headers", () => {
261
+ const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
262
+ const view = renderHeader({
263
+ keyframeClip: audio,
264
+ trackElements: [audio],
265
+ isAudioTrack: true,
266
+ animations: [],
267
+ });
268
+ const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
269
+ b.getAttribute("aria-label"),
270
+ );
271
+ expect(labels.some((l) => l && /^(Hide|Show) track/.test(l))).toBe(false);
272
+ expect(labels).not.toContain("Mute");
273
+ act(() => view.root.unmount());
274
+ });
275
+
276
+ // The escape hatch. `data-hidden` on audio silences it in preview and drops it
277
+ // from the render; the panel's "Muted" is the unrelated HTML `muted`
278
+ // attribute, and nothing else writes it. Withholding the eye unconditionally
279
+ // meant a track hidden by "Hide all" (or by hand, or before that rule existed)
280
+ // was silent with no control anywhere to bring it back.
281
+ it("offers the eye on an audio track that is already hidden, so it can be restored", () => {
282
+ const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
283
+ const view = renderHeader({
284
+ keyframeClip: audio,
285
+ trackElements: [audio],
286
+ isAudioTrack: true,
287
+ isTrackHidden: true,
288
+ animations: [],
289
+ });
290
+ const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
291
+ b.getAttribute("aria-label"),
292
+ );
293
+ expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true);
294
+ act(() => view.root.unmount());
295
+ });
296
+
297
+ it("keeps it on a non-audio track", () => {
298
+ const view = renderHeader({ isAudioTrack: false });
299
+ const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
300
+ b.getAttribute("aria-label"),
301
+ );
302
+ expect(labels.some((l) => l && /^Hide track/.test(l))).toBe(true);
303
+ act(() => view.root.unmount());
304
+ });
305
+
183
306
  // The eye acts on the layer, so it has to be reachable without a pointer and
184
307
  // in every disclosure state — a hover-gated eye is unusable by keyboard.
185
308
  it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
@@ -614,7 +737,9 @@ describe("TimelineTrackHeader", () => {
614
737
  it("names the header for the track, not for one of the clips on it", () => {
615
738
  const view = renderHeader({ ...ROW, keyframeClip: NARRATION_2 });
616
739
  expect(view.host.textContent).not.toContain("narration-2");
617
- expect(view.host.querySelector('[title="Track 1"]')?.textContent).toBe("Track 1");
740
+ // Asserted on the rendered text, not a `title`: the name wraps now rather
741
+ // than truncating, so it no longer carries a tooltip to be found by.
742
+ expect(view.host.textContent).toContain("Track 1");
618
743
  // Alone on the track it is still named for itself.
619
744
  view.rerender({
620
745
  ...ROW,
@@ -622,7 +747,167 @@ describe("TimelineTrackHeader", () => {
622
747
  trackElements: [NARRATION_2],
623
748
  clipCount: 1,
624
749
  });
625
- expect(view.host.querySelector('[title="narration-2"]')?.textContent).toBe("narration-2");
750
+ expect(view.host.textContent).toContain("narration-2");
751
+ act(() => view.root.unmount());
752
+ });
753
+ });
754
+
755
+ describe("audio ids and canary gates", () => {
756
+ const VOICE: TimelineElement = {
757
+ id: "voice-1",
758
+ key: "index.html#voice-1",
759
+ domId: "voice-1",
760
+ tag: "audio",
761
+ start: 0,
762
+ duration: 5,
763
+ track: 0,
764
+ };
765
+ const VOICE_2: TimelineElement = {
766
+ ...VOICE,
767
+ id: "voice-2",
768
+ key: "index.html#voice-2",
769
+ domId: "voice-2",
770
+ };
771
+
772
+ // A member row is `aria-level="2"`, and without this it looked identical to
773
+ // every top-level row — the nesting existed for a screen reader and not for
774
+ // an eye. B2's design called for the accent rail; only the semantics shipped.
775
+ it("indents a group member's row and gives it the accent rail", () => {
776
+ const view = renderHeader({
777
+ keyframeClip: VOICE,
778
+ animations: [],
779
+ expanded: false,
780
+ isAudioTrack: true,
781
+ });
782
+ const header = () => view.host.querySelector<HTMLElement>('[role="rowheader"]');
783
+
784
+ expect(header()?.style.paddingLeft).toBe("");
785
+ expect(header()?.style.borderLeft).toBe("");
786
+ expect(header()?.style.background).not.toContain("linear-gradient");
787
+
788
+ view.rerender({
789
+ keyframeClip: VOICE,
790
+ animations: [],
791
+ expanded: false,
792
+ isAudioTrack: true,
793
+ isGroupMember: true,
794
+ });
795
+ expect(header()?.style.paddingLeft).toBe("14px");
796
+ expect(header()?.style.borderLeft).toContain("2px");
797
+ // And a lighter gutter, so the row reads as sitting INSIDE its group
798
+ // rather than beside it. Overlaid on the theme's own fill rather than a
799
+ // hard-coded colour, so it follows whatever the gutter is.
800
+ expect(header()?.style.background).toContain("linear-gradient");
801
+ act(() => view.root.unmount());
802
+ });
803
+
804
+ it("offers the FX button on every audio track", () => {
805
+ const view = renderHeader({
806
+ keyframeClip: VOICE,
807
+ animations: [],
808
+ expanded: false,
809
+ isAudioTrack: true,
810
+ });
811
+ expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull();
812
+ act(() => view.root.unmount());
813
+ });
814
+
815
+ // A visual track has no chain to open, so the button must not follow the
816
+ // header onto every row.
817
+ it("withholds the FX button from a non-audio track", () => {
818
+ const view = renderHeader({
819
+ keyframeClip: ELEMENT,
820
+ animations: [],
821
+ expanded: false,
822
+ });
823
+ expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull();
824
+ act(() => view.root.unmount());
825
+ });
826
+
827
+ // A chain belongs to ONE bus, so a track carrying several ungrouped clips
828
+ // gets the pointer instead of the FX button — group first, then mix.
829
+ it("shows the group pointer on a multi-clip ungrouped audio track", () => {
830
+ const opts = {
831
+ keyframeClip: VOICE,
832
+ trackElements: [VOICE, VOICE_2],
833
+ clipCount: 2,
834
+ animations: [],
835
+ expanded: false,
836
+ isAudioTrack: true,
837
+ };
838
+ const pointer = (host: HTMLElement) =>
839
+ host.querySelector('button[aria-label="Effects — group these clips first"]');
840
+ const view = renderHeader(opts);
841
+ expect(pointer(view.host)).not.toBeNull();
842
+ // One clip needs no grouping — the real FX button takes its place.
843
+ view.rerender({ ...opts, trackElements: [VOICE], clipCount: 1 });
844
+ expect(pointer(view.host)).toBeNull();
845
+ expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull();
846
+ act(() => view.root.unmount());
847
+ });
848
+
849
+ // The header is a 48px column of exactly TWO lines — what the row is, then
850
+ // what you can do to it. The group pointer used to render as a sibling of
851
+ // both, making a third: 17 + 24 + 24 + gaps in a 48px box, which
852
+ // `justify-center` then spilled evenly out of the top and bottom. The name
853
+ // rode 10px above its own row and the pointer collided with the row below.
854
+ // The header GROWS by AUTOMATION_LANE_H for every open lane, and the lanes
855
+ // are absolutely positioned from its top. `justify-center` on the header
856
+ // itself therefore centred the two lines in the FULL height, so opening a
857
+ // lane pushed the name and its controls down on top of the lane rows.
858
+ it("pins the two lines to the top TRACK_H, whatever the header grows to", () => {
859
+ const automated: TimelineElement = {
860
+ ...VOICE,
861
+ automation: JSON.stringify({
862
+ version: 1,
863
+ lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
864
+ }),
865
+ };
866
+ const view = renderHeader({
867
+ keyframeClip: automated,
868
+ trackElements: [automated],
869
+ clipCount: 1,
870
+ animations: [],
871
+ expanded: true,
872
+ isAudioTrack: true,
873
+ });
874
+ const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
875
+ const lines = header?.children[0] as HTMLElement | undefined;
876
+ expect(lines?.style.height).toBe(`${TRACK_H}px`);
877
+ // The lane row is a sibling of the wrapper, not inside it — it stacks
878
+ // BELOW the two lines rather than sharing their box.
879
+ expect((header?.children.length ?? 0) > 1).toBe(true);
880
+ act(() => view.root.unmount());
881
+ });
882
+
883
+ // The header is ONE line: name, clip count, then every control anchored to
884
+ // the right edge. It was two — a name line and a control line — which is
885
+ // what let a stray third child overflow the 48px box; now there is a single
886
+ // row and the controls share one right-aligned group.
887
+ it("keeps the name and every control on one line, controls to the right", () => {
888
+ const view = renderHeader({
889
+ keyframeClip: VOICE,
890
+ trackElements: [VOICE, VOICE_2],
891
+ clipCount: 2,
892
+ animations: [],
893
+ expanded: false,
894
+ isAudioTrack: true,
895
+ });
896
+ const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
897
+ // One TRACK_H-tall wrapper holding one line.
898
+ const lines = header?.children[0];
899
+ expect(lines?.children).toHaveLength(1);
900
+ // The controls live in a right-anchored group at the end of that line,
901
+ // after the name and the clip count — `ml-auto` is what holds the edge.
902
+ const line = lines?.children[0];
903
+ const controls = line?.lastElementChild as HTMLElement | null;
904
+ expect(controls?.className).toContain("ml-auto");
905
+ expect(
906
+ controls?.querySelector('button[aria-label="Effects — group these clips first"]'),
907
+ ).not.toBeNull();
908
+ // And the clip count is beside the name, not out with the controls.
909
+ expect(controls?.querySelector('[aria-label="2 clips"]')).toBeNull();
910
+ expect(line?.querySelector('[aria-label="2 clips"]')).not.toBeNull();
626
911
  act(() => view.root.unmount());
627
912
  });
628
913
  });