@hyperframes/studio 0.8.11 → 0.8.12

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 (154) hide show
  1. package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-1vMlv05L.js → index-DQ_lkuqY.js} +1 -1
  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 +6431 -5515
  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/timelineAudioGroupCreate.ts +345 -0
  64. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  65. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  66. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  67. package/src/hooks/timelineEditingHelpers.ts +19 -7
  68. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  69. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  70. package/src/hooks/timelineTrackVisibility.ts +6 -182
  71. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  72. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  73. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  74. package/src/hooks/useDomEditSession.test.tsx +57 -2
  75. package/src/hooks/useDomEditSession.ts +23 -24
  76. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  77. package/src/hooks/useLivePlayheadTime.ts +3 -1
  78. package/src/hooks/useRemoveBackground.ts +70 -0
  79. package/src/hooks/useTimelineEditing.ts +5 -19
  80. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  81. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  82. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  83. package/src/player/components/PlayerControls.tsx +0 -38
  84. package/src/player/components/Timeline.test.ts +40 -1
  85. package/src/player/components/Timeline.tsx +5 -2
  86. package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
  87. package/src/player/components/TimelineAutomationLane.tsx +89 -12
  88. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  89. package/src/player/components/TimelineFxButton.test.tsx +167 -21
  90. package/src/player/components/TimelineFxButton.tsx +156 -31
  91. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  92. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  93. package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
  94. package/src/player/components/TimelineGroupRow.test.tsx +146 -0
  95. package/src/player/components/TimelineGroupRow.tsx +123 -64
  96. package/src/player/components/TimelineLanes.test.tsx +3 -3
  97. package/src/player/components/TimelineLanes.tsx +25 -23
  98. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  99. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  100. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  101. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  102. package/src/player/components/automationLaneData.test.ts +94 -0
  103. package/src/player/components/automationLaneData.ts +23 -0
  104. package/src/player/components/groupAutomationElement.test.ts +51 -0
  105. package/src/player/components/groupAutomationElement.ts +37 -0
  106. package/src/player/components/timelineCallbacks.ts +6 -2
  107. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  108. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  109. package/src/player/components/timelineLayout.ts +0 -1
  110. package/src/player/components/timelineViewModel.ts +30 -1
  111. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  112. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  113. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  114. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  115. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  116. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  117. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  118. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  120. package/src/player/hooks/previewMessageRouter.ts +4 -11
  121. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  122. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  123. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  124. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  126. package/src/player/lib/automationStoreSync.test.ts +26 -0
  127. package/src/player/lib/automationStoreSync.ts +28 -4
  128. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  129. package/src/player/lib/timelineDOM.test.ts +115 -0
  130. package/src/player/lib/timelineDOM.ts +3 -32
  131. package/src/player/lib/timelineElementHelpers.ts +27 -0
  132. package/src/player/lib/timelineGroupInfo.ts +134 -0
  133. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  134. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  135. package/src/player/store/keyframeSlice.ts +79 -5
  136. package/src/player/store/playerStore.ts +37 -47
  137. package/src/player/store/playerStoreDevHandle.ts +22 -0
  138. package/src/player/store/playerStoreSelection.ts +41 -0
  139. package/src/player/store/timelineElement.ts +32 -0
  140. package/src/utils/hmrStableContext.ts +64 -0
  141. package/src/utils/timelineInspector.test.ts +35 -1
  142. package/src/utils/timelineInspector.ts +38 -0
  143. package/dist/assets/index-Ba9zbpT9.css +0 -1
  144. package/dist/assets/index-DoW5v5eS.js +0 -428
  145. package/src/hooks/useAudioSoloBridge.ts +0 -61
  146. package/src/hooks/useGroupLevel.ts +0 -36
  147. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  148. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  149. package/src/player/components/TimelineSoloButton.tsx +0 -32
  150. package/src/player/store/audioSoloSlice.test.ts +0 -113
  151. package/src/player/store/audioSoloSlice.ts +0 -43
  152. package/src/player/store/groupLevels.ts +0 -33
  153. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  154. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -77,8 +77,8 @@ export interface BuildTimelineLogicalRowsInput {
77
77
  selectedElementId: string | null;
78
78
  selectedElementIds: ReadonlySet<string>;
79
79
  expandedClipIds: ReadonlySet<string>;
80
- /** Groups whose member rows the caret has shown (structural, not lanes). */
81
- expandedGroupIds: ReadonlySet<string>;
80
+ /** Groups the caret has COLLAPSED — absent means expanded, the default. */
81
+ collapsedGroupIds: ReadonlySet<string>;
82
82
  /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
83
83
  expandedLaneOwnerIds: ReadonlySet<string>;
84
84
  groups: readonly TimelineTrackGroupInfo[];
@@ -251,7 +251,7 @@ export function buildTimelineLogicalRows({
251
251
  selectedElementId,
252
252
  selectedElementIds,
253
253
  expandedClipIds,
254
- expandedGroupIds,
254
+ collapsedGroupIds,
255
255
  expandedLaneOwnerIds,
256
256
  groups,
257
257
  trackGroupOf,
@@ -273,7 +273,8 @@ export function buildTimelineLogicalRows({
273
273
  selectedElementIds,
274
274
  gsapAnimations,
275
275
  );
276
- const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
276
+ const disclosable = isTrackDisclosable(elements, lanes.length);
277
+ const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && disclosable;
277
278
  rows.push({
278
279
  id: trackId,
279
280
  kind: "row",
@@ -282,7 +283,7 @@ export function buildTimelineLogicalRows({
282
283
  level,
283
284
  parentId,
284
285
  elementId: activeId,
285
- expandable: lanes.length > 0,
286
+ expandable: disclosable,
286
287
  expanded,
287
288
  items: clipItems(trackId, elements),
288
289
  });
@@ -300,7 +301,7 @@ export function buildTimelineLogicalRows({
300
301
  // count.
301
302
  function emitGroup(group: TimelineTrackGroupInfo): void {
302
303
  const groupRowId = timelineGroupRowId(group.id);
303
- const groupExpanded = expandedGroupIds.has(group.id);
304
+ const groupExpanded = !collapsedGroupIds.has(group.id);
304
305
  rows.push({
305
306
  id: groupRowId,
306
307
  kind: "row",
@@ -315,8 +316,10 @@ export function buildTimelineLogicalRows({
315
316
  items: [],
316
317
  });
317
318
  if (expandedLaneOwnerIds.has(group.id)) {
318
- const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
319
- for (const laneGroup of groupAutomationLanes(memberElements)) {
319
+ // The group's own member list, not `trackMap`: a COLLAPSED group can have
320
+ // its lane shelf open, and its members are absent from the display list —
321
+ // so looking them up there emitted zero lane rows for exactly that case.
322
+ for (const laneGroup of groupAutomationLanes(group.memberElements)) {
320
323
  rows.push({
321
324
  id: `${groupRowId}::${laneGroup.key}`,
322
325
  kind: "row",
@@ -452,3 +455,17 @@ export function resolveTimelineFocusFallback(
452
455
  }
453
456
  return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null;
454
457
  }
458
+
459
+ /**
460
+ * Does a track have anything to open — the header's own `disclosable`.
461
+ *
462
+ * `TimelineTrackHeader` is `lanes.length > 0 || automationRows.length > 0`, and
463
+ * keyed on tweens alone here an audio track whose only disclosable content is
464
+ * AUTOMATION drew the `∿` while reporting itself unexpandable to the treegrid,
465
+ * so ArrowRight could not open it. Automation rows are counted per shared
466
+ * PROPERTY across the track's clips, the way the header counts them, not per
467
+ * clip.
468
+ */
469
+ function isTrackDisclosable(elements: readonly TimelineElement[], laneCount: number): boolean {
470
+ return laneCount > 0 || groupAutomationLanes(elements).length > 0;
471
+ }
@@ -7,7 +7,6 @@ export const GUTTER = 32;
7
7
  export const LABEL_COL_W = 232;
8
8
  export const TRACK_H = 48;
9
9
  export const LANE_H = 28;
10
- export const STRIP_H = 40; // group bus strip (B7) — one fixed block, not per-property like LANE_H
11
10
  export const RULER_H = 24;
12
11
  export const CLIP_Y = 3;
13
12
  export const CLIP_HANDLE_W = 18;
@@ -4,7 +4,7 @@ import type { ResizingClipState } from "./timelineClipDragTypes";
4
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
5
  import { animationContributesLane } from "./TimelinePropertyLanes";
6
6
 
7
- export function hasKeyframedTimelineClips(
7
+ function hasKeyframedTimelineClips(
8
8
  animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
9
9
  ): boolean {
10
10
  return Array.from(animationsByElement.values()).some((animations) =>
@@ -12,6 +12,35 @@ export function hasKeyframedTimelineClips(
12
12
  );
13
13
  }
14
14
 
15
+ /**
16
+ * Does the timeline need the wide label column?
17
+ *
18
+ * Keyframed clips need it for their property-lane names. Audio GROUPS need it
19
+ * for the same reason a keyframed clip does — a row whose name has nowhere else
20
+ * to go. A track row survives a narrow gutter because its CLIPS carry the name
21
+ * on the bar; a group row has no clips at all, so in the 80px gutter its label
22
+ * rendered at zero width and its FX and lane buttons were clipped off the
23
+ * side.
24
+ *
25
+ * Widening the column for the whole timeline, rather than letting just the
26
+ * group row overhang: the header is sticky and opaque, so an oversized one
27
+ * painted a slab across the rest of its own row, stayed pinned there through
28
+ * horizontal scroll, and had the playhead drawn straight through it.
29
+ */
30
+ export function timelineNeedsLabelColumn(
31
+ animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
32
+ elements: readonly TimelineElement[],
33
+ ): boolean {
34
+ return (
35
+ hasKeyframedTimelineClips(animationsByElement) ||
36
+ // The column exists FOR the group rows, so this must stay in step with
37
+ // whatever decides they are drawn (`useTimelineTrackDerivations`) —
38
+ // widening it with no group row on screen is a 232px shift of every clip
39
+ // with nothing to explain it.
40
+ elements.some((element) => Boolean(element.audioGroup))
41
+ );
42
+ }
43
+
15
44
  export function getEffectiveTimelineDuration(
16
45
  duration: number,
17
46
  elements: readonly TimelineElement[],
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The rows a track header draws BELOW its own line: one per disclosed keyframe
3
+ * property group, one per automation envelope.
4
+ *
5
+ * Split out of `TimelineTrackHeader.tsx`, which owned all of this and stood at
6
+ * 763 lines against the studio's 600-line cap. Nothing else moved: these three
7
+ * components never read the header's state, only their own props, which is why
8
+ * they are the seam.
9
+ */
10
+
11
+ import type { TimelineElement } from "../store/playerStore";
12
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
13
+ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
14
+ import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
15
+ import {
16
+ resolveLaneHeaderState,
17
+ type KeyframeNavigationState,
18
+ type TimelinePropertyLane,
19
+ } from "./trackHeaderLaneState";
20
+ import { valueReadout } from "./trackHeaderLaneValues";
21
+ import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
22
+
23
+ // Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
24
+ function PropertyGroupNavigation({
25
+ navigation,
26
+ label,
27
+ expandedElement,
28
+ onSeek,
29
+ children,
30
+ }: {
31
+ navigation: KeyframeNavigationState;
32
+ label: string;
33
+ expandedElement: TimelineElement;
34
+ onSeek?: (time: number) => void;
35
+ children: React.ReactNode;
36
+ }) {
37
+ // The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
38
+ // target is met with a centered transparent ::before overlay instead of a
39
+ // bigger box; focus-visible matches every other control in this header.
40
+ const CHEVRON_BUTTON_CLASS =
41
+ "relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
42
+ "focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
43
+ "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
44
+ "before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
45
+ const seekTo = (keyframe: { percentage: number } | null) => {
46
+ if (keyframe) {
47
+ onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
48
+ }
49
+ };
50
+ return (
51
+ <span className="flex shrink-0 items-center gap-0.5">
52
+ <button
53
+ type="button"
54
+ aria-label={`Previous ${label} keyframe`}
55
+ disabled={!navigation.prevKeyframe}
56
+ className={CHEVRON_BUTTON_CLASS}
57
+ onClick={(event) => {
58
+ event.stopPropagation();
59
+ seekTo(navigation.prevKeyframe);
60
+ }}
61
+ >
62
+
63
+ </button>
64
+ {children}
65
+ <button
66
+ type="button"
67
+ aria-label={`Next ${label} keyframe`}
68
+ disabled={!navigation.nextKeyframe}
69
+ className={CHEVRON_BUTTON_CLASS}
70
+ onClick={(event) => {
71
+ event.stopPropagation();
72
+ seekTo(navigation.nextKeyframe);
73
+ }}
74
+ >
75
+
76
+ </button>
77
+ </span>
78
+ );
79
+ }
80
+
81
+ export function PropertyGroupHeaderRow({
82
+ lanesId,
83
+ lane,
84
+ laneIndex,
85
+ isLastLane,
86
+ expandedElement,
87
+ currentTime,
88
+ clipPercentage,
89
+ gutterBackground,
90
+ columnWidth,
91
+ onTogglePropertyGroupKeyframe,
92
+ onSeek,
93
+ rovingTargetId = null,
94
+ }: {
95
+ lanesId: string;
96
+ lane: TimelinePropertyLane;
97
+ laneIndex: number;
98
+ isLastLane: boolean;
99
+ expandedElement: TimelineElement;
100
+ currentTime: number;
101
+ clipPercentage: number;
102
+ gutterBackground: string;
103
+ columnWidth: number;
104
+ onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
105
+ onSeek?: (time: number) => void;
106
+ rovingTargetId: string | null;
107
+ }) {
108
+ const elementId = expandedElement.key ?? expandedElement.id;
109
+ const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
110
+ lane,
111
+ currentTime,
112
+ clipPercentage,
113
+ );
114
+
115
+ return (
116
+ <div
117
+ id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
118
+ data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
119
+ data-timeline-element-id={elementId}
120
+ tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
121
+ data-property-group={lane.group}
122
+ data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
123
+ className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
124
+ style={{
125
+ top: getTimelineLaneTop(laneIndex),
126
+ // The header column narrows to contentOrigin whenever that is under
127
+ // LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
128
+ // readout over the canvas, on top of the clips it is labelling.
129
+ width: columnWidth,
130
+ height: LANE_H,
131
+ background: gutterBackground,
132
+ }}
133
+ >
134
+ {/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
135
+ <span className="relative h-full w-3 shrink-0" aria-hidden="true">
136
+ <span
137
+ className="absolute left-1.5 top-0 w-px bg-white/15"
138
+ style={{ height: isLastLane ? "50%" : "100%" }}
139
+ />
140
+ <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
141
+ </span>
142
+ <span className="w-[46px] shrink-0 truncate text-white" title={label}>
143
+ {label}
144
+ </span>
145
+ <PropertyGroupNavigation
146
+ navigation={navigation}
147
+ label={label}
148
+ expandedElement={expandedElement}
149
+ onSeek={onSeek}
150
+ >
151
+ <button
152
+ type="button"
153
+ aria-pressed={!!navigation.currentKeyframe}
154
+ aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
155
+ title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
156
+ // h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
157
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
158
+ onClick={(event) => {
159
+ // Same as the disclosure caret and the eye: a control in the label
160
+ // column owns its click, it does not also hit the track row behind it.
161
+ event.stopPropagation();
162
+ if (toggleTarget) {
163
+ void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
164
+ }
165
+ }}
166
+ >
167
+ {navigation.currentKeyframe ? "◆" : "◇"}
168
+ </button>
169
+ </PropertyGroupNavigation>
170
+ <span
171
+ className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
172
+ title={valueReadout(lane.group, values)}
173
+ >
174
+ {valueReadout(lane.group, values)}
175
+ </span>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ /**
181
+ * One envelope's row in the label column.
182
+ *
183
+ * Named here rather than inside the lane, on the same tree connector the
184
+ * keyframe rows use: an automation lane is a child of its clip exactly as a
185
+ * property group is, and drawing its name over the envelope put the label on top
186
+ * of the curve it describes and scrolled it away from its own row.
187
+ */
188
+ export function AutomationLaneHeaderRow({
189
+ target,
190
+ label,
191
+ name,
192
+ param,
193
+ alsoAutomatedBy,
194
+ top,
195
+ isLastLane,
196
+ gutterBackground,
197
+ columnWidth,
198
+ onRemove,
199
+ isCarve,
200
+ onReveal,
201
+ }: {
202
+ /** The lane the ACTIVE clip draws in this row, or null when it draws none —
203
+ * the row belongs to the property, and a clip may be absent from it. */
204
+ target: string | null;
205
+ /** The whole thing on one line: the row's identity, its tooltip, and the
206
+ * remove button's name. */
207
+ label: string;
208
+ /** What the effect is — "Peaking EQ 1.6 kHz". */
209
+ name: string;
210
+ /** Which knob the envelope drives. Empty when there is no second line to draw. */
211
+ param: string;
212
+ /** Set when this clip's group automates the SAME parameter. Gain stages
213
+ * multiply — 0.42 on the group under 0.80 here plays at 0.34 — so an author
214
+ * who drew one curve and then another hears something quieter than either
215
+ * with nothing on screen to say why (groups doc §5). Not a warning; an
216
+ * explanation. */
217
+ alsoAutomatedBy?: string;
218
+ top: number;
219
+ isLastLane: boolean;
220
+ gutterBackground: string;
221
+ columnWidth: number;
222
+ onRemove?: (target: string) => void;
223
+ /** The carve owns this envelope and rewrites it on every re-run, so it is
224
+ * shown but not the author's to edit or delete. */
225
+ isCarve?: boolean;
226
+ /** Reveal this parameter in the rack. Absent, the name is inert rather than a
227
+ * button that looks live and does nothing. */
228
+ onReveal?: () => void;
229
+ }) {
230
+ return (
231
+ <div
232
+ data-automation-lane-label={label}
233
+ data-timeline-lane-top={top}
234
+ className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
235
+ style={{
236
+ top,
237
+ width: columnWidth,
238
+ height: AUTOMATION_LANE_H,
239
+ background: gutterBackground,
240
+ }}
241
+ >
242
+ {/* Tree connector, as the keyframe rows draw it: spine down the row, branch
243
+ tick at the name's own height. */}
244
+ <span className="relative h-full w-3 shrink-0" aria-hidden="true">
245
+ <span
246
+ className="absolute left-1.5 top-0 w-px bg-white/15"
247
+ style={{ height: isLastLane ? "50%" : "100%" }}
248
+ />
249
+ <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
250
+ </span>
251
+ {/* Two lines: what the effect is, then which knob the envelope drives. On
252
+ one line a band's own name was the first thing truncated in a column this
253
+ narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
254
+ bands apart. */}
255
+ {/* A button, because the name IS the way to the knob: clicking it opens
256
+ the rack on the effect this envelope drives and scrolls to it. Nothing
257
+ else in the timeline can get there — a lane names a parameter, and the
258
+ rack is where a parameter is set. */}
259
+ <button
260
+ type="button"
261
+ tabIndex={-1}
262
+ aria-label={onReveal ? `Show ${label} in the effect rack` : undefined}
263
+ title={onReveal ? `Show ${label} in the effect rack` : label}
264
+ disabled={!onReveal}
265
+ className="flex min-w-0 flex-1 flex-col justify-center rounded border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
266
+ onPointerDown={(event) => event.stopPropagation()}
267
+ onClick={(event) => {
268
+ // The label column owns its click; it does not also fall through to
269
+ // the track row behind it and move the selection.
270
+ event.stopPropagation();
271
+ onReveal?.();
272
+ }}
273
+ >
274
+ <span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
275
+ {name}
276
+ </span>
277
+ {param ? (
278
+ <span
279
+ data-automation-lane-param=""
280
+ className="truncate font-mono text-[9px] text-white/40"
281
+ >
282
+ {param}
283
+ </span>
284
+ ) : null}
285
+ {alsoAutomatedBy ? (
286
+ <span
287
+ data-automation-lane-also=""
288
+ className="truncate text-[9px] text-[#F5C542]/80"
289
+ title={`${alsoAutomatedBy} is also fading this — the two multiply.`}
290
+ >
291
+ {alsoAutomatedBy} is also fading this.
292
+ </span>
293
+ ) : null}
294
+ </button>
295
+ {/* Beside the name it labels, because that is the only place an envelope is
296
+ named at all: a carve writes its own lanes, and the FX panel's automate
297
+ toggle can only reach a parameter it still lists — so without this an
298
+ envelope could be created and never removed.
299
+
300
+ Only for the clip the header is showing, since that is the only one a
301
+ write can reach: a shared row's other envelopes belong to clips that
302
+ are not selected, and a button that silently removed one of them (or
303
+ none) would be worse than no button. */}
304
+ {/* Not on a carve's own lane: the carve regenerates it on the next
305
+ analysis, so removing one band would come back and reads as the
306
+ button not working. Switching the carve off in the rack is what
307
+ removes them, all together, which is how they were made. */}
308
+ {onRemove && target !== null && !isCarve && (
309
+ <button
310
+ type="button"
311
+ aria-label={`Remove ${label} automation`}
312
+ title={`Remove ${label} automation`}
313
+ // h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
314
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
315
+ onPointerDown={(event) => event.stopPropagation()}
316
+ onClick={(event) => {
317
+ // A control in the label column owns its click; it does not also hit
318
+ // the track row behind it and change the selection.
319
+ event.stopPropagation();
320
+ onRemove(target);
321
+ }}
322
+ >
323
+ ×
324
+ </button>
325
+ )}
326
+ </div>
327
+ );
328
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Opening and closing a track's keyframe property lanes, with the telemetry that
3
+ * goes with it.
4
+ *
5
+ * Split out of `TimelineLanes.tsx` to keep that file under the studio's 600-line
6
+ * cap. Both callbacks were already the only place the disclosure state and its
7
+ * `keyframe_lane_expand` event were written together, which is what makes them a
8
+ * seam rather than a shuffle.
9
+ */
10
+
11
+ import { usePlayerStore } from "../store/playerStore";
12
+ import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
13
+
14
+ export interface TimelineClipDisclosure {
15
+ /** The caret belongs to the ROW, so it opens and closes every clip on it at
16
+ * once. Toggling only the active clip left the row's state depending on which
17
+ * sibling happened to be selected: expand one, click another, and the row
18
+ * collapsed under a caret that still pointed down. */
19
+ toggleRowExpanded: (keys: readonly string[]) => void;
20
+ toggleClipExpanded: (key: string) => void;
21
+ }
22
+
23
+ export function useTimelineClipDisclosure(): TimelineClipDisclosure {
24
+ const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
25
+ const expandClips = usePlayerStore((s) => s.expandClips);
26
+ const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
27
+ const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
28
+
29
+ return {
30
+ toggleRowExpanded: (keys) => {
31
+ const willExpand = !keys.some((key) => expandedClipIds.has(key));
32
+ trackStudioKeyframeLaneExpand({ expanded: willExpand });
33
+ if (willExpand) expandClips(keys);
34
+ else for (const key of keys) setClipExpanded(key, false);
35
+ },
36
+ toggleClipExpanded: (key) => {
37
+ trackStudioKeyframeLaneExpand({ expanded: !expandedClipIds.has(key) });
38
+ toggleClipExpanded(key);
39
+ },
40
+ };
41
+ }
@@ -6,7 +6,7 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
6
6
  /** The four pieces of group-disclosure state a group row's header reads and writes. */
7
7
  export function useTimelineGroupDisclosure() {
8
8
  return {
9
- expandedGroupIds: usePlayerStore((s) => s.expandedGroupIds),
9
+ collapsedGroupIds: usePlayerStore((s) => s.collapsedGroupIds),
10
10
  expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
11
11
  toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
12
12
  toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
@@ -35,7 +35,7 @@ interface TimelineLogicalFocusInput {
35
35
 
36
36
  export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
37
37
  const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
38
- const expandedGroupIds = usePlayerStore((state) => state.expandedGroupIds);
38
+ const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds);
39
39
  const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
40
40
  const projectId = usePlayerStore((state) => state.timelineProjectId);
41
41
  const logicalRows = useTimelineLogicalRows({
@@ -45,7 +45,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
45
45
  selectedElementId: input.selectedElementId,
46
46
  selectedElementIds: input.selectedElementIds,
47
47
  expandedClipIds,
48
- expandedGroupIds,
48
+ collapsedGroupIds,
49
49
  expandedLaneOwnerIds,
50
50
  groups: input.groups,
51
51
  trackGroupOf: input.trackGroupOf,
@@ -22,7 +22,7 @@ const displayTrackOrder = tracks.map(([track]) => track);
22
22
  const laneCounts = new Map<string, number>();
23
23
  const selectedElementIds = new Set<string>();
24
24
  const expandedClipIds = new Set<string>();
25
- const expandedGroupIds = new Set<string>();
25
+ const collapsedGroupIds = new Set<string>();
26
26
  const expandedLaneOwnerIds = new Set<string>();
27
27
  const groups: never[] = [];
28
28
  const trackGroupOf = new Map();
@@ -37,7 +37,7 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
37
37
  selectedElementId: null,
38
38
  selectedElementIds,
39
39
  expandedClipIds,
40
- expandedGroupIds,
40
+ collapsedGroupIds,
41
41
  expandedLaneOwnerIds,
42
42
  groups,
43
43
  trackGroupOf,
@@ -14,7 +14,7 @@ export function useTimelineLogicalRows({
14
14
  selectedElementId,
15
15
  selectedElementIds,
16
16
  expandedClipIds,
17
- expandedGroupIds,
17
+ collapsedGroupIds,
18
18
  expandedLaneOwnerIds,
19
19
  groups,
20
20
  trackGroupOf,
@@ -29,7 +29,7 @@ export function useTimelineLogicalRows({
29
29
  selectedElementId,
30
30
  selectedElementIds,
31
31
  expandedClipIds,
32
- expandedGroupIds,
32
+ collapsedGroupIds,
33
33
  expandedLaneOwnerIds,
34
34
  groups,
35
35
  trackGroupOf,
@@ -38,7 +38,7 @@ export function useTimelineLogicalRows({
38
38
  [
39
39
  displayTrackOrder,
40
40
  expandedClipIds,
41
- expandedGroupIds,
41
+ collapsedGroupIds,
42
42
  expandedLaneOwnerIds,
43
43
  groups,
44
44
  trackGroupOf,