diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx
index 2aa0a0794..8a9e3446e 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx
@@ -19,16 +19,10 @@ import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationL
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { clipTimingStart } from "../../hooks/gsapShared";
import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow";
-import { LABEL_COL_W, LANE_H, TRACK_H, getTimelineLaneTop } from "./timelineLayout";
+import { LABEL_COL_W, TRACK_H, getTimelineLaneTop } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
-import {
- resolveLaneHeaderState,
- type KeyframeNavigationState,
- type TimelinePropertyLane,
-} from "./trackHeaderLaneState";
-import { valueReadout } from "./trackHeaderLaneValues";
import { trackDisplaySuffix } from "./timelineTrackDisplay";
-import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
+import { AutomationLaneHeaderRow, PropertyGroupHeaderRow } from "./trackHeaderLabelRows";
/** Accent rail + inset marking a row as a group MEMBER, matching the level-2
* nesting its `aria-level` already reports. */
@@ -89,313 +83,6 @@ interface TimelineTrackHeaderProps {
onSeek?: (time: number) => void;
}
-// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
-function PropertyGroupNavigation({
- navigation,
- label,
- expandedElement,
- onSeek,
- children,
-}: {
- navigation: KeyframeNavigationState;
- label: string;
- expandedElement: TimelineElement;
- onSeek?: (time: number) => void;
- children: React.ReactNode;
-}) {
- // The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
- // target is met with a centered transparent ::before overlay instead of a
- // bigger box; focus-visible matches every other control in this header.
- const CHEVRON_BUTTON_CLASS =
- "relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
- "focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
- "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
- "before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
- const seekTo = (keyframe: { percentage: number } | null) => {
- if (keyframe) {
- onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
- }
- };
- return (
-
-
- {children}
-
-
- );
-}
-
-function PropertyGroupHeaderRow({
- lanesId,
- lane,
- laneIndex,
- isLastLane,
- expandedElement,
- currentTime,
- clipPercentage,
- gutterBackground,
- columnWidth,
- onTogglePropertyGroupKeyframe,
- onSeek,
- rovingTargetId = null,
-}: {
- lanesId: string;
- lane: TimelinePropertyLane;
- laneIndex: number;
- isLastLane: boolean;
- expandedElement: TimelineElement;
- currentTime: number;
- clipPercentage: number;
- gutterBackground: string;
- columnWidth: number;
- onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
- onSeek?: (time: number) => void;
- rovingTargetId: string | null;
-}) {
- const elementId = expandedElement.key ?? expandedElement.id;
- const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
- lane,
- currentTime,
- clipPercentage,
- );
-
- return (
-
- {/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
-
-
-
-
-
- {label}
-
-
-
-
-
- {valueReadout(lane.group, values)}
-
-
- );
-}
-
-/**
- * One envelope's row in the label column.
- *
- * Named here rather than inside the lane, on the same tree connector the
- * keyframe rows use: an automation lane is a child of its clip exactly as a
- * property group is, and drawing its name over the envelope put the label on top
- * of the curve it describes and scrolled it away from its own row.
- */
-function AutomationLaneHeaderRow({
- target,
- label,
- name,
- param,
- alsoAutomatedBy,
- top,
- isLastLane,
- gutterBackground,
- columnWidth,
- onRemove,
- isCarve,
- onReveal,
-}: {
- /** The lane the ACTIVE clip draws in this row, or null when it draws none —
- * the row belongs to the property, and a clip may be absent from it. */
- target: string | null;
- /** The whole thing on one line: the row's identity, its tooltip, and the
- * remove button's name. */
- label: string;
- /** What the effect is — "Peaking EQ 1.6 kHz". */
- name: string;
- /** Which knob the envelope drives. Empty when there is no second line to draw. */
- param: string;
- /** Set when this clip's group automates the SAME parameter. Gain stages
- * multiply — 0.42 on the group under 0.80 here plays at 0.34 — so an author
- * who drew one curve and then another hears something quieter than either
- * with nothing on screen to say why (groups doc §5). Not a warning; an
- * explanation. */
- alsoAutomatedBy?: string;
- top: number;
- isLastLane: boolean;
- gutterBackground: string;
- columnWidth: number;
- onRemove?: (target: string) => void;
- /** The carve owns this envelope and rewrites it on every re-run, so it is
- * shown but not the author's to edit or delete. */
- isCarve?: boolean;
- /** Reveal this parameter in the rack. Absent, the name is inert rather than a
- * button that looks live and does nothing. */
- onReveal?: () => void;
-}) {
- return (
-
- {/* Tree connector, as the keyframe rows draw it: spine down the row, branch
- tick at the name's own height. */}
-
-
-
-
- {/* Two lines: what the effect is, then which knob the envelope drives. On
- one line a band's own name was the first thing truncated in a column this
- narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
- bands apart. */}
- {/* A button, because the name IS the way to the knob: clicking it opens
- the rack on the effect this envelope drives and scrolls to it. Nothing
- else in the timeline can get there — a lane names a parameter, and the
- rack is where a parameter is set. */}
-
- {/* Beside the name it labels, because that is the only place an envelope is
- named at all: a carve writes its own lanes, and the FX panel's automate
- toggle can only reach a parameter it still lists — so without this an
- envelope could be created and never removed.
-
- Only for the clip the header is showing, since that is the only one a
- write can reach: a shared row's other envelopes belong to clips that
- are not selected, and a button that silently removed one of them (or
- none) would be worse than no button. */}
- {/* Not on a carve's own lane: the carve regenerates it on the next
- analysis, so removing one band would come back and reads as the
- button not working. Switching the carve off in the rack is what
- removes them, all together, which is how they were made. */}
- {onRemove && target !== null && !isCarve && (
-
- )}
-
- );
-}
-
// fallow-ignore-next-line complexity
export function TimelineTrackHeader({
trackNumber,
diff --git a/packages/studio/src/player/components/trackHeaderLabelRows.tsx b/packages/studio/src/player/components/trackHeaderLabelRows.tsx
new file mode 100644
index 000000000..55e606a62
--- /dev/null
+++ b/packages/studio/src/player/components/trackHeaderLabelRows.tsx
@@ -0,0 +1,328 @@
+/**
+ * The rows a track header draws BELOW its own line: one per disclosed keyframe
+ * property group, one per automation envelope.
+ *
+ * Split out of `TimelineTrackHeader.tsx`, which owned all of this and stood at
+ * 763 lines against the studio's 600-line cap. Nothing else moved: these three
+ * components never read the header's state, only their own props, which is why
+ * they are the seam.
+ */
+
+import type { TimelineElement } from "../store/playerStore";
+import type { TimelineEditCallbacks } from "./timelineCallbacks";
+import { AUTOMATION_LANE_H } from "./automationLaneHeight";
+import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
+import {
+ resolveLaneHeaderState,
+ type KeyframeNavigationState,
+ type TimelinePropertyLane,
+} from "./trackHeaderLaneState";
+import { valueReadout } from "./trackHeaderLaneValues";
+import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
+
+// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
+function PropertyGroupNavigation({
+ navigation,
+ label,
+ expandedElement,
+ onSeek,
+ children,
+}: {
+ navigation: KeyframeNavigationState;
+ label: string;
+ expandedElement: TimelineElement;
+ onSeek?: (time: number) => void;
+ children: React.ReactNode;
+}) {
+ // The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
+ // target is met with a centered transparent ::before overlay instead of a
+ // bigger box; focus-visible matches every other control in this header.
+ const CHEVRON_BUTTON_CLASS =
+ "relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
+ "focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
+ "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
+ "before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
+ const seekTo = (keyframe: { percentage: number } | null) => {
+ if (keyframe) {
+ onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
+ }
+ };
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function PropertyGroupHeaderRow({
+ lanesId,
+ lane,
+ laneIndex,
+ isLastLane,
+ expandedElement,
+ currentTime,
+ clipPercentage,
+ gutterBackground,
+ columnWidth,
+ onTogglePropertyGroupKeyframe,
+ onSeek,
+ rovingTargetId = null,
+}: {
+ lanesId: string;
+ lane: TimelinePropertyLane;
+ laneIndex: number;
+ isLastLane: boolean;
+ expandedElement: TimelineElement;
+ currentTime: number;
+ clipPercentage: number;
+ gutterBackground: string;
+ columnWidth: number;
+ onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
+ onSeek?: (time: number) => void;
+ rovingTargetId: string | null;
+}) {
+ const elementId = expandedElement.key ?? expandedElement.id;
+ const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
+ lane,
+ currentTime,
+ clipPercentage,
+ );
+
+ return (
+
+ {/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
+
+
+
+
+
+ {label}
+
+
+
+
+
+ {valueReadout(lane.group, values)}
+
+
+ );
+}
+
+/**
+ * One envelope's row in the label column.
+ *
+ * Named here rather than inside the lane, on the same tree connector the
+ * keyframe rows use: an automation lane is a child of its clip exactly as a
+ * property group is, and drawing its name over the envelope put the label on top
+ * of the curve it describes and scrolled it away from its own row.
+ */
+export function AutomationLaneHeaderRow({
+ target,
+ label,
+ name,
+ param,
+ alsoAutomatedBy,
+ top,
+ isLastLane,
+ gutterBackground,
+ columnWidth,
+ onRemove,
+ isCarve,
+ onReveal,
+}: {
+ /** The lane the ACTIVE clip draws in this row, or null when it draws none —
+ * the row belongs to the property, and a clip may be absent from it. */
+ target: string | null;
+ /** The whole thing on one line: the row's identity, its tooltip, and the
+ * remove button's name. */
+ label: string;
+ /** What the effect is — "Peaking EQ 1.6 kHz". */
+ name: string;
+ /** Which knob the envelope drives. Empty when there is no second line to draw. */
+ param: string;
+ /** Set when this clip's group automates the SAME parameter. Gain stages
+ * multiply — 0.42 on the group under 0.80 here plays at 0.34 — so an author
+ * who drew one curve and then another hears something quieter than either
+ * with nothing on screen to say why (groups doc §5). Not a warning; an
+ * explanation. */
+ alsoAutomatedBy?: string;
+ top: number;
+ isLastLane: boolean;
+ gutterBackground: string;
+ columnWidth: number;
+ onRemove?: (target: string) => void;
+ /** The carve owns this envelope and rewrites it on every re-run, so it is
+ * shown but not the author's to edit or delete. */
+ isCarve?: boolean;
+ /** Reveal this parameter in the rack. Absent, the name is inert rather than a
+ * button that looks live and does nothing. */
+ onReveal?: () => void;
+}) {
+ return (
+
+ {/* Tree connector, as the keyframe rows draw it: spine down the row, branch
+ tick at the name's own height. */}
+
+
+
+
+ {/* Two lines: what the effect is, then which knob the envelope drives. On
+ one line a band's own name was the first thing truncated in a column this
+ narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
+ bands apart. */}
+ {/* A button, because the name IS the way to the knob: clicking it opens
+ the rack on the effect this envelope drives and scrolls to it. Nothing
+ else in the timeline can get there — a lane names a parameter, and the
+ rack is where a parameter is set. */}
+
+ {/* Beside the name it labels, because that is the only place an envelope is
+ named at all: a carve writes its own lanes, and the FX panel's automate
+ toggle can only reach a parameter it still lists — so without this an
+ envelope could be created and never removed.
+
+ Only for the clip the header is showing, since that is the only one a
+ write can reach: a shared row's other envelopes belong to clips that
+ are not selected, and a button that silently removed one of them (or
+ none) would be worse than no button. */}
+ {/* Not on a carve's own lane: the carve regenerates it on the next
+ analysis, so removing one band would come back and reads as the
+ button not working. Switching the carve off in the rack is what
+ removes them, all together, which is how they were made. */}
+ {onRemove && target !== null && !isCarve && (
+
+ )}
+