diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
index 94da3b674..d1b9849c3 100644
--- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
@@ -1747,3 +1747,70 @@ describe("TimelineAutomationLane stretch", () => {
expect(svg.style.cursor).not.toBe("col-resize");
});
});
+
+describe("TimelineAutomationLane — a read-only lane offers nothing to grab", () => {
+ /** Hover the lane the way a pointer entering it does. */
+ function hover(container: HTMLElement): SVGSVGElement {
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ // React implements onPointerEnter through the delegated `pointerover`
+ // event, not a native `pointerenter` — which does not bubble and so never
+ // reaches its listener.
+ fire(svg, "pointerover");
+ return svg as SVGSVGElement;
+ }
+
+ const handles = (container: HTMLElement) =>
+ [...container.querySelectorAll("circle[data-automation-point]")] as SVGCircleElement[];
+
+ // A grab handle raised on hover is an offer, and a carve lane cannot honour
+ // it: the analysis rewrites these envelopes on every run, so a point moved
+ // here is discarded rather than saved. Dimming alone did not say that — the
+ // handles still came up under the cursor and the drag silently did nothing.
+ it("keeps its point handles hidden on hover", () => {
+ const { container } = render(
+ ,
+ );
+ hover(container);
+ const drawn = handles(container);
+ expect(drawn.length).toBeGreaterThan(0);
+ for (const c of drawn) {
+ expect(c.style.opacity).toBe("0");
+ expect(c.style.cursor).toBe("default");
+ }
+ });
+
+ it("still raises them on an editable lane, so the gate is readOnly and not hover", () => {
+ const { container } = render();
+ hover(container);
+ const drawn = handles(container);
+ expect(drawn.length).toBeGreaterThan(0);
+ for (const c of drawn) {
+ expect(c.style.opacity).toBe("1");
+ expect(c.style.cursor).toBe("grab");
+ }
+ });
+
+ it("says why, in the lane, once hovered", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
+ hover(container);
+ expect(container.querySelector("[data-automation-readonly-note]")?.textContent).toContain(
+ "Owned by the carve.",
+ );
+ });
+
+ it("says nothing when no reason was given", () => {
+ const { container } = render(
+ ,
+ );
+ hover(container);
+ expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
+ });
+});
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx
index 061fea8e4..eb8dfa2c7 100644
--- a/packages/studio/src/player/components/TimelineAutomationLane.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx
@@ -86,6 +86,75 @@ function pointCircleStyle(
}
/** Pointer shape: a read-only lane can only be selected, a live one edited. */
+/**
+ * Whether a point's grab handle is up.
+ *
+ * A raised handle is an offer to drag, so a read-only lane keeps them down: the
+ * carve rewrites its envelopes from the analysis on every run, and a point
+ * moved here is discarded rather than saved. Hidden rather than unmounted
+ * either way, so the hit area survives — a point dragged past the lane's edge
+ * fires pointerleave mid-gesture, and a handle that vanished then would drop
+ * the drag. A live drag and a selected range both keep them up regardless: the
+ * range is the subject of a pending Delete, and which points it caught cannot
+ * depend on where the mouse is.
+ */
+/** The svg's tooltip: what this lane's gestures actually are. */
+function laneTitle(readOnly: boolean | undefined): string {
+ return readOnly
+ ? "Drag a box to select points, which also selects this clip; then double-click to add a point"
+ : "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.";
+}
+
+/**
+ * Why this lane will not take an edit, in the lane itself.
+ *
+ * Dimming and a default cursor say "not editable" but not WHY, and the why is
+ * the part that matters: the carve rewrites these envelopes from its own
+ * analysis on every run, so a point moved here is discarded rather than saved.
+ * Only while hovered, so a stack of read-only lanes is not a stack of notices,
+ * and never over a selection the author is about to act on.
+ *
+ * Separate from the gesture `hint` slot, which belongs to handlers that do not
+ * run on a read-only lane at all.
+ */
+function ReadOnlyNote({
+ readOnly,
+ note,
+ hovered,
+ hasRange,
+ leftPx,
+ widthPx,
+}: {
+ readOnly: boolean | undefined;
+ note: string | undefined;
+ hovered: boolean;
+ /** A selection is the subject of a pending action; do not cover it. */
+ hasRange: boolean;
+ leftPx: number;
+ widthPx: number;
+}) {
+ if (!readOnly || !note || !hovered || hasRange) return null;
+ return (
+
+ {note}
+
+ );
+}
+
+function pointHandleOpacity(args: {
+ readOnly: boolean | undefined;
+ hovered: boolean;
+ dragging: boolean;
+ hasRange: boolean;
+}): number {
+ if (args.dragging || args.hasRange) return 1;
+ return !args.readOnly && args.hovered ? 1 : 0;
+}
+
function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
// A stretch handle wins over everything it might also sit above: the handle is
// a few px wide and always overlaps whatever is under the selection edge, so
@@ -120,6 +189,9 @@ export interface TimelineAutomationLaneProps {
snapTimes?: readonly number[];
/** Editing writes to the selected element, so an unselected clip is read-only. */
readOnly?: boolean;
+ /** Why `readOnly`, shown in the lane on hover. Without it the lane only looks
+ * disabled; the author still has to guess what would let them edit it. */
+ readOnlyNote?: string;
/** Called when a read-only lane is pressed: selects the clip so it goes live. */
onSelect?(): void;
/** Active selection box on THIS lane, or null. */
@@ -142,6 +214,7 @@ export function TimelineAutomationLane({
onCommit,
snapTimes,
readOnly,
+ readOnlyNote,
onSelect,
rangeSelection,
onRangeSelect,
@@ -372,11 +445,7 @@ export function TimelineAutomationLane({
role="group"
aria-label={`${range.label} automation`}
>
-
- {readOnly
- ? "Drag a box to select points, which also selects this clip; then double-click to add a point"
- : "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."}
-
+ {laneTitle(readOnly)}
{/* No plate behind the envelope: the lane used to darken its clip's width,
which drew a box inside the row and made a stack of lanes read as tiles
rather than as rows of one timeline. The row background shows through, and
@@ -433,15 +502,14 @@ export function TimelineAutomationLane({
fill={accentColor}
stroke={stroke}
strokeWidth={strokeWidth}
- // Hidden rather than unmounted, so the hit area survives: a point
- // dragged past the lane's edge fires pointerleave mid-gesture, and a
- // handle that vanishes then would drop the drag. A drag in progress
- // and a selected range both keep them up for the same reason — the
- // range is the subject of a pending Delete, and which points it
- // caught cannot depend on where the mouse is.
style={{
cursor: readOnly ? "default" : "grab",
- opacity: hovered || dragIndex !== null || rangeSelection ? 1 : 0,
+ opacity: pointHandleOpacity({
+ readOnly,
+ hovered,
+ dragging: dragIndex !== null,
+ hasRange: Boolean(rangeSelection),
+ }),
}}
onContextMenu={(e) => {
e.preventDefault();
@@ -484,6 +552,15 @@ export function TimelineAutomationLane({
) : null}
+
+
{menuAt && rangeSelection ? (
- toggleGroupExpanded(group.id)}
- // The GROUP's own lanes, not its members'. `∿` is per-row (groups doc
- // §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and
- // counting the members' here made the group advertise curves it does
- // not own and cannot show.
- laneCount={groupAutomationLanes([groupElement]).length}
- isLaneOpen={isLaneOpen}
- onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
- fxChain={group.fxChain}
- onFxChainChange={(next) => writeGroupFxChain(next, false)}
- onFxChainPreview={(next) => writeGroupFxChain(next, true)}
- auditionSpans={memberElements}
- onOpenFxRack={openGroupFxRack}
- // Same width as every other row's header. The group row needs a real
- // label column, but it gets one by turning `labelMode` on for the whole
- // timeline (see Timeline.tsx) rather than by overhanging alone — an
- // overhanging header paints opaquely across the rest of its row and
- // stays pinned there through horizontal scroll.
- columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
- theme={theme}
- />
- {/* The group's OWN curves, under the strip. Selected-gated exactly like a
+ {/* Header and its lane labels in ONE sticky column — the shape
+ `TimelineTrackHeader` already uses: a fixed TRACK_H line box with the
+ lane rows absolutely positioned beneath it, the whole thing pinned.
+ The labels used to be SIBLINGS of the header, so `absolute left-0`
+ resolved against the ROW, and the row is what scrolls horizontally —
+ they slid away with the canvas. Sticky lives here rather than on the
+ header, which keeps its own box inside; a zero-width sticky wrapper
+ around the labels alone does not work either, because as a flex item
+ after the header it starts at x = columnWidth, i.e. inside the lanes. */}
+ = LABEL_COL_W ? LABEL_COL_W : contentOrigin }}
+ >
+ toggleGroupExpanded(group.id)}
+ // The GROUP's own lanes, not its members'. `∿` is per-row (groups doc
+ // §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and
+ // counting the members' here made the group advertise curves it does
+ // not own and cannot show.
+ laneCount={groupAutomationLanes([groupElement]).length}
+ isLaneOpen={isLaneOpen}
+ onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
+ fxChain={group.fxChain}
+ onFxChainChange={(next) => writeGroupFxChain(next, false)}
+ onFxChainPreview={(next) => writeGroupFxChain(next, true)}
+ auditionSpans={memberElements}
+ onOpenFxRack={openGroupFxRack}
+ // Same width as every other row's header. The group row needs a real
+ // label column, but it gets one by turning `labelMode` on for the whole
+ // timeline (see Timeline.tsx) rather than by overhanging alone — an
+ // overhanging header paints opaquely across the rest of its row and
+ // stays pinned there through horizontal scroll.
+ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
+ theme={theme}
+ />
+ {/* The group's OWN curves, under the strip. Selected-gated exactly like a
clip's: the binder writes through the dom-edit selection, so a lane is
editable once the group is selected — which clicking its name does. */}
- {/* The label column for those lanes, on the accent rail. Outside the
- offset content cell below, because the labels belong to the sticky
- gutter the row header occupies, not to the scrolling canvas. */}
- {isLaneOpen && (
- = LABEL_COL_W ? LABEL_COL_W : contentOrigin}
- gutterBackground={theme.gutterBackground}
- accentColor={GROUP_LANE_ACCENT}
- />
- )}
+ {/* The label column for those lanes, on the accent rail — inside the
+ sticky column above, so they pin with the header. */}
+ {isLaneOpen && (
+ = LABEL_COL_W ? LABEL_COL_W : contentOrigin}
+ gutterBackground={theme.gutterBackground}
+ accentColor={GROUP_LANE_ACCENT}
+ />
+ )}
+
{isLaneOpen && (
// The same offset content cell a track row wraps its lanes in — the
// slot positions absolutely, so mounted straight on the row it resolved