Files
hyperframes/packages/studio/src/player/components/TimelineTrackHeader.tsx
T
Vance Ingalls 43639eda43 fix(studio): restore the audition's seek, in the place both racks share
I removed this in 6d46547e3 on a spec-purity argument — runbook C1 §2 says the
timeline shelf renders "exactly as FxSection renders it — same props", and
FxSection passed no spans. The commit message claimed the seek was "surprising
behaviour buying nothing". That was wrong, and I did not re-check the browser
before asserting it.

Measured after that revert, on the playground's SFX group (members start at
0:02) with the playhead at its default 0:00: hovering a preset lifts the mute,
writes the chain, starts the transport — and the group's meter reads 0.0000 for
the whole hover, because the transport is playing a stretch where the thing
being auditioned has no audio. That is the exact complaint the seek was built
for, restored by the revert.

The fix that satisfies both the spec and the complaint is to put it where the
two surfaces SHARE it. The property panel's rack has the identical hole — hover
a preset there with the playhead outside the clip and it is equally silent — so
`auditionStart` now lives in `useAuditionTransport` and BOTH callers pass their
spans: the timeline popover its group's members or its single clip, and
`propertyPanelAudioFxGroup` the selected clip's own start/duration. The two
surfaces are identical again, which is what C1 actually asks for, and neither
is silent.

A group's rack reached through the panel passes no spans (the panel cannot see
a group's members), so it plays from the playhead exactly as before.

Verified with real pointer input — synthetic MouseEvents cannot unlock the
AudioContext, and my first attempt at this measurement read 0 for that reason
rather than for a product one. Playhead 0:00, group muted: hover jumps to 0:02,
meter reads 0.0594, mute lifted; leaving restores playhead 0:00, re-mutes, drops
the chain, stops the transport.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean.
2026-08-20 02:16:31 -07:00

598 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
HF_AUDIO_FX_ATTR,
serializeAudioFxChain,
type HfAudioFxChain,
} from "@hyperframes/core/audio-fx";
import { classifyAudioName } from "@hyperframes/core/audio-carve";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
import { runtimeAudioId } from "../lib/timelineElementHelpers";
import { isCanaryEnabled } from "../../telemetry/canary";
import { TimelineFxButton } from "./TimelineFxButton";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { groupAutomationLanes } from "./automationLaneData";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { clipTimingStart } from "../../hooks/gsapShared";
import { LayerDisclosureRow } from "./LayerDisclosureRow";
import { LABEL_COL_W, LANE_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";
/** Accent rail + inset marking a row as a group MEMBER, matching the level-2
* nesting its `aria-level` already reports. */
const GROUP_MEMBER_RAIL = "#3CE6AC59";
const GROUP_MEMBER_INDENT = 14;
interface TimelineTrackHeaderProps {
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
* never shown or announced. */
trackNumber: number;
/** The track's 1-based position in the rendered order: the only number safe
* to put in a label. Announcing `trackNumber` read out "track
* 0.16666666666666666". Null when the key has no row, which drops the number
* from the label rather than inventing one (see trackDisplayNumber). */
trackDisplayNumber: number | null;
trackLabel: string;
/** Ids of the canvas-side lane regions the disclosure caret expands, space
* separated as `aria-controls` takes them: the active clip's keyframe lanes
* and the track's automation lanes are separate elements, one owned by a
* clip and one by the row. Minted by TimelineLanes, the one place that sees
* every subtree. */
lanesId: string;
contentOrigin: number;
/** The track's active keyframe clip (selected, else primary) — the one whose
* disclosure + property rows this header shows, whether expanded or not. */
keyframeClip: TimelineElement | null;
/** Every clip on this track. Automation rows are the track's, unioned over
* these, so they stop changing with the selection. */
trackElements: readonly TimelineElement[];
/** Clips on this track, so the header can say how many the row holds. */
clipCount: number;
isExpanded: boolean;
animations: readonly GsapAnimation[];
currentTime: number;
isTrackHidden: boolean;
isAudioTrack: boolean;
/** This track is a member of an audio group — indents the row under its header. */
isGroupMember?: boolean;
rovingTargetId?: string | null;
theme: TimelineTheme;
onToggleClipExpanded: () => void;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
/** Drop one envelope. Absent while the lanes are read-only, which is what
* hides the control rather than offering a button that cannot act. */
onRemoveAutomationLane?: (target: string) => void;
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 (
<span className="flex shrink-0 items-center gap-0.5">
<button
type="button"
aria-label={`Previous ${label} keyframe`}
disabled={!navigation.prevKeyframe}
className={CHEVRON_BUTTON_CLASS}
onClick={(event) => {
event.stopPropagation();
seekTo(navigation.prevKeyframe);
}}
>
</button>
{children}
<button
type="button"
aria-label={`Next ${label} keyframe`}
disabled={!navigation.nextKeyframe}
className={CHEVRON_BUTTON_CLASS}
onClick={(event) => {
event.stopPropagation();
seekTo(navigation.nextKeyframe);
}}
>
</button>
</span>
);
}
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 (
<div
id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
data-timeline-element-id={elementId}
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
data-property-group={lane.group}
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
style={{
top: getTimelineLaneTop(laneIndex),
// The header column narrows to contentOrigin whenever that is under
// LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
// readout over the canvas, on top of the clips it is labelling.
width: columnWidth,
height: LANE_H,
background: gutterBackground,
}}
>
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
<span
className="absolute left-1.5 top-0 w-px bg-white/15"
style={{ height: isLastLane ? "50%" : "100%" }}
/>
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
</span>
<span className="w-[46px] shrink-0 truncate text-white" title={label}>
{label}
</span>
<PropertyGroupNavigation
navigation={navigation}
label={label}
expandedElement={expandedElement}
onSeek={onSeek}
>
<button
type="button"
aria-pressed={!!navigation.currentKeyframe}
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
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]"
onClick={(event) => {
// Same as the disclosure caret and the eye: a control in the label
// column owns its click, it does not also hit the track row behind it.
event.stopPropagation();
if (toggleTarget) {
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
}
}}
>
{navigation.currentKeyframe ? "◆" : "◇"}
</button>
</PropertyGroupNavigation>
<span
className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
title={valueReadout(lane.group, values)}
>
{valueReadout(lane.group, values)}
</span>
</div>
);
}
/**
* 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,
top,
isLastLane,
gutterBackground,
columnWidth,
onRemove,
}: {
/** 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;
top: number;
isLastLane: boolean;
gutterBackground: string;
columnWidth: number;
onRemove?: (target: string) => void;
}) {
return (
<div
data-automation-lane-label={label}
data-timeline-lane-top={top}
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
style={{
top,
width: columnWidth,
height: AUTOMATION_LANE_H,
background: gutterBackground,
}}
>
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch
tick at the name's own height. */}
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
<span
className="absolute left-1.5 top-0 w-px bg-white/15"
style={{ height: isLastLane ? "50%" : "100%" }}
/>
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
</span>
{/* 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. */}
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight" title={label}>
<span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
{name}
</span>
{param ? (
<span
data-automation-lane-param=""
className="truncate font-mono text-[9px] text-white/40"
>
{param}
</span>
) : null}
</span>
{/* 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. */}
{onRemove && target !== null && (
<button
type="button"
aria-label={`Remove ${label} automation`}
title={`Remove ${label} automation`}
// h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
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]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
// A control in the label column owns its click; it does not also hit
// the track row behind it and change the selection.
event.stopPropagation();
onRemove(target);
}}
>
×
</button>
)}
</div>
);
}
// fallow-ignore-next-line complexity
export function TimelineTrackHeader({
trackNumber,
trackDisplayNumber,
trackLabel,
lanesId,
contentOrigin,
keyframeClip,
trackElements,
clipCount,
isExpanded,
animations,
currentTime,
isTrackHidden,
isAudioTrack,
isGroupMember = false,
theme,
onToggleClipExpanded,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
onRemoveAutomationLane,
onSeek,
rovingTargetId = null,
}: TimelineTrackHeaderProps) {
const clipPercentage = keyframeClip
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
: 0;
const lanes = keyframeClip
? // clipTimingStart, not the raw start: an expanded sub-comp child's start is
// host-absolute while its tweens are local to its own file.
getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration)
: [];
// Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
const showTrackLabel = contentOrigin >= LABEL_COL_W;
// One row per automated property across the whole track, in the order the
// canvas draws them — a name beside the wrong envelope is worse than an awkward
// order. `target` is the ACTIVE clip's lane in that row, which is the only one
// the remove button can write to; null when the row belongs to its siblings.
const activeKey = keyframeClip ? (keyframeClip.key ?? keyframeClip.id) : null;
const automationRows = groupAutomationLanes(trackElements).map((group) => ({
key: group.key,
label: group.key,
name: group.name,
param: group.param,
target:
group.entries.find((entry) => (entry.element.key ?? entry.element.id) === activeKey)?.lane
.target ?? null,
}));
// Automation counts as something to disclose: gating the caret on tweens alone
// left an audio clip's envelopes unreachable, since the track could not expand.
const disclosable = lanes.length > 0 || automationRows.length > 0;
const isKeyframeLayer = !!keyframeClip && disclosable;
// Solo is per-clip/per-group, never per track (design doc §2.2) — this header
// acts on the track's first clip as a pragmatic stand-in for "this track",
// the same simplification the mute button doesn't need to make (it patches
// every clip on the track at once).
// A bare DOM id, not the store key: the set lands in the runtime, which
// compares it against `el.id` (see `runtimeAudioId`). A track whose first
// clip has no DOM id simply has no solo button.
const soloTargetId = trackElements[0] ? runtimeAudioId(trackElements[0]) : null;
const soloed = usePlayerStore((s) => s.soloed);
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
// C1: the FX entry point. A single audio clip has one chain to point at; a
// track holding several ungrouped ones has no single chain — the design
// doc refuses to build "N clips = N chains", so that case gets a pointer
// at grouping (B6's normative rule) instead of a popover.
const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
useTimelineEditContextOptional();
const domEditActions = useDomEditActionsContextOptional();
const singleAudioClip =
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
};
const openClipFxRack = (clip: TimelineElement) => {
void domEditActions?.handleTimelineElementSelect(clip);
};
// DOM ids, matching the carve picker's other caller — membership is read back
// by `resolveAudioGroups`, which only ever sees the document. A clip with no
// DOM id cannot be a member (resolveAudioGroups skips it), so a track holding
// one cannot be grouped WHOLE — and grouping the rest would quietly leave
// those clips outside the bus, past every fader, mute and effect, while the
// UI showed the track as grouped. The button is withheld instead of acting on
// a subset, which is also why the carve path's loud guard cannot catch this:
// the unresolvable ids were filtered out before the call.
const groupableClipIds = trackElements.map(runtimeAudioId);
const canGroupWholeTrack =
groupableClipIds.length >= 2 && groupableClipIds.every((id) => id !== null);
const groupUngroupedClips = () => {
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
if (!doc || !onGroupClips) return;
if (!canGroupWholeTrack) return;
void onGroupClips(groupableClipIds as string[], mintGroupId(doc));
};
return (
<div
role="rowheader"
aria-colindex={1}
className={`sticky left-0 z-[12] shrink-0 ${
!isKeyframeLayer
? showTrackLabel
? "flex items-center gap-1 px-1.5 text-white/55"
: "flex flex-col items-center justify-center gap-0.5"
: ""
}`}
style={{
width: showTrackLabel ? LABEL_COL_W : contentOrigin,
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
// A group's member rows are `aria-level="2"`, and until this they read
// as level 2 to a screen reader while looking identical to every
// top-level row on screen. The rail is the accent-tinted left border
// B2's design called for; the inset is what actually makes the nesting
// legible. Padding rather than margin so the rail stays flush with the
// gutter's own edge.
...(isGroupMember
? {
borderLeft: `2px solid ${GROUP_MEMBER_RAIL}`,
paddingLeft: GROUP_MEMBER_INDENT,
}
: {}),
}}
>
{!keyframeClip || !disclosable ? (
<>
<PlainTrackHeader
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
trackLabel={trackLabel}
clipCount={clipCount}
showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
onToggleTrackHidden={onToggleTrackHidden}
/>
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
<TimelineFxButton
variant="chain"
fxChainRaw={singleAudioClip.fxChain}
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
// Muted, an audition is silent — so the hover lifts the mute on
// the running graph and puts it back on the way out, the same
// borrow-and-return it already does with the playhead.
auditionSpans={[singleAudioClip]}
isMuted={isTrackHidden}
onSetMutedLive={(muted) =>
onSetElementAttributeLive?.(singleAudioClip, "data-hidden", muted ? "" : null)
}
onOpenRack={() => openClipFxRack(singleAudioClip)}
/>
)}
{/* The rack shelf is `audio-fx-rack`; the group-pointer variant WRITES
a group, so it needs `audio-groups` too — without it a user outside
that canary could create a group and then have no UI to manage it. */}
{isAudioTrack &&
clipCount > 1 &&
!isTrackGrouped &&
canGroupWholeTrack &&
isCanaryEnabled("audio-fx-rack") &&
isCanaryEnabled("audio-groups") && (
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
)}
</>
) : (
<>
<LayerDisclosureRow
name={
// A row holding several clips is named for the track, not for
// whichever of them is selected — the lanes below it are the
// track's, shared per property, and naming it "Narration 2" read
// as if they all belonged to that one slice.
clipCount > 1
? `Track${trackDisplaySuffix(trackDisplayNumber)}`
: (keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id)
}
clipCount={clipCount}
isExpanded={isExpanded}
gutterBackground={theme.gutterBackground}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
lanesId={lanesId}
onToggleClipExpanded={onToggleClipExpanded}
>
{/* The eye belongs to the LAYER, so it lives on the always-mounted
layer row exactly like a plain track's. Hanging it off a lane row
(hover-gated, and only while expanded) left a keyframed track with
no way to be hidden at all by keyboard, and put the control on a
row it does not act on. */}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible
isAudioTrack={isAudioTrack}
onToggle={onToggleTrackHidden}
/>
</LayerDisclosureRow>
{/* The caret expands TWO disjoint subtrees: these label-column rows,
which carry the per-lane keyframe controls, and the diamond lanes
on the canvas. `lanesId` names the canvas lanes (rendered by
TimelineLanes), because that is what a sighted user watches appear
and what following the reference has to land on. These rows are not
empty and are not the target; they are absolutely positioned inside
the sticky column, which is what made a wrapper HERE compute to
0x0 and hold no diamonds. */}
{isExpanded &&
lanes.map((lane, laneIndex) => (
<PropertyGroupHeaderRow
key={lane.group}
lanesId={lanesId}
lane={lane}
laneIndex={laneIndex}
isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
expandedElement={keyframeClip}
currentTime={currentTime}
clipPercentage={clipPercentage}
gutterBackground={theme.gutterBackground}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onSeek={onSeek}
rovingTargetId={rovingTargetId}
/>
))}
{/* Below the keyframe rows and stepping by its own height, which is how
TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
two have to agree or a name labels the wrong curve. */}
{isExpanded &&
automationRows.map((row, index) => (
<AutomationLaneHeaderRow
key={row.key}
target={row.target}
label={row.label}
name={row.name}
param={row.param}
top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
isLastLane={index === automationRows.length - 1}
gutterBackground={theme.gutterBackground}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onRemove={onRemoveAutomationLane}
/>
))}
</>
)}
</div>
);
}