Skip to content

feat(studio): add keyframe track headers - #2785

Merged
miguel-heygen merged 19 commits into
mainfrom
codex/studio-timeline-b-track-headers-v2
Jul 28, 2026
Merged

feat(studio): add keyframe track headers#2785
miguel-heygen merged 19 commits into
mainfrom
codex/studio-timeline-b-track-headers-v2

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Adds compact keyframe-aware track headers and lane expansion controls.

Why

Users need a clear, low-density way to understand track identity, clip count, visibility, and whether property lanes are expanded.

How

Introduces the track-header presentation and interaction contract while keeping selection and expansion state owned by the existing timeline model. This is B5 of the Family B Graphite stack.

Review fixes land in the stack tip (#2690): state-specific toggle labels (Add <Group> keyframe / Remove <Group> keyframe), and property-value sampling that follows the segment's own ease instead of interpolating linearly. The linear sampling reported a value the element never has mid-segment, and stamped that wrong value onto keyframes inserted from the header.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (not applicable)

Exact Family B tip validation: 2,910 Studio tests, 398 Studio Server tests, both package typechecks, formatting, lint, diff check, file-size gate, and Fallow audit passed.

Deferred review findings

Every blocker and high finding raised on this PR is fixed in the stack. The 4 remaining low/nit findings are parked, verbatim, in .scratch/studio-timeline-family-b/issues/05-pr-2687-deferred-review-findings.md:

  • 🟡 packages/studio/src/player/components/timelineCallbacks.ts:74 — New optional keyframe-callback params widen propertyGroup to string, diverging from PropertyGroupName used on the sibling type
  • 🟡 packages/studio/src/player/components/TimelineTrackHeader.tsx:482 — hoveredGroup useState causes full lane recompute on every pointer enter/leave (flicker-lens)
  • 🟢 packages/studio/src/player/components/LayerDisclosureRow.tsx:47 — Decorative ◇ glyph labels an unlabelable
  • 🟢 packages/studio/src/player/components/TimelineTrackHeader.tsx:491 — Keyframe-layer branch renders LABEL_COL_W children inside a parent whose width can be smaller

Supersedes #2687, which was closed when main was rewritten to unwind an early landing of this stack. Same head commit, same review history.

R1 review follow-ups

Fixed in this PR:

  • Every control in the lane label column (both chevrons and the toggle diamond) stops its click, so seeking to a keyframe no longer also selects the track behind the header.
  • The truncated track label, group label, and value readout carry title attributes, plus the layer disclosure row label.

The 2 remaining low findings are parked in .scratch/studio-timeline-family-b/issues/09-family-b-v2-r1-deferred.md.

R2 review follow-ups

Fixed in this PR:

  • TimelineTrackHeader.tsx owned value sampling, readout formatting, lane-state resolution and the JSX at once, so a formatting change and a layout change edited the same file. Sampling and formatting moved to trackHeaderLaneValues, lane-state resolution to trackHeaderLaneState, and resolveLaneHeaderState returns only the four fields its caller reads. The header also renders the per-track clip count it previously described but never showed.
  • LegacyTrackHeader read as deprecated code while being the live path for every track with no keyframe clip to disclose. It is now PlainTrackHeader, with a comment saying which tracks take it.

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-property-lanes-v2 branch from a9bdade to 6bc8d54 Compare July 25, 2026 19:45
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-track-headers-v2 branch from 7f86018 to 86e9735 Compare July 25, 2026 19:45

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE — head 86e9735.

Track-header contract is clean, the extract-to-helper is safe (both KeyframeNavigation callsite and the new lane header type-check under the generic NavigableKeyframe), and the alignment test (assertAligned) pins the pixel-perfect lane contract with #2784. Findings below are all P2/P3, non-blocking.

Adversarial lenses

Lens D — Interaction bleed with property lanes (P2). Half the controls stop propagation, half don't. Consistent stoppers: LayerDisclosureRow's caret (packages/studio/src/player/components/LayerDisclosureRow.tsx:100-104, both onPointerDown and onClick) and VisibilityButton (TimelineTrackHeader.tsx:559-563). Non-stoppers: PropertyGroupNavigation's prev/next chevrons (TimelineTrackHeader.tsx:736,745onClick={() => seekTo(...)} with no stopPropagation) and the toggle diamond (TimelineTrackHeader.tsx:832-836). Since sibling buttons in the same absolute-positioned label column deliberately stop propagation, clicks that leak from the chevrons/toggle up to whatever the ancestor track-row does are the likely regression path here. Match the sibling pattern.

Lens B — Truncation without tooltip fidelity (P2). Three ellipsis'd spans have no title attribute → mouse users get no full-name reveal, touch users are stuck: layer name in LayerDisclosureRow.tsx:119 (min-w-0 flex-1 truncate font-medium), track label in LegacyTrackHeader at TimelineTrackHeader.tsx:590, and the value readout at TimelineTrackHeader.tsx:841. The two <button>s in the same subtree already carry title= — adding it to the truncatable spans is a two-line fix.

Lens E — Contract with #2784 (verified clean). Header count = lane count (both derive from getTimelinePropertyLanes(animations, ...); the LegacyTrackHeader fallback fires when lanes.length === 0). [data-timeline-lane-top] offsets match TimelinePropertyLanes via getTimelineLaneTop(laneIndex) — asserted by the alignment test at TimelineTrackHeader.test.tsx:342-386. No orphan header/lane path.

Lens A — Disclosure state (deferred, not new). State ownership is upstream (isExpanded is a prop). Caret click is isolated to the button; wrapper <div> has no click handler, so header-body vs. caret can't diverge here.

Lens C — Reorder (N/A). No drag-to-reorder in this PR.

Nits (P3)

  • TimelineTrackHeader.tsx:519-524visualValueReadout shows raw values when |opacity| > 1, which for a percentage readout is a leaky formatting branch. Won't fire on valid GSAP opacity.
  • TimelineTrackHeader.test.tsx:245-249 — locks in the linear-sampling behaviour (tweenPercentage: 25opacity: 0.25). Per the PR body, the ease-following sampler lands at the stack tip; that PR will need to update this expectation.
  • The 4 deferred findings enumerated in the PR body all match the diff — verified.

— Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 86e9735511ec.

R2 adversarial pass on the track-headers slice. Three orange a11y items on the interactive layer: LayerDisclosureRow trigger missing aria-expanded/aria-controls (disclosure widget's whole contract), the hover-only Eye button unmounting means keyboard users can't reach it on any lane where laneIndex !== 0, and the O(lanes × keyframes) hover-driven recompute on every pointer-enter/leave. Plus the same propertyGroup?: string widening pattern Via flagged in a different form.

🟢 Verified clean

  • KeyframeNavigation.tsx: getKeyframeNavigationState generic extraction preserves prev/current/next semantics + TOLERANCE boundaries
  • timelineLayout.ts: single additive LABEL_COL_W=232 constant, no behavior change
  • TimelineTrackHeader.test.tsx: happy-dom coverage of disclosure + navigation

Complements Via's parallel adversarial pass (Family B rollup). Where Via found 0 P1 / 17 P2 / 18 P3, the adversarial subagent pass here surfaced additional depth on the mutation-authority thread and the Promise chain.

Review by Rames D Jusso

Comment thread packages/studio/src/player/components/LayerDisclosureRow.tsx
Comment thread packages/studio/src/player/components/LayerDisclosureRow.tsx
Comment thread packages/studio/src/player/components/timelineCallbacks.ts
Comment thread packages/studio/src/player/components/TimelineTrackHeader.tsx
Comment thread packages/studio/src/player/components/TimelineTrackHeader.tsx
Comment thread packages/studio/src/player/components/TimelineTrackHeader.tsx
Comment thread packages/studio/src/player/components/TimelineTrackHeader.tsx
Comment thread packages/studio/src/player/components/TimelineTrackHeader.tsx
An ungrouped tween (mixed property groups classify to propertyGroup
undefined) fed keyframeCache but was skipped by every gsapAnimations
writer, so the collapsed row drew diamonds the expanded lanes had no
source animation to render. Drop the property-group gate at all three
writers; lane consumers already filter by group.

Also route the same-percentage merge in updateKeyframeCacheFromParsed
through deduplicateKeyframes so the easeAmbiguous rule has one owner.
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-property-lanes-v2 branch from 6bc8d54 to 913fe08 Compare July 25, 2026 21:17
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-track-headers-v2 branch from 86e9735 to 0b9eff8 Compare July 25, 2026 21:17
Each keyframe-cache writer re-derived a clip-relative percentage inline, and the
post-commit writer rounded to 0.1% while the others used 0.001%. Selection keys
embed that number, so a commit-time rewrite could orphan a live key.
toClipPercentage owns the rounding, toClipKeyframes owns the whole row (percentage
plus the tween percentage and animation identity the lanes read), and the parsed
write reuses elementCacheKeys instead of open-coding the three key variants.
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-track-headers-v2 branch from 0b9eff8 to cf1cc8c Compare July 25, 2026 23:51
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-property-lanes-v2 branch from 913fe08 to 0509de7 Compare July 25, 2026 23:51
R3 review follow-ups on the keyframe cache:

- clearKeyframeCacheForFile collected ids from the index.html alias prefix
  too, so a re-scan of one composition file wiped rows a sibling file had
  just written (several files re-scan concurrently). Only the file's own
  prefixed keys name the ids now; clearKeyframeCacheForElement still takes
  the alias and bare key with them.
- toClipKeyframes fell back to a fixed 1s tween duration, which put a
  duration-less tween's keyframes at a percentage no edit path agreed with.
  It now spans the clip, matching resolveEditableTweenDuration.
- collectAnimatableKeyframeProperties takes `object` so call sites drop
  their `as Record<string, unknown>` casts.

Regression tests cover both fixes.
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-property-lanes-v2 branch from 0509de7 to 4a8322f Compare July 27, 2026 14:07
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-track-headers-v2 branch from cf1cc8c to 3795067 Compare July 27, 2026 14:07
…tack tip

The R1/R3 residuals on this PR were fixed at the top of the stack, so they
only cleared once every branch above landed. They belong here, next to the
code they correct:

- `idFromSelector` inverts `idSelector` for both regex readers, so the
  post-commit cache refresh stops skipping the CSS-unsafe ids `idSelector`
  exists to support.
- `deduplicateKeyframes` drops `ease` when it is ambiguous; the flag was the
  only honest answer and the last-writer-wins curve belonged to an arbitrary
  colliding tween.
- `isStaticPositionHold` is now the single owner of the hold skip. The
  `sourceAnimations` filter and the `allKeyframes` filter had diverged on
  whether `immediateRender` counts as a property.
- The keyframe-cache setters no-op when the write changes nothing, instead of
  handing every subscriber a fresh Map.
- `reset()` clears `focusedEaseSegment`.
- The test hook `delete`s its window key rather than setting it to undefined,
  so feature detection still works.
- The `toClipKeyframes` fixture uses `as unknown as T` with the justification
  CONTRIBUTING.md asks for.
Dragging the playhead to the start of the composition needed a very slow
drag. The scrub surface begins GUTTER + TRACKS_LEFT_PAD px right of the
viewport edge, and both scrub paths bailed out when the pointer sat left of
that origin rather than clamping. So the last 80px of the drag toward zero
silently did nothing: the playhead stuck at whatever the last in-range sample
reported, and only a drag slow enough to sample inside the thin sliver before
the origin ever reached 0.

Both paths now share getTimelineScrubTime, which clamps to [0, duration]. One
owner, so the live-feedback path and the committed-seek path cannot disagree
about the edge again.
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-property-lanes-v2 branch from 4a8322f to 8c762e3 Compare July 27, 2026 17:20
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-track-headers-v2 branch from 3795067 to 43363f2 Compare July 27, 2026 17:20
The local extractIdFromSelector duplicated the `#id`-only regex that
idFromSelector replaced, so both DOM-less paths in
resolveSelectorElementIds (no-iframe fallback and querySelectorAll-throw
recovery) read no id at all for the bracketed `[id="..."]` form writers
emit for CSS-unsafe ids. Deleted the duplicate and imported the shared
reader; both forms now resolve.
Rows stopped sharing one pixel height when lanes gained expansion, so the only
production caller was passing cumulative row coordinates with trackHeight 1 and
both scrollTops zeroed. The parameter names described units the values no longer
carried. The vertical axis is now a row index and the caller keeps ownership of
folding scroll and per-row heights into it.
The getTimelineRowTop docblock had a second copy sitting on
TimelineTrackHeightClip, where it describes nothing. Only the one on the
function stays.
…view

Escape now ends an in-flight diamond drag the way it already ends clip
and element drags: the armed gesture is marked cancelled, the preview is
dropped, and the pointerup that follows is swallowed instead of falling
through to the click branch.

The preview also flushes once per animation frame instead of once per
pointermove, so a high-rate trackpad no longer re-renders every diamond
in the row several times a frame. Single-diamond retime stays the
documented scope; multi-select drag needs a batched mutation the script
ops do not express yet.
CONTRIBUTING.md asks for a guard clause rather than a non-null assertion outside
an already-checked path. The index check and the lookup are now the same guard.
The prev/next keyframe chevrons and the group toggle diamond let their
click bubble to the ancestor track row, so seeking to a keyframe also
reselected the track. The disclosure caret and the eye already stop it;
these now match.

Truncated labels (layer name, track label, group label, value readout)
also carry a title so the full text is reachable on hover.
The header file owned value sampling, readout formatting, lane-state
resolution and the JSX at once, so a formatting change and a layout change
edited the same file. Sampling and formatting now live in
trackHeaderLaneValues, lane-state resolution in trackHeaderLaneState, and
resolveLaneHeaderState returns only the four fields its caller reads.

Also shows the track's clip count next to the track identity, which the
header promised but never rendered.
LegacyTrackHeader reads as deprecated code. It is the live path for every
track that has no keyframe clip to disclose, so call it PlainTrackHeader and
say so in a comment.
toggleTarget is derived from expandedElement, so it can only be set when
expandedElement is. The extra check read as if the two could disagree.
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-property-lanes-v2 branch from 8c762e3 to fed5e5b Compare July 27, 2026 17:54
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-track-headers-v2 branch from 43363f2 to 555d7f7 Compare July 27, 2026 17:54
vanceingalls
vanceingalls previously approved these changes Jul 27, 2026

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 555d7f7a5ce78a057d396564488aebaf0792827f (code-review max, R2, delta over R1 at 86e9735511ec).

Verdict

APPROVE — R2 confirms R1. Both R1 P2 findings (interaction bleed, truncation tooltips) landed in this PR's own diff. All three 🟠 Rames items and the 🟡 aria/target-size items are addressed at the stack tip (verified 6ee750fee on PR #2791); one 🟠 sub-item (aria-controls on the disclosure button) and one 🟡 (label-column overflow at contentOrigin < LABEL_COL_W) remain even at tip and land as follow-up. No new blockers in the R2 rewrite; standards mechanical grep is clean on every new/changed file (as T 0, !. 0, .message 0, <T> cast 0).

Rames R2 🟠 findings — status at new head

  • 🟠 Disclosure trigger missing aria-expanded / aria-controls (LayerDisclosureRow.tsx) — partial at tip. aria-expanded={isExpanded} added at 6ee750fee; aria-controls still absent. Sub-follow-up, not a block: the lane region has no stable id yet, and the fix is a one-liner once the region is named.
  • 🟠 Type widening propertyGroup?: string in timelineCallbacks.tsresolved at tip. onDeleteKeyframe / onMoveKeyframeToPlayhead / onMoveKeyframe collapsed to a TimelineKeyframeTarget object whose propertyGroup field is typed PropertyGroupName. Type is narrowed at source rather than widened per call site.
  • 🟠 Hover-only eye keyboard-unreachable on non-first lanesresolved at tip. The hoveredGroup state and pointer-enter/leave paths are gone; the eye moved to LayerDisclosureRow's trailing-children slot, so it is in the DOM unconditionally and reachable by keyboard.
  • 🟠 O(lanes × keyframes) recompute on hoverresolved at tip. Removed by the same architectural change that fixed the eye — no hover state, no per-pointer-transition re-render of getTimelinePropertyLanes + resolveLaneHeaderState.

Rames's 🟡 items — 4 (WCAG 24×24 target-size on chevrons + toggle) addressed via a before: pseudo-element 24×24 overlay at tip (nice pattern — visual density preserved, hit target compliant); 2 (aria-label on span) replaced with aria-hidden; 5 (aria-pressed on toggle diamond) added. 8 (label-column overflow when isKeyframeLayer && contentOrigin < LABEL_COL_W) remains at tip — real edge case, but the reachability threshold (a keyframe layer on a track narrower than 232 px of gutter) is thin. File as follow-up; not block-worthy.

Miguel's inline comments — status

Miguel dropped 8 identical replies pointing every Rames item at commit 6ee750fee on PR #2791. Cross-checked each one against the tip: 6 of 8 legitimately fixed at tip (items 2, 3, 4, 5, 6, 7), item 1 partially fixed (aria-expanded ✅, aria-controls ⏳), item 8 not fixed. Stack-review convention (fixes to earlier PRs land at the tip and merge together) is honored. Response verified accurate on 6.5 of 8.

Delta between old and new heads

Same three-file surface (TimelineTrackHeader.tsx, LayerDisclosureRow.tsx, KeyframeNavigation.tsx) plus three new sibling files created between R1 (86e9735) and R2 (555d7f7a):

  • trackHeaderLaneState.ts (121 lines) — resolveLaneHeaderState, KeyframeNavigationState, TimelinePropertyLane type; pure state, no JSX. Consolidates what was previously inline in TimelineTrackHeader.
  • trackHeaderLaneValues.ts (110 lines) — valuesAt + groupLabel + valueReadout (with positionValueReadout, rotationValueReadout, visualValueReadout split into a GROUP_VALUE_READOUTS map). Format concerns held apart from layout concerns.
  • TrackClipCount.tsx (17 lines) — small factored component; single-clip → renders nothing.

In this PR's diff (not the stack): R1 P2 items resolved — PropertyGroupNavigation's prev/next chevrons and the toggle diamond both event.stopPropagation() now (matches the sibling caret + eye pattern R1 flagged); the layer name (title={name}), track label (title={trackLabel}), and value readout (title={valueReadout(...)}) all carry title= for truncation tooltip fidelity.

Fix-internal remaining-silent-X audit

Adversarial-audit internal-boundary sweep on new helpers:

  • State discard. resolveLaneHeaderState returns {navigation, values, label, toggleTarget} — no partial state. If animation resolves to undefined, toggleTarget is null (correctly nullable and gated at callsite: if (toggleTarget) { void onTogglePropertyGroupKeyframe?.(...) }). No silent no-op path.
  • Return invariant. valuesAt(animation, group, tweenPercentage) returns {} when the animation touches no property in that group. groupLabel handles empty properties: Object.keys(properties)[0]undefined → falls back to "Other". No throw on empty.
  • Library defaults. propertyValueAt filter keyframe.properties[property] — assumes property exists (matched by filter). Uses .at(-1) and .find cleanly. Progress calculation guards before.percentage === after.percentage → returns before.value (no divide-by-zero).
  • Precedence. resolveLaneAnimation: animationAtPlayhead ?? lane.animations.find(... id === animationId). Playhead animation wins; nearest-keyframe fallback second. Consistent with lane-selection intent.
  • Session ownership. TimelineTrackHeader owns hoveredGroup via useState; passed as prop to PropertyGroupHeaderRow. State ownership contained. (Tip-level rewrite removes this entirely; here it's still local, no leak.)
  • Discovery completeness. getTimelinePropertyLanes derives lanes from (animations, keyframeClip.start, keyframeClip.duration). Header count = lane count (both branch on lanes.length === 0). No orphan header/lane path — same invariant R1 verified via assertAligned in the tests.

Standards lens re-run

Mechanical grep on every file this PR adds or modifies (LayerDisclosureRow.tsx, TimelineTrackHeader.tsx, TrackClipCount.tsx, trackHeaderLaneState.ts, trackHeaderLaneValues.ts, timelineCallbacks.ts, KeyframeNavigation.tsx):

  • Bare as T casts: 0
  • Non-null !. assertions on new lines: 0 (the !. chain in KeyframeNavigation.clipToTweenPercentage is pre-existing, not this PR's diff)
  • .message without instanceof Error: 0
  • Angle-bracket casts <T>: 0

Empty section — required.

Independent findings

None P1/P2 net-new in this diff. Two P3 nits worth noting for the stack owner (do not block):

  • P3 — TimelineTrackHeader.test.tsx:13 uses a double-cast (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }) to set the React act flag. Convention in the tree (packages/studio/src/**/*.test.tsx) is fine; not a standards issue since it goes through unknown.
  • P3 — trackHeaderLaneValues.ts:visualValueReadout still shows raw values when |opacity| > 1 (R1 P3 nit, unchanged). Won't fire on valid GSAP opacity; keeping as-is is defensible for a diagnostic readout.

Peer state

Rames R2 (4780132579, COMMENTED at old head 86e9735) flagged 8 items on the interactive layer; verified 6.5 of 8 addressed at stack tip 6ee750fee (PR #2791). One 🟠 sub-item (aria-controls) and one 🟡 (label-column overflow when narrow gutter meets keyframe layer) remain — both are appropriate follow-ups, not blockers on this PR.

R1 (Via, 4780108208, APPROVED at old head 86e9735): reconfirmed. The two R1 P2 findings (interaction bleed, truncation tooltips) landed in this PR's own diff; R1 P3 nits (visualValueReadout |opacity|>1 branch, ease-following-sampler test lock-in) unchanged and non-blocking. Extract-to-helper refactor is safe under the same generic NavigableKeyframe constraint I verified at R1.

Review by Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 555d7f7 — R3 delta from my R2 at 86e9735511ec.

Delta summary vs. R2

Substantial refactor since R2 — commit 4ac57a5845ae split lane math from JSX (into trackHeaderLaneState.ts + trackHeaderLaneValues.ts), TimelineTrackHeader.tsx shrank from ~700 lines to 367, and resolveLaneHeaderState now returns exactly the 4 fields its caller destructures. Commit c264cf266f1c addressed the click-bubbling to track row (chevrons + toggle diamond stopPropagation) plus added title attributes to truncated labels. Dead expandedElement guard drop at 555d7f7a5ce7 is safe (verified — toggleTarget is derived from expandedElement, a required prop).

One new concern — consumer-wiring

🟡 TimelineTrackHeader is not mounted anywhere at this SHA. Full-tree grep at HEAD for TimelineTrackHeader, <TimelineTrackHeader, and resolveLaneHeaderState finds only the component itself, its own test, and sibling files under packages/studio/src/player/components — no importer in Timeline.tsx, NLELayout, EditorShell, or anywhere else. Meanwhile TimelineLanes.tsx:179-210 still renders the live legacy header inline (sticky left-0 z-[12], Music icon, Show/Hide track N eye button) — the same UI PlainTrackHeader replaces.

Ships ~1000 lines of code + a full test file that never run in the shipped app. Same "primitive defined but no app consumer" pattern as #2784. Not itemized in this PR's 4-item parked list.

Ask: Either wire TimelineTrackHeader into TimelineLanes.tsx (replacing the inline sticky-left header) in this PR, or add a // TODO(follow-up: wired by #NNNN) marker + note in the parked list so a reviewer landing this in isolation sees the wiring is a separate merge.

Confirms of Miguel's 4 parked items

All still present at HEAD (line numbers in the parked list are stale relative to the R2 refactor's file shortening):

  • 🟡 hoveredGroup useState — parked as TimelineTrackHeader.tsx:482; actual line 295. Every onPointerEnter/onPointerLeave re-renders TimelineTrackHeaderlanes.map(...) → per-row resolveLaneHeaderState (O(keyframes) filter/reduce/find). ~180 array walks per hover pixel on a 3-lane × 20-keyframe clip. HF asset-flicker debt makes this materially relevant. CSS :hover on the row's parent would sidestep React state entirely.
  • 🟡 propertyGroup?: string widening — parked as timelineCallbacks.ts:74; actual lines 77, 86, 94 (three new callbacks, all widened). Same file line 10 uses tight PropertyGroupName: PropertyGroupName; on the sibling toggle interface — asymmetry. Change to propertyGroup?: PropertyGroupName.
  • 🟢 Decorative glyph — parked as LayerDisclosureRow.tsx:47; actual line 51. aria-label on a non-interactive <span> is not exposed by most screen readers. Replace with aria-hidden="true" (the diamond meaning already reappears on the toggle button below).
  • 🟢 LABEL_COL_W-wide children in narrower parent — parked as TimelineTrackHeader.tsx:491; actual line 317. showTrackLabel = contentOrigin >= LABEL_COL_W; when contentOrigin < LABEL_COL_W, outer div width is contentOrigin but LayerDisclosureRow (LABEL_COL_W-wide) and every PropertyGroupHeaderRow (LABEL_COL_W-wide) render inside. Currently latent since nothing mounts the component.

Still-open R2 items Miguel did NOT park (all a11y-facing)

These still stand at HEAD; noting for completeness in case they should also be added to the parked list:

  • 🟠 LayerDisclosureRow.tsx:32 — disclosure trigger missing aria-expanded / aria-controls. Button is the disclosure widget's whole ARIA contract; aria-label alone doesn't satisfy the pattern.
  • 🟠 TimelineTrackHeader.tsx:208-210 — hover-only Eye keyboard-unreachable on non-first lanes.
const showEye =
  hoveredGroup === lane.group ||
  (hoveredGroup === null && laneIndex === 0 && (isActive || isHovered));

VisibilityButton unmounts when showEye is false; keyboard users can't reach the Eye on any lane where laneIndex !== 0.

  • 🟡 TimelineTrackHeader.tsx:143, 156 — WCAG 2.5.8 target-size failure on prev/next chevrons (h-5 w-3 = 20×12 CSS px, well under the 24×24 minimum).
  • 🟡 TimelineTrackHeader.tsx:243-249 — toggle-state buttons missing aria-pressed. Screen-reader users can't distinguish add-mode from remove-mode.

Nit — test coverage gap

TimelineTrackHeader.test.tsx covers click-stop propagation and clip-count rendering but has zero assertions on the title="…" attributes added by commit 4ac57a5 (R1 truncation-tooltips fix). A future edit dropping a title prop wouldn't fail any test. One-line addition: assert [title] inside a lane returns the expected label/value strings.

What I didn't verify

  • Whether TimelinePropertyLanes (from #2784) will consume TimelineTrackHeader when it's wired in — the two scaffolding slices' interface fit isn't visible from either PR's diff.
  • Behavior when keyframeClip.label is empty / null — the name fallback chain at LayerDisclosureRow.tsx:21 (label ?? domId ?? id) handles it, but no test exercises the missing-label case.

Stamp routing per convention. Leaving as COMMENTED.

Review by Rames D Jusso

@miguel-heygen
miguel-heygen changed the base branch from codex/studio-timeline-b-property-lanes-v2 to main July 28, 2026 00:23
@miguel-heygen
miguel-heygen dismissed vanceingalls’s stale review July 28, 2026 00:23

The base branch was changed.

@miguel-heygen
miguel-heygen merged commit 555d7f7 into main Jul 28, 2026
46 of 56 checks passed
@miguel-heygen
miguel-heygen deleted the codex/studio-timeline-b-track-headers-v2 branch July 28, 2026 00:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants