@hyperframes/studio 0.8.12 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/parsers": "0.8.12",
51
- "@hyperframes/sdk": "0.8.12",
52
- "@hyperframes/player": "0.8.12",
53
- "@hyperframes/studio-server": "0.8.12",
54
- "@hyperframes/core": "0.8.12"
50
+ "@hyperframes/parsers": "0.8.13",
51
+ "@hyperframes/core": "0.8.13",
52
+ "@hyperframes/studio-server": "0.8.13",
53
+ "@hyperframes/sdk": "0.8.13",
54
+ "@hyperframes/player": "0.8.13"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.12"
71
+ "@hyperframes/producer": "0.8.13"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -1,4 +1,5 @@
1
1
  import type { SelectElementOptions, TimelineElement } from "../player";
2
+ import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
2
3
  import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers";
3
4
  import type { DomEditSelection } from "../components/editor/domEditing";
4
5
  import { logSelect } from "../utils/selectDebug";
@@ -60,9 +61,17 @@ export function announceTimelineSelection(
60
61
  anchor,
61
62
  anchorPublished: anchor != null && publishedMembers.has(anchor),
62
63
  });
63
- // A canvas target can be editable without owning a timeline row. Preserve that
64
- // canvas-only selection when the timeline has nothing truthful to represent.
65
- if (!timelineAnchor) return;
64
+ // A canvas target can be editable without owning a timeline row. Most such
65
+ // targets live inside a clip and must not erase its timeline context. A mixer
66
+ // bus is different: it is itself the editing target, so its title replaces any
67
+ // selected clips even though the bus has no clip row of its own.
68
+ if (!timelineAnchor) {
69
+ if (primary.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
70
+ setTimelineSelectionSet(new Set());
71
+ setSelectedTimelineElementId(null);
72
+ }
73
+ return;
74
+ }
66
75
  // A late async primary that already belongs to the live set must preserve the
67
76
  // group. A fresh single click does not belong to it, so publish the singleton
68
77
  // first; otherwise `preserveSet` clears the set and sync wipes the canvas.
@@ -130,6 +130,121 @@ describe("useDomSelection — Variables tab preservation", () => {
130
130
  });
131
131
  });
132
132
 
133
+ describe("useDomSelection — canvas-only targets replace timeline clips", () => {
134
+ beforeEach(() => {
135
+ deferreds.clear();
136
+ usePlayerStore.getState().clearSelection();
137
+ });
138
+ afterEach(() => {
139
+ deferreds.clear();
140
+ usePlayerStore.getState().clearSelection();
141
+ });
142
+
143
+ it("deselects every clip when an audio bus is selected", () => {
144
+ const store = usePlayerStore.getState();
145
+ store.setSelectedElementId("voice-1");
146
+ store.setSelectedElementIds(new Set(["voice-1", "voice-2"]));
147
+
148
+ const bus = document.createElement("hf-audio-group");
149
+ bus.id = "voiceover";
150
+ const harness = renderHarness({
151
+ rightPanelTab: "design",
152
+ setRightPanelTab: vi.fn(),
153
+ iframe: null,
154
+ timelineElements: [
155
+ { id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 },
156
+ { id: "voice-2", tag: "audio", start: 1, duration: 1, track: 1 },
157
+ ],
158
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
159
+ setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
160
+ });
161
+
162
+ act(() => harness.current().applyDomSelection(makeSelection("Voiceover", bus)));
163
+
164
+ expect(harness.current().domEditSelection?.id).toBe("voiceover");
165
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
166
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
167
+ harness.cleanup();
168
+ });
169
+
170
+ it("lets a bus supersede a clip selection that is still resolving", async () => {
171
+ const iframe = document.createElement("iframe");
172
+ document.body.append(iframe);
173
+ const doc = iframe.contentDocument!;
174
+ const clipNode = doc.createElement("audio");
175
+ clipNode.id = "voice-1";
176
+ const busNode = doc.createElement("hf-audio-group");
177
+ busNode.id = "voiceover";
178
+ doc.body.append(clipNode, busNode);
179
+
180
+ const clip: TimelineElement = {
181
+ id: "voice-1",
182
+ domId: "voice-1",
183
+ tag: "audio",
184
+ start: 0,
185
+ duration: 1,
186
+ track: 0,
187
+ };
188
+ const bus: TimelineElement = {
189
+ id: "voiceover",
190
+ domId: "voiceover",
191
+ tag: "audio",
192
+ start: 0,
193
+ duration: 10,
194
+ track: -0.5,
195
+ };
196
+ const harness = renderHarness({
197
+ rightPanelTab: "design",
198
+ setRightPanelTab: vi.fn(),
199
+ iframe,
200
+ // The bus is a synthetic row target, not a clip in the store.
201
+ timelineElements: [clip],
202
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
203
+ setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
204
+ });
205
+
206
+ let pendingClip = Promise.resolve();
207
+ let pendingBus = Promise.resolve();
208
+ act(() => {
209
+ pendingClip = harness.current().handleTimelineElementSelect(clip);
210
+ pendingBus = harness.current().handleTimelineElementSelect(bus);
211
+ });
212
+ await act(async () => {
213
+ deferreds.get("voiceover")?.resolve();
214
+ await pendingBus;
215
+ deferreds.get("voice-1")?.resolve();
216
+ await pendingClip;
217
+ });
218
+
219
+ expect(harness.current().domEditSelection?.id).toBe("voiceover");
220
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
221
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
222
+ harness.cleanup();
223
+ iframe.remove();
224
+ });
225
+
226
+ it("preserves clip context for a non-bus canvas-only selection", () => {
227
+ const store = usePlayerStore.getState();
228
+ store.setSelectedElementId("voice-1");
229
+ const decoration = document.createElement("div");
230
+ decoration.id = "decoration";
231
+ const harness = renderHarness({
232
+ rightPanelTab: "design",
233
+ setRightPanelTab: vi.fn(),
234
+ iframe: null,
235
+ timelineElements: [{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 }],
236
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
237
+ setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
238
+ });
239
+
240
+ act(() => harness.current().applyDomSelection(makeSelection("Decoration", decoration)));
241
+
242
+ expect(usePlayerStore.getState().selectedElementId).toBe("voice-1");
243
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["voice-1"]));
244
+ harness.cleanup();
245
+ });
246
+ });
247
+
133
248
  describe("useDomSelection — timeline-select race guard", () => {
134
249
  beforeEach(() => deferreds.clear());
135
250
  afterEach(() => deferreds.clear());
@@ -0,0 +1,54 @@
1
+ /** The base automation envelope plus the heavier segment grab affordance. */
2
+
3
+ import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
4
+ import { envelopeSegmentPath } from "./automationLaneGeometry";
5
+
6
+ interface AutomationEnvelopePathsProps {
7
+ path: string;
8
+ lane: HfAutomationLane;
9
+ range: AutomationRange;
10
+ accentColor: string;
11
+ activeSegment: number | null;
12
+ xOf(t: number): number;
13
+ yOf(v: number): number;
14
+ }
15
+
16
+ export function AutomationEnvelopePaths({
17
+ path,
18
+ lane,
19
+ range,
20
+ accentColor,
21
+ activeSegment,
22
+ xOf,
23
+ yOf,
24
+ }: AutomationEnvelopePathsProps) {
25
+ const activePath =
26
+ activeSegment === null
27
+ ? null
28
+ : envelopeSegmentPath({ lane, range, index: activeSegment, xOf, yOf });
29
+
30
+ return (
31
+ <>
32
+ <path
33
+ data-automation-envelope=""
34
+ d={path}
35
+ fill="none"
36
+ stroke={accentColor}
37
+ strokeWidth={1.5}
38
+ opacity={lane.points.length === 0 ? 0.35 : 0.95}
39
+ />
40
+ {activePath ? (
41
+ <path
42
+ data-automation-segment-active={activeSegment ?? undefined}
43
+ d={activePath}
44
+ fill="none"
45
+ stroke={accentColor}
46
+ strokeWidth={3}
47
+ strokeLinecap="round"
48
+ opacity={1}
49
+ pointerEvents="none"
50
+ />
51
+ ) : null}
52
+ </>
53
+ );
54
+ }
@@ -10,6 +10,7 @@ import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
10
10
  import {
11
11
  normalizeAutomation,
12
12
  resolveAutomationRange,
13
+ sampleAutomationLane,
13
14
  VOLUME_RANGE,
14
15
  type HfAutomation,
15
16
  } from "@hyperframes/core/audio-automation";
@@ -668,6 +669,118 @@ describe("TimelineAutomationLane point visibility", () => {
668
669
  });
669
670
  });
670
671
 
672
+ describe("TimelineAutomationLane segment drag", () => {
673
+ const four: HfAutomation = {
674
+ version: 1,
675
+ lanes: [
676
+ {
677
+ target: "volume",
678
+ points: [
679
+ { t: 0, v: 1 },
680
+ { t: 1, v: 0.8 },
681
+ { t: 2, v: 0.6 },
682
+ { t: 3.5, v: 0.2 },
683
+ ],
684
+ },
685
+ ],
686
+ };
687
+
688
+ const previewedPoints = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
689
+ props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as {
690
+ t: number;
691
+ v: number;
692
+ viaX?: number;
693
+ viaY?: number;
694
+ }[];
695
+
696
+ it("thickens the segment and offers a grab cursor only within its hit proximity", () => {
697
+ const { container, svg } = mount(ramp);
698
+ const envelope = container.querySelector<SVGPathElement>("[data-automation-envelope]");
699
+ expect(envelope?.getAttribute("stroke-width")).toBe("1.5");
700
+
701
+ fire(svg, "pointermove", at(2, 0.5));
702
+ const active = container.querySelector<SVGPathElement>("[data-automation-segment-active]");
703
+ expect(active).not.toBeNull();
704
+ expect(active?.getAttribute("stroke-width")).toBe("3");
705
+ expect(svg.style.cursor).toBe("grab");
706
+
707
+ // Same time span, but far enough above the drawn ramp to be background.
708
+ fire(svg, "pointermove", at(2, 0.9));
709
+ expect(container.querySelector("[data-automation-segment-active]")).toBeNull();
710
+ expect(svg.style.cursor).toBe("crosshair");
711
+ });
712
+
713
+ it("moves both segment endpoints by the same time and value delta", () => {
714
+ const { svg, props } = mount(four);
715
+ // Midpoint of the segment from (1, .8) to (2, .6).
716
+ fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
717
+ fire(svg, "pointermove", { ...at(2, 0.5), buttons: 1 });
718
+
719
+ const points = previewedPoints(props);
720
+ expect(points[0]).toEqual({ t: 0, v: 1 });
721
+ expect(points[1]!.t).toBeCloseTo(1.5, 2);
722
+ expect(points[2]!.t).toBeCloseTo(2.5, 2);
723
+ expect(points[1]!.v).toBeCloseTo(0.6, 2);
724
+ expect(points[2]!.v).toBeCloseTo(0.4, 2);
725
+ expect(points[3]).toEqual({ t: 3.5, v: 0.2 });
726
+ });
727
+
728
+ it("treats a press outside the line's proximity as a background range drag", () => {
729
+ const onRangeSelect = vi.fn();
730
+ const { svg, props } = mount(four, { onRangeSelect });
731
+ fire(svg, "pointerdown", { ...at(1.5, 0.95), buttons: 1 });
732
+ fire(svg, "pointermove", { ...at(2.5, 0.95), buttons: 1 });
733
+ expect(onRangeSelect).toHaveBeenCalled();
734
+ expect(props.onPreview).not.toHaveBeenCalled();
735
+ });
736
+
737
+ it("preserves the segment's curve while translating its endpoints", () => {
738
+ const curved: HfAutomation = {
739
+ version: 1,
740
+ lanes: [
741
+ {
742
+ target: "volume",
743
+ points: [
744
+ { t: 0, v: 1 },
745
+ { t: 1, v: 0.8, viaX: 0.4, viaY: 0.7 },
746
+ { t: 2, v: 0.6 },
747
+ { t: 3.5, v: 0.2 },
748
+ ],
749
+ },
750
+ ],
751
+ };
752
+ const { svg, props } = mount(curved);
753
+ const lineValue = sampleAutomationLane(curved.lanes[0]!, 1.7, "linear");
754
+ fire(svg, "pointerdown", { ...at(1.7, lineValue), buttons: 1 });
755
+ fire(svg, "pointermove", { ...at(2.1, lineValue - 0.1), buttons: 1 });
756
+ const points = previewedPoints(props);
757
+ expect(points[1]?.viaX).toBe(0.4);
758
+ expect(points[1]?.viaY).toBe(0.7);
759
+ });
760
+
761
+ it("stops both endpoints together before the next breakpoint", () => {
762
+ const { svg, props } = mount(four);
763
+ fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
764
+ fire(svg, "pointermove", { ...at(4, 0.7), buttons: 1, altKey: true });
765
+ const points = previewedPoints(props);
766
+ expect(points[2]!.t).toBeLessThan(points[3]!.t);
767
+ expect(points[3]!.t - points[2]!.t).toBeCloseTo(0.001, 4);
768
+ expect(points[2]!.t - points[1]!.t).toBeCloseTo(1, 4);
769
+ });
770
+
771
+ it("previews every move and persists the segment once on release", () => {
772
+ const { svg, props } = mount(four);
773
+ fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
774
+ for (const t of [1.7, 1.9, 2.1]) {
775
+ fire(svg, "pointermove", { ...at(t, 0.6), buttons: 1 });
776
+ }
777
+ expect(props.onPreview).toHaveBeenCalledTimes(3);
778
+ expect(props.onCommit).not.toHaveBeenCalled();
779
+ fire(svg, "pointerup", { ...at(2.1, 0.6), buttons: 0 });
780
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
781
+ });
782
+ });
783
+
671
784
  /** `mount`, plus the re-render a real store update causes — the persisted
672
785
  * automation and the new selection coming back down as props. */
673
786
  const mountRerenderable = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
@@ -1,21 +1,15 @@
1
1
  /**
2
2
  * Breakpoint automation over an audio clip, edited the way a DAW edits it:
3
3
  * double-click the line to add a point, drag one to shape it, right-click or
4
- * Shift+click a point to remove it, Alt-drag the line between two points to bend
5
- * it, and double-click a point to type an exact value.
4
+ * Shift+click a point to remove it, drag a line segment to move both endpoints,
5
+ * Alt-drag the line to bend it, and double-click a point to type an exact value.
6
6
  *
7
- * Modifiers follow Ableton's, because that is the muscle memory an automation
8
- * lane inherits: Shift locks a drag to one axis and fines the value down, Alt
9
- * over a segment curves it, and Alt during a point drag ignores the grid.
7
+ * Ableton-style modifiers apply: Shift locks/fines a drag, while Alt bends a
8
+ * segment or ignores the grid during a point drag. Background drags select a
9
+ * set that can be moved, deleted, or shaped together.
10
10
  *
11
- * Drag the background to draw a selection box around a set of breakpoints, then
12
- * Delete to remove them, drag any one of them to move the whole set, or
13
- * right-click inside the box for shapes over its span.
14
- *
15
- * The lane knows nothing about any particular effect. Which parameters it can
16
- * offer, their ranges, units and whether they read logarithmically all come
17
- * from the FX registry, so an effect gained upstream needs no change here — the
18
- * same principle the property panel's controls follow.
11
+ * Effect parameters, ranges, units, and scaling come from the FX registry, so
12
+ * an upstream effect needs no lane-specific code here.
19
13
  */
20
14
 
21
15
  import {
@@ -42,6 +36,7 @@ import { generateShape, type AutomationShapeId } from "./automationShapes";
42
36
  import { simplifyPoints } from "./automationSimplify";
43
37
  import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
44
38
  import { defaultTimelineTheme } from "./timelineTheme";
39
+ import { AutomationEnvelopePaths } from "./AutomationEnvelopePaths";
45
40
 
46
41
  /**
47
42
  * Drawn radius of a breakpoint.
@@ -102,7 +97,7 @@ function pointCircleStyle(
102
97
  function laneTitle(readOnly: boolean | undefined): string {
103
98
  return readOnly
104
99
  ? "Drag a box to select points, which also selects this clip; then double-click to add a point"
105
- : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
100
+ : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag a line segment to move both endpoints. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
106
101
  }
107
102
 
108
103
  /**
@@ -155,13 +150,19 @@ function pointHandleOpacity(args: {
155
150
  return !args.readOnly && args.hovered ? 1 : 0;
156
151
  }
157
152
 
158
- function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
153
+ function laneCursor(
154
+ readOnly: boolean | undefined,
155
+ dragging: boolean,
156
+ stretching: boolean,
157
+ segmentHovering: boolean,
158
+ ): string {
159
159
  // A stretch handle wins over everything it might also sit above: the handle is
160
160
  // a few px wide and always overlaps whatever is under the selection edge, so
161
161
  // any other cursor there would advertise a gesture the press will not start.
162
162
  if (stretching) return "col-resize";
163
163
  if (readOnly) return "pointer";
164
- return dragging ? "grabbing" : "crosshair";
164
+ if (dragging) return "grabbing";
165
+ return segmentHovering ? "grab" : "crosshair";
165
166
  }
166
167
 
167
168
  export interface TimelineAutomationLaneProps {
@@ -318,7 +319,16 @@ export function TimelineAutomationLane({
318
319
  duration,
319
320
  rangeSelection,
320
321
  });
321
- const { dragIndex, curveIndex, edgeDrag, edgeHover, hint, editing } = gestures;
322
+ const {
323
+ dragIndex,
324
+ curveIndex,
325
+ segmentDragIndex,
326
+ segmentHoverIndex,
327
+ edgeDrag,
328
+ edgeHover,
329
+ hint,
330
+ editing,
331
+ } = gestures;
322
332
 
323
333
  const removeAt = useCallback(
324
334
  (index: number): void => {
@@ -426,8 +436,9 @@ export function TimelineAutomationLane({
426
436
  height: h,
427
437
  cursor: laneCursor(
428
438
  readOnly,
429
- dragIndex !== null || curveIndex !== null,
439
+ dragIndex !== null || curveIndex !== null || segmentDragIndex !== null,
430
440
  edgeDrag !== null || edgeHover,
441
+ segmentHoverIndex !== null,
431
442
  ),
432
443
  opacity: readOnly ? 0.55 : 1,
433
444
  touchAction: "none",
@@ -435,7 +446,10 @@ export function TimelineAutomationLane({
435
446
  width={widthPx + PAD_X * 2}
436
447
  height={h}
437
448
  onPointerEnter={() => setHovered(true)}
438
- onPointerLeave={() => setHovered(false)}
449
+ onPointerLeave={() => {
450
+ setHovered(false);
451
+ gestures.onPointerLeave();
452
+ }}
439
453
  onPointerDown={gestures.onPointerDown}
440
454
  onPointerMove={gestures.onPointerMove}
441
455
  onPointerUp={gestures.endDrag}
@@ -478,12 +492,14 @@ export function TimelineAutomationLane({
478
492
  pointerEvents="none"
479
493
  />
480
494
  ) : null}
481
- <path
482
- d={path}
483
- fill="none"
484
- stroke={accentColor}
485
- strokeWidth={1.5}
486
- opacity={lane.points.length === 0 ? 0.35 : 0.95}
495
+ <AutomationEnvelopePaths
496
+ path={path}
497
+ lane={lane}
498
+ range={range}
499
+ accentColor={accentColor}
500
+ activeSegment={segmentDragIndex ?? segmentHoverIndex}
501
+ xOf={xOf}
502
+ yOf={yOf}
487
503
  />
488
504
  {lane.points.map((p, i) => {
489
505
  // Endpoint-inclusive, the same rule Delete uses, so what looks caught by
@@ -25,6 +25,7 @@ export function TimelineGroupLaneLabels({
25
25
  columnWidth,
26
26
  gutterBackground,
27
27
  accentColor,
28
+ onReveal,
28
29
  }: {
29
30
  /** The group wearing a clip's shape — see `groupAutomationElement`. */
30
31
  groupElement: TimelineElement;
@@ -34,6 +35,8 @@ export function TimelineGroupLaneLabels({
34
35
  columnWidth: number;
35
36
  gutterBackground: string;
36
37
  accentColor: string;
38
+ /** Select the bus and reveal this lane's exact parameter in its rack. */
39
+ onReveal?: (target: string) => void;
37
40
  }) {
38
41
  // The LIVE playhead, not the row's `currentTime` prop — that one only moves
39
42
  // on seek, so the readout sat frozen while the curve was audibly working,
@@ -55,10 +58,13 @@ export function TimelineGroupLaneLabels({
55
58
  // clip-local rebase here — unlike a clip's lane.
56
59
  const value = sampleAutomationLane(lane, currentTime);
57
60
  return (
58
- <div
61
+ <button
62
+ type="button"
63
+ tabIndex={-1}
59
64
  key={lane.target}
60
65
  data-group-lane-label={lane.target}
61
- className="absolute left-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[10px] text-white/65"
66
+ aria-label={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
67
+ className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-white/65 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
62
68
  style={{
63
69
  top: top + index * AUTOMATION_LANE_H,
64
70
  width: columnWidth,
@@ -67,6 +73,11 @@ export function TimelineGroupLaneLabels({
67
73
  borderLeft: `2px solid ${accentColor}`,
68
74
  }}
69
75
  title={`${groupLabel} · ${parts.param ? `${parts.name} · ${parts.param}` : parts.name}`}
76
+ onPointerDown={(event) => event.stopPropagation()}
77
+ onClick={(event) => {
78
+ event.stopPropagation();
79
+ onReveal?.(lane.target);
80
+ }}
70
81
  >
71
82
  <span aria-hidden="true" className="shrink-0 text-[11px] text-white/40">
72
83
 
@@ -80,7 +91,7 @@ export function TimelineGroupLaneLabels({
80
91
  <span className="shrink-0 font-mono text-[9px] tabular-nums text-white/55">
81
92
  {value.toFixed(2)}
82
93
  </span>
83
- </div>
94
+ </button>
84
95
  );
85
96
  })}
86
97
  </>
@@ -6,14 +6,23 @@ import { TimelineGroupRow } from "./TimelineGroupRow";
6
6
  import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
7
7
  import { defaultTimelineTheme } from "./timelineTheme";
8
8
  import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
9
- import type { TimelineElement } from "../store/playerStore";
9
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
10
10
 
11
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
12
 
13
13
  vi.mock("../../telemetry/canary", () => ({ isCanaryEnabled: () => true }));
14
+ const domEditMocks = vi.hoisted(() => ({
15
+ handleTimelineElementSelect: vi.fn(async () => undefined),
16
+ }));
17
+ vi.mock("../../contexts/DomEditContext", () => ({
18
+ useDomEditSelectionContextOptional: () => null,
19
+ useDomEditActionsContextOptional: () => domEditMocks,
20
+ }));
14
21
 
15
22
  afterEach(() => {
16
23
  document.body.innerHTML = "";
24
+ domEditMocks.handleTimelineElementSelect.mockClear();
25
+ usePlayerStore.setState({ revealedAudioFxTarget: null });
17
26
  });
18
27
 
19
28
  const member = (id: string, track: number): TimelineElement => ({
@@ -36,7 +45,10 @@ const GROUP: TimelineTrackGroupInfo = {
36
45
  hidden: false,
37
46
  };
38
47
 
39
- function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
48
+ function renderRow(
49
+ overrides: Partial<TimelineTrackGroupInfo> = {},
50
+ expandedLaneOwnerIds = new Set<string>(),
51
+ ) {
40
52
  const onSetAudioGroupAttributeQuiet = vi.fn();
41
53
  const onSetElementAttributeQuiet = vi.fn();
42
54
  const host = document.createElement("div");
@@ -55,10 +67,31 @@ function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
55
67
  contentOrigin={232}
56
68
  theme={defaultTimelineTheme}
57
69
  collapsedGroupIds={new Set()}
58
- expandedLaneOwnerIds={new Set()}
70
+ expandedLaneOwnerIds={expandedLaneOwnerIds}
59
71
  toggleGroupExpanded={vi.fn()}
60
72
  toggleLaneOwnerExpanded={vi.fn()}
61
- lanes={{ bind: () => ({ lanes: [] }) } as never}
73
+ lanes={
74
+ {
75
+ bind: (element: TimelineElement) => {
76
+ const automation = element.automation
77
+ ? JSON.parse(element.automation)
78
+ : { version: 1, lanes: [] };
79
+ return {
80
+ automation,
81
+ lanes: automation.lanes,
82
+ chain: element.fxChain ? JSON.parse(element.fxChain) : null,
83
+ onPreview: vi.fn(),
84
+ onCommit: vi.fn(),
85
+ onSelect: vi.fn(),
86
+ readOnly: true,
87
+ commitTargetKey: null,
88
+ selection: null,
89
+ onRangeSelect: vi.fn(),
90
+ onRangeClear: vi.fn(),
91
+ };
92
+ },
93
+ } as never
94
+ }
62
95
  pps={10}
63
96
  currentTime={0}
64
97
  compositionDuration={60}
@@ -72,6 +105,49 @@ function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
72
105
  }
73
106
 
74
107
  describe("TimelineGroupRow", () => {
108
+ it("routes the group title through the guarded selection path", () => {
109
+ const { host } = renderRow();
110
+ const title = host.querySelector<HTMLButtonElement>(
111
+ 'button[aria-label="Open Voiceover effects"]',
112
+ );
113
+
114
+ act(() => title?.click());
115
+
116
+ expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith(
117
+ expect.objectContaining({ id: "voiceover", domId: "voiceover" }),
118
+ );
119
+ });
120
+
121
+ it("opens a group automation lane on its exact rack parameter", async () => {
122
+ const { host } = renderRow(
123
+ {
124
+ fxChain: JSON.stringify({
125
+ version: 1,
126
+ nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
127
+ }),
128
+ automation: JSON.stringify({
129
+ version: 1,
130
+ lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
131
+ }),
132
+ },
133
+ new Set(["voiceover"]),
134
+ );
135
+ const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
136
+
137
+ await act(async () => {
138
+ laneTitle?.click();
139
+ await Promise.resolve();
140
+ });
141
+
142
+ expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith(
143
+ expect.objectContaining({ id: "voiceover", domId: "voiceover" }),
144
+ );
145
+ expect(usePlayerStore.getState().revealedAudioFxTarget).toMatchObject({
146
+ elementKey: "voiceover",
147
+ automationTarget: "fx.p1.gain",
148
+ });
149
+ });
150
+
75
151
  // C1 names this as the step's own definition of done: "opening the popover on
76
152
  // a GROUP and applying a preset results in exactly ONE `data-fx-chain` write,
77
153
  // on the group element, and zero writes on members". A group IS a bus — a