feat(studio): add timeline property lanes - #2784
Conversation
5816e70 to
50a9077
Compare
a9bdade to
6bc8d54
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Verdict: APPROVE — additive surface (+712/-1) integrates cleanly, tests are thorough (13 rendering assertions + a per-project auto-expand scenario), and every P2/P3 candidate I found is either pre-declared in the body or deferred by design to later stack PRs.
Adversarial pass at PR head 6bc8d545 against base codex/studio-timeline-b-keyframe-retiming-v2. Prior reviews: 0. Prior line comments: 0.
Adversarial lenses
- Lens A — Property-lane taxonomy. Groups derive from
classifyPropertyGroup+PropertyGroupName(core-defined, closed union) — no adapter injection surface. Lane order comes fromArray.from(sourceGroups(...))wheresourceGroupsis aMap<PropertyGroupName, GsapAnimation[]>filled by iteration order of the inputanimationsarray (TimelinePropertyLanes.tsx:41-49). i.e. display order = caller-supplied animation order. Testreturns both flat and authored keyframe property groups(test.tsx:143) asserts["position", "visual"]by inputting them in that order — the assertion is tautological. P3 (deferred): whichever PR wires the consumer must decide whether to sort by canonical group order or leave to upstream. Not blocking here — no consumer exists yet in this PR. - Lens B — Empty-state UX.
TimelinePropertyLanes.tsx:108,111returnnullfor narrow clip and no lanes; there is no click-to-add affordance in the lane track region. Adding keyframes happens elsewhere (segment button flow throughTimelineDiamondLane), and un-keyframed clips remain collapsed viauseAutoExpandKeyframedClips— so an "empty lane region" is unreachable by design. Intentional, no action. - Lens C — Lane height & density. Fixed
LANE_Hper lane, no compact toggle, no virtualization. Realistic upper bound is the count of distinctPropertyGroupNamevalues (~6-8), so DOM churn is bounded. P3 (informational): no ceiling enforced, but the closed taxonomy makes explosion unlikely. - Lens D — Additive claim veracity. Verified. The
-1isTimelineClipDiamonds.tsx:15interface TimelineDiamondKeyframe→export interface TimelineDiamondKeyframe— pure visibility widening, no functional delta. NeitherTimelinePropertyLanesnoruseAutoExpandKeyframedClipsis invoked by any file in this PR — this is stage code that later stack PRs wire in. No existing render path branches on new state; no consumer is accidentally opted-in. - Lens E — Stack cohesion with #2785 (track headers). Clean shape: each lane div carries
data-property-group={group},data-timeline-property-lane="",data-timeline-lane-top={getTimelineLaneTop(laneIndex)}(TimelinePropertyLanes.tsx:117-120). No orphaned header slot, no half-placeholder. #2785 has a clear DOM keying surface to attach to.
Findings
PR body's 4 pre-declared deferred findings — verified real, correctly severity-marked:
- 🟡
useAutoExpandKeyframedClips.ts:14— collapse-sticky invariant is per hook-component lifetime (seenis auseRef). Confirmed — unmount-remount loses the memory. Deferred.scratch/…/04-…md. OK. - 🟡
TimelinePropertyLanes.tsx:141—globalEasefallback =groupAnimations[0]?.keyframes?.easeEach ?? groupAnimations[0]?.ease ?? "none", ignoring the actual animation each keyframe belongs to. Confirmed — when a single group has multiple animations, easeEach from animation index >0 is dropped. Deferred. OK. - 🟢
useAutoExpandKeyframedClips.ts:20— project switch that reuses previous project's Map identity silently skips expansion (sourceChangedguard, line 21). Confirmed. Deferred. OK. - 🟢
TimelinePropertyLanes.tsx:41—synthesizeFlatTweenKeyframesruns twice per flat-tween per render (once insideanimationContributesLaneinsourceGroups, again insidegroupKeyframes). Confirmed via code path trace. Deferred. OK.
Additional adversarial observations (all P3 / informational, none blocking):
- P3 — Auto-expand
useEffectis state-syncing.useAutoExpandKeyframedClips.ts:19-34writes tousePlayerStorefrom an effect. Not a violation of the "no useEffect for state syncing" rule as strictly written (Zustand store is an external system, not local React state), and it can't cleanly becomeuseMemobecause it's a side-effect. Flag only if the rule is being read maximally. - P3 —
getTimelineLaneTopdefensive clamp (timelineLayout.ts:12-14) —Math.max(0, Math.trunc(laneIndex))silently normalizes negatives/fractionals. Fine as-is; worth a doc line if callers ever pass indices from a filtered array. - P3 —
data-timeline-lane-topduplicatesstyle.top(TimelinePropertyLanes.tsx:118-121). Presumably intentional for test/consumer readback of the resolved integer without parsing a CSS pixel string. OK, but if #2785 doesn't consume the attribute, it can go.
Signal for the stack
- PR body/message hygiene is strong — the "Supersedes #2686" note plus deferred-findings file makes downstream review much cheaper.
TimelineDiamondLanealready existed on the head branch (from an earlier stack PR); this PR wraps it withgroupAwareper lane. Interface is stable.- The exported
TimelineDiamondKeyframeinterface is now module-public — trivially small blast radius today (2 importers, both in-PR), but a future ABI change becomes a cross-file ripple.
Ship it. My additions above are pre-emptive notes for the consumer PR (#2785+), not this one.
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 6bc8d545accf.
R2 adversarial pass on the expandable per-property lanes slice. Two orange items on the render path: globalEase = groupAnimations[0]?.keyframes?.easeEach ?? ... misrepresents ease for sibling animations in the same group (segments show the wrong curve while clicks still route to the correct animationId — displayed and edited ease diverge); and a fresh keyframesData={{ format, keyframes }} object literal per render + un-memoized getTimelinePropertyLanes recomputation defeat TimelineDiamondLane's React.memo — every playhead tick re-renders every diamond in every expanded lane.
🟢 Verified clean
- TimelineClipDiamonds.tsx: only exports TimelineDiamondKeyframe — no behavior change
- timelineLayout.ts: getTimelineLaneTop is pure additive helper with defensive Math.max/Math.trunc guarding
- TimelinePropertyLanes.test.tsx: uses act() correctly, cleans DOM in afterEach; keyframe target payload assertion covers group-aware selection identity
- useAutoExpandKeyframedClips.test.tsx: correctly resets playerStore + shell mock between cases and covers sticky-collapse within a project
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.
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.
6bc8d54 to
913fe08
Compare
50a9077 to
0779ac9
Compare
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.
0779ac9 to
32427d9
Compare
913fe08 to
0509de7
Compare
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.
0509de7 to
4a8322f
Compare
32427d9 to
29a9984
Compare
…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.
29a9984 to
e3e3456
Compare
4a8322f to
8c762e3
Compare
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.
8c762e3 to
fed5e5b
Compare
e3e3456 to
6e0118c
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at fed5e5b71df5 (code-review max).
Verdict
APPROVE.
Rebase-clean over my R1 approval at 6bc8d545accf — every one of the 6 PR-scope files is byte-identical between the two SHAs (base…head compare shows ahead_by=1, behind_by=0 off codex/studio-timeline-b-keyframe-retiming-v2@6e0118cb; the divergent 43-file compare is entirely ancestor lag from the base itself moving). No new content to re-open on. Max sweep below extends R1 with the full lens set plus a peer-lens cross-check against Rames.
Scope
Additive stage code (+712/-1). No consumer at HEAD — verified by grep across packages/studio/src/** for TimelinePropertyLanes / useAutoExpandKeyframedClips / getTimelineLaneTop: zero external references. The single deletion is TimelineClipDiamonds.tsx:15 widening interface TimelineDiamondKeyframe → export interface TimelineDiamondKeyframe; pure visibility change so TimelinePropertyLanes.tsx:456 can import { ..., type TimelineDiamondKeyframe }.
TimelinePropertyLanes.tsx:494-546— pure lane-model helpers (animationContributesLane,sourceGroups,groupKeyframes,getTimelinePropertyLanes).TimelinePropertyLanes.tsx:548-609— presentational wrapper mapping the model to one<TimelineDiamondLane groupAware …>per lane, keyed bygroup, positioned bygetTimelineLaneTop(laneIndex).useAutoExpandKeyframedClips.ts:12-35— per-clip first-sight auto-expand keyed toprojectId, sticky against later user collapse within one hook lifetime.timelineLayout.ts:12-14—getTimelineLaneTop(laneIndex)=TRACK_H + max(0, trunc(laneIndex)) * LANE_H.TimelinePropertyLanes.test.tsx(433 lines, 13 assertions) +useAutoExpandKeyframedClips.test.tsx(75 lines, one full per-project scenario).
Property-lane data model + interactions
- Model. Denormalized on every render —
getTimelinePropertyLanes(animations, clipStart, clipDuration)recomputes lanes + per-lane keyframes inline (.tsx:567). Ordering = Map insertion order ofsourceGroups, which = caller-supplied animation order. No canonicalPropertyGroupNamesort. R1 already flagged this as a consumer-PR decision. - Visibility. Zustand
expandedClipIds: Set<string>(keyframeSlice.ts:32); ephemeral (no localStorage persist).expandClipsis union-only (keyframeSlice.ts:81-87),toggleClipExpandedandsetClipExpandedhandle user actions. - Auto-expand semantics.
useAutoExpandKeyframedClipsrecords seen clip ids in auseRef; only clips not yet seen are expanded. Correctly guardsSTUDIO_KEYFRAMES_ENABLED. Project switch resetsseen.currentwhen eitherprojectIdORgsapAnimationsidentity changed (thesourceChangedguard at :22-24 protects against the "same project reopens with a fresh Map identity" case). - Empty state / narrow-clip / zero-lane. All return
null(.tsx:566, 569). Consumer owns any empty affordance. - Add / remove / restore. Adding a new clip → auto-expand next render. Collapsing a seen clip → stays collapsed. Removing then re-adding the same-id clip → treated as already-seen, no re-expand (declared behaviour in the doc comment
.ts:9-11and matched by the parked 🟡 in the PR body). - Lane count / virtualization. Bounded by the closed
PropertyGroupNameunion (~6-8 groups);.map()render is safe. Diamonds per lane are bounded by author input viaTimelineDiamondLane.
Editor-UI 12 lenses
- Silent-catch / error-invariant. No
try/catch, no.catch(() => {}). None hidden. - Commit semantics. No commits inside the lane; every mutation is a callback (
onMoveKeyframereturnsPromise<boolean>, no fire-and-forget). - Key stability. Lane wrapper
key={group}—groupis a stablePropertyGroupNameenum value. ✔. Inner diamond keys${i}-${kf.percentage}and connector keys live in baseTimelineDiamondLane, not this PR's diff — same pattern as inline row. Selection identity is namespace-split (see §Element identity below) so lane vs collapsed-row keys can't collide. - ARIA. Rames flagged (
:125) — lane wrapper carries onlydata-*attributes, norole/aria-label. Real; deferred (per Miguel's stack-tip resolution on #2791). - Keyboard. Lane is presentational; keyboard interaction lives on the inner diamond
<button>elements (base code, unchanged here). No new keyboard surface introduced or dropped. - Propagation. Wrapper
<div>has no event handlers. Inner ease<button onPointerDown={(e) => e.stopPropagation()}>(base :306) prevents pointer bleed to the ancestor clip. Nothing new to leak here. - Semantic-vs-symptom. Lanes are a real compositional layer over the existing
TimelineDiamondLane(viagroupAware=true), not a workaround wrapper. - Sibling helpers. One real overlap — see §Non-blocking observations (
groupKeyframesreimplements the shape of the canonicalgsapShared.toClipKeyframes).getTimelineLaneTopis the solelaneIndex → ycalculator;animationContributesLaneis a single-source export reused by the auto-expand hook. - Parity audit. The
groupAwareboolean split routes both collapsed inline diamonds and expanded lane diamonds through the sameTimelineDiamondLanerender path — same size, same fill, same hover-reveal ease button. Testkeeps the collapsed TimelineClipDiamonds positions and callback contract unchanged(test.tsx:407-444) locks this: identical["-11px", "89px"]positions and identicalonClickKeyframe(50)payload shape. - Cross-mode. Studio-only; no preview branch to diverge.
- Perf audit. Rames raised two real perf items — both stand on this PR's diff:
:120—getTimelinePropertyLanesre-runs every render, nestedsynthesizeFlatTweenKeyframescalls double-count (once inanimationContributesLane, again ingroupKeyframes).:140—keyframesData={{ format: "percentage", keyframes }}fresh literal + freshly-constructedkeyframesarray defeatsReact.memoonTimelineDiamondLane.
Both parked; both fixed at stack tip6ee750feeper Miguel's reply-thread. Concurring with Rames on the substance; not blocking because there's no consumer mounting this lane at HEAD, so the wasted work has no live surface.
- PR-body-vs-diff. Bullets → code:
- "expandable per-property keyframe lanes" — ✔
TimelinePropertyLanes+useAutoExpandKeyframedClips. - "segment easing controls" — ✔ hover-reveal button on
data-keyframe-ease-segment(base code path exercised viagroupAware=true). - "accessible ease controls" — partial: aria-label on the ease button (base
:290-291), but the lane wrapper itself carries no ARIA identity (Rames :125). "Accessible" is closer to aspirational than delivered by this diff. - "navigation affordances from the canonical lane model" — callback-only (
onClickKeyframe/onShiftClickKeyframe/onContextMenuKeyframe); no in-lane focus / arrow-key nav ships here.
Code → bullets: the "Deferred review findings" section correctly enumerates the 4 known parked items, and the "R1 review follow-ups" section correctly names 5 low/informational items — matches R1's actual count (my R1 raised 3 P3s explicitly, but the pre-declared 4 plus my 3 net-of-overlap gives the ~5 informational total; close enough that the body's "5" is honest, not padded).
- "expandable per-property keyframe lanes" — ✔
Standards + Spec + Precision + Round-trip
- Standards. Root
AGENTS.mdcalls outAvoid any and as T assertions. Production code: 0 bareas T, 0any, 0 non-null assertions, 0.messagesansinstanceof, 0 angle-bracket casts.import type { MouseEvent as ReactMouseEvent, RefObject } from "react"(.tsx:448) is an import alias — not an assertion. Test files use the standard(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean })React 18 test-env pattern andarr[0]!for happy-dom fixture handles — test-scope idioms, fine. - Bi-directional spec bullet-check. Traced under Lens 12 above.
- Sibling-precision divergence. One real hit — see §Non-blocking observations.
groupKeyframes(.tsx:509-533) mirrorsgsapShared.toClipKeyframes(gsapShared.ts:262-290) minus theMath.round(... * 100000) / 1000clip-% rounding step and minus the shared helper's docstring-declared "one precision every keyframe-cache writer must share" invariant. - Middle-man wrap-unwrap.
keyframe.percentage(tween-relative %) →((absoluteTime - clipStart) / clipDuration) * 100(clip-relative %), original preserved astweenPercentage. Selection identity (timelineKeyframeSelectionKey) usespercentage(clip-%) as key suffix, but thegroupAwarenamespace prefix (${propertyGroup}:${animationId}:) segregates PropertyLanes' key namespace from the collapsed cache-fed row's key namespace — so precision drift can't orphan a selection across the two rendering surfaces. It could still drift within PropertyLanes if the animations prop identity changes; latent, not live.
Six-category internal-boundary audit
- State-accumulation discard.
useAutoExpandKeyframedClips.seen.current.clipsis bounded (per-project) and correctly replaced viaseen.current = { projectId, source, clips: new Set() }on project change — old Set GC'd, no leak.sourceChangedgate at:22-24prevents the "same project, fresh Map" case from double-expanding. - Return-boundary invariant.
getTimelinePropertyLanesends with.filter((lane) => lane.keyframes.length > 0)— every returned lane has ≥1 keyframe.groupKeyframesreturns an empty array for a group whose only animations have no keyframes withhasGroupPropertymatch; the filter then drops the empty lane. Invariant holds. - Library defaults on failure axis. Effect deps
[gsapAnimations, expandClips, projectId]— Zustand selectorusePlayerStore((s) => s.expandClips)returns a stable reference;projectId?? nullnormalizes the optional-context "not yet mounted" case;STUDIO_KEYFRAMES_ENABLEDshort-circuits before any mutation. - Precedence in overlap rules.
sourceGroupscollates every matching animation into one lane.globalEasefallback picksgroupAnimations[0]unconditionally — Rames:141. Precedence bug is real (siblings'easeEachare silently displayed as animations[0]'s ease); segment click still routes via each keyframe's ownanimationId, so display ≠ edit target. Parked; fix at #2791. - Session / resource ownership.
usePlayerStore.getState().reset()in the test suite resets the shared store;seen.currentis per-hook-instance and per-project. No cross-instance leakage. - Discovery / enumeration completeness.
sourceGroupsandgroupKeyframesbothfor-ofiterate the animations array — every animation withpropertyGroup && animationContributesLaneis included;hasGroupPropertyfilter is per-keyframe so a mixed-property animation contributes to each group it touches. No skip path.
React refactor hygiene
- Dead props. All 15
TimelinePropertyLanesPropsfields consumed in the JSX (accentColor,isSelected,currentPercentage,elementId,selectedKeyframes,onSelectSegment,onClickKeyframe,onShiftClickKeyframe,onContextMenuKeyframe,onMoveKeyframe,suppressClickRefall pass through toTimelineDiamondLane;animations,clipStart,clipDuration,clipLeftPx,clipWidthPxused inline). - Falsy-zero.
clipWidthPx < 20 || clipDuration <= 0treats 0 correctly (0 < 20 → true → return null).currentPercentage={-10}sentinel keeps the atPlayhead check|kf.pct − (-10)| < 0.5unreachable within [0, 100] — off-screen playhead by design.?? "none"ease fallback would letease: ""slip through (??only guardsundefined/null) — theoretical only; parser doesn't emit empty-string ease today. - Mid-drag state. No drag state owned by
TimelinePropertyLanes; delegated toTimelineDiamondLanewhich now has proper Escape +pointercancelhandling (baseTimelineClipDiamonds.tsx:170-183, 495-504). useEffectfor state syncing.useAutoExpandKeyframedClipswritesexpandClips(fresh)from an effect. Not a derivation-of-props concern (Zustand store is external) — same reasoning as R1. Not a violation.- Fast Refresh / list virtualization / semantic tokens / emojis / CSS-in-JS. None of these Pacific-scoped Golden Rules apply to hyperframes (bun/oxlint/oxfmt, different design surface). No hardcoded colors, no CSS-in-JS, no emojis in the diff regardless.
Element identity / keying
- Lane wrapper:
key={group}— enum-valued, stable. ✔ - Diamonds inside lane:
key={${i}-${kf.percentage}}(base). Index + percentage — remounts on retime (percentage changes); acceptable existing behaviour. - Selection identity:
timelineKeyframeSelectionKey(elementId, target)(timelineKeyframeSelectionKey.ts:8-17) — withgroupAware=trueincludes${propertyGroup}:${animationId}:prefix, keeping the lane's key namespace disjoint from the collapsed cache-fed row's namespace. Testkeeps Position@50% selection distinct from Opacity@50%(test.tsx:251-294) locks this: the same 50% keyframe in Position vs Visual produces different selection keys and the Visual fill stays#a3a3a3while Position lights up#4ba3d2.
Standards lens re-run
- bare
as Tcasts (production): 0 - non-null
!./![/!;(production): 0 (test-only: 5 — all against happy-dom NodeList fixture handles) .messagewithoutinstanceof: 0- angle-bracket casts
<T>: 0 anyin production: 0- emojis in JSX / strings: 0
- CSS-in-JS: 0
- hardcoded Tailwind color classes: 0 (hyperframes uses inline
style; notw-*prefix regime)
The deleted line
TimelineClipDiamonds.tsx:15 interface TimelineDiamondKeyframe { → export interface TimelineDiamondKeyframe {. Pure visibility widening — no functional delta, no field shape change. Enables the new import { TimelineDiamondLane, type TimelineDiamondKeyframe } in TimelinePropertyLanes.tsx:456. Blast-radius today: 2 importers, both in-PR. As R1 noted, once module-public, any future field change becomes a cross-file ripple — a cost the stack accepts on purpose.
Non-blocking observations
-
P2 — sibling-helper duplication + latent precision drift.
TimelinePropertyLanes.groupKeyframes(.tsx:509-533) re-implements the shape of the canonicalgsapShared.toClipKeyframes(gsapShared.ts:262-290) — sametweenStart/tweenDurationfallback, sametweenPercentage / propertyGroup / animationIdfield set — but skipstoClipPercentage'sMath.round(... * 100000) / 1000rounding step. The canonical helper's docstring explicitly names the reason: "0.001% keeps a beat-snapped keyframe centered on the beat dot, and because selection keys embed this number, a writer that rounds coarser would orphan a live selection the moment it rewrites the cache. […] Shared by the cache writers so they cannot drift in precision or in which identity fields they record." PropertyLanes is effectively another writer of the same field shape. The namespace prefix (${propertyGroup}:${animationId}:) protects against orphaning selections across the collapsed cache-fed row and the expanded lane, so today it's latent — but the divergence is exactly the class of drift the shared helper was designed to prevent. Suggested fix: havegroupKeyframescalltoClipKeyframes(looping per animation, filtering byhasGroupProperty) rather than open-coding the conversion. -
P3 — beat-strip parity.
TimelinePropertyLanesdoesn't thread thebeatsActiveflag through toTimelineDiamondLane(baseTimelineClipDiamonds.tsx:39-41, 125), so an expanded lane on a beat-strip track loses the "shrink diamonds + drop them into the bottom half" behaviour the collapsed row gets. If the consumer PR wires this into a track that ever getsbeatsActive=true, expanded and collapsed states will disagree on diamond placement. Not blocking here — no consumer. -
P3 —
?? ""sensitivity in globalEase.groupAnimations[0]?.keyframes?.easeEach ?? groupAnimations[0]?.ease ?? "none"uses??throughout, which lets an empty-stringeaseslip past both fallbacks. Parser doesn't emit""today; note only. - P3 — Rames items on the diff. All 8 of Rames's inline findings are real against this PR's own diff; Miguel replied to each pointing at the fix commit at stack tip #2791. Consistent with the "same head commit, same review history" supersedes note in the PR body. If the stack ships together (Graphite merge), the tip fix carries; if this branch were ever merged alone, the bugs would live.
Peer state
vanceingalls— APPROVED 2026-07-25 at6bc8d545accf(R1). PR-scope files byte-identical between that SHA andfed5e5b71df5— approval carries.james-russo-rames-d-jusso— COMMENTED 2026-07-25 at same SHA. 2 orange + 3 yellow + 3 green inline findings. Peer-lens split: two independent reviewers converged on the parked items (Rames named the two perf items I hadn't quantified in R1). No blocker raised. Concurring with Rames on substance; not matching a block because neither of us raised one and the stack-tip fix path is documented.miguel-heygen— 8 replies 2026-07-27 acknowledging each Rames finding with "Addressed at the stack tip in6ee750fee(PR #2791) […] verified on the tip: typecheck, oxlint 0/0, 2916 tests pass."- CI at head. All required checks green (Preflight, Preview parity, preview-regression, regression, player-perf, Detect changes ×3, WIP). Graphite mergeability_check pending because #2791 is still open — stack-order gate, not a substantive failure.
Stamped.
— Review by Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at fed5e5b71df505b2598fe31522eba1710ea62c7c — B4 slice on top of B3, +712/-1.
The lane primitive itself is clean: getTimelinePropertyLanes is a small pure function, TimelinePropertyLanes composes TimelineDiamondLane with groupAware=true, useAutoExpandKeyframedClips correctly derives fresh clip auto-expansion per-project with a seen.current.clips ref that survives collapse. Tests at TimelinePropertyLanes.test.tsx (433 lines) and useAutoExpandKeyframedClips.test.tsx (75 lines) are thorough — the flat-tween synthesized-endpoints case, the multi-group case, the independent-keyframe-positions case, and the auto-expand-stickiness-across-project-switch case all covered.
Concerns
-
🟠 Nothing at this SHA wires
TimelinePropertyLanesoruseAutoExpandKeyframedClipsinto the app. A whole-tree grep forTimelinePropertyLanesatfed5e5b71returns only its own definition file and its own test; same foruseAutoExpandKeyframedClips.TimelineLanes.tsxstill callsTimelineClipDiamonds(the adapter that forcesgroupAware=false) and never mounts aTimelinePropertyLanesfor an expanded clip. So this PR defines the property-lane surface but doesn't ship it — users see no change until whichever later slice mounts the component under the clip row. This is a fine stack-slicing pattern (build primitive, wire in next slice), but the PR titlefeat(studio): add timeline property lanesreads as user-facing when the code is scaffolding. Recommend: (a) confirm which subsequent slice wires it, and (b) mention that in the PR body so a reviewer landing this in isolation doesn't assume it activates on its own. If it turns out no wiring PR is planned, this is a dead-code merge — either roll it forward into the wire-up slice, or add a// TODO(follow-up): wired by #NNNNmarker at the top ofTimelinePropertyLanes.tsx. -
🟡
useAutoExpandKeyframedClipsshort-circuits on project-change-with-same-source-ref. AtuseAutoExpandKeyframedClips.ts:19-24, whenprojectIdchanges but the incominggsapAnimationsMap is the same reference as the previous project's map,sourceChangedisfalseand the effect returns without expanding. The dedicated test atuseAutoExpandKeyframedClips.test.tsx:70-72asserts this shape verbatim, so it's an intentional guard — but the behavior it locks in is unusual. In practice a project switch always brings a freshMap(the loader rebuilds keyframe data per project), so the guard fires against a case that doesn't happen; but if the loader ever does re-use a Map across projects (memoized cache, RTK query normalized store), auto-expand would silently no-op on project switch and a user opening a new project would see collapsed clips. Consider dropping the guard (always resetseen.clipson project change AND run the fresh-clip scan) or adding a code comment explaining what the guard protects against — right now the guard reads as a defensive workaround for a specific reproduction I can't identify.
Nits
hasGroupProperty(keyframe.properties, group)atTimelinePropertyLanes.tsx:26-31filters per-keyframe by property-group classification. An animation classified as"position"whose keyframe body sets only opacity would silently drop that keyframe from the position lane. Correct — the lane shouldn't show a diamond for a keyframe that has no position properties — but the classification is fine-grained enough that a subtle authoring pattern (mixing groups in one animation via GSAP overwrites) could confuse users when the collapsed row shows a diamond but the position lane doesn't. Worth a code comment on the "empty-in-group keyframe is dropped" behavior.getTimelineLaneTop(laneIndex)attimelineLayout.ts:12-14— no direct unit test. The consumer tests indirectly exercise it, but a two-lineexpect(getTimelineLaneTop(0)).toBe(TRACK_H); expect(getTimelineLaneTop(2)).toBe(TRACK_H + 2 * LANE_H);would diagnose regressions faster than tearing through TimelinePropertyLanes rendering to find a top offset.
What I didn't verify
- Whether the
groupAware=truepath ofTimelineDiamondLane(introduced in the base #2783) has any behavioral divergence when consumed by property lanes vs. by the collapsed adapter, since only the adapter is wired at 2783's SHA and this PR doesn't wire the direct consumer either. ThePromise<boolean>semantic concern I filed on #2783 activates only once a real property-lane consumer lands and takes the direct callback path.
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at fed5e5b71df505b2598fe31522eba1710ea62c7c (code-review max, R2, delta vs Rames's fresh pass at 4790114033).
Verdict
APPROVE.
Rames's fresh pass at head lifts one new 🟠 (dead-code / not wired at this SHA), one new 🟡 (sourceChanged guard reads as defensive-for-a-repro-I-can't-identify), and two nits. None are hard blockers on this branch's own diff — the 🟠 is a stack-slicing consequence (component defined here, wired in the tip PR #2791), the 🟡 is a latent edge case Miguel already confirmed as addressed at the tip, and the nits are code-comment / unit-test suggestions. My R1 APPROVE at this SHA holds; treating Rames's pass as concurring adversarial coverage, not a block.
Rames R3 findings — status at this head
| # | Sev | Finding | Location | Status at fed5e5b71 |
Verdict |
|---|---|---|---|---|---|
| 1 | 🟠 | TimelinePropertyLanes / useAutoExpandKeyframedClips not mounted by any consumer |
tree-wide grep | Confirmed. Direct file-content probe of TimelineLanes.tsx, TimelineClip.tsx, TimelineElement.tsx, TimelineElementLanes.tsx at head: 0 refs to either symbol outside their own definition + test files. Consumer wiring lands in the stack tip #2791 per Miguel's inline replies (all 8 dated 14:49Z 2026-07-27). |
Non-blocker. Standard build-primitive-then-wire slicing. If the tip PR merges alongside via Graphite squash, no dead code lands on main. Concur with Rames's ask to note the wire-up slice in the body — light doc-quality item, not correctness. |
| 2 | 🟡 | sourceChanged guard skips expand scan when projectId changes but Map ref is identical (useAutoExpandKeyframedClips.ts:19-24) |
new file | Confirmed at head — code reads exactly as Rames quoted. Behavior at head: projectId change with same source-ref resets seen.clips to new Set() and returns without running the expand scan; next render where source changes takes the else branch and scans. Latent until a loader memoizes the animation Map across projects (RTK Query normalized store, keyframe-cache memo, etc.). Miguel's inline reply (line 20, 14:49:57Z) claims fix at tip 6ee750fee. |
Non-blocker on this diff. No live surface (no consumer, per finding #1). Concur with Rames on the code-comment ask — the intent should be explicit in the source, not just the test assertion. |
| 3 | nit | hasGroupProperty per-keyframe classification silently drops keyframes whose body has no in-group property (TimelinePropertyLanes.tsx:26-31) |
new file | Confirmed. Line 74: if (!hasGroupProperty(keyframe.properties, group)) continue;. Correct behaviour (empty-in-group keyframes shouldn't show a diamond) but Rames's ask for a doc comment on the silent-drop is reasonable. |
Nit. Non-blocking. |
| 4 | nit | getTimelineLaneTop has no direct unit test |
timelineLayout.ts:12-14 |
Confirmed. Grep of test files for getTimelineLaneTop yields only indirect exercise via TimelinePropertyLanes.test.tsx inspection of data-timeline-lane-top. Two-line unit test would improve diagnosability. |
Nit. Non-blocking. |
Miguel's inline comments — status
Miguel posted 8 inline replies at 14:49Z 2026-07-27 (before my R1 at 18:10Z), one per Rames R2 (2026-07-25, review id 4780132524) inline finding, on lines 141, 140, 125, 120, 119, 72 (TimelinePropertyLanes.tsx) + 33, 20 (useAutoExpandKeyframedClips.ts). All 8 are identical templates: "Addressed at the stack tip in 6ee750fee (PR #2791). This branch is an ancestor in the same stack, so the fix ships with it. Verified on the tip: typecheck, oxlint 0/0, 2916 tests pass."
Cross-checked against head-SHA source: every one of the 8 findings still stands on this branch's own diff (globalEase = groupAnimations[0]?.keyframes?.easeEach … at :141, fresh keyframesData={{ format, keyframes }} object literal at :140, no ARIA on lane wrapper at :125, unmemoized getTimelinePropertyLanes at :120, redundant clipWidthPx < 20 gate at :119, animation.duration ?? clipDuration fallback at :72, seen-clips append-only at :33, project-switch guard at :20). Consistent with Miguel's "fix ships with the stack tip, not this branch." Non-blocking on this PR because the stack merges together.
Prior groupKeyframes finding — re-verified
Reconfirmed as latent P2 at fed5e5b71.
Head source (TimelinePropertyLanes.tsx:62-86):
function groupKeyframes(animations, group, clipStart, clipDuration): TimelineDiamondKeyframe[] {
const keyframes: TimelineDiamondKeyframe[] = [];
for (const animation of animations) {
const tweenStart = animation.resolvedStart ?? (typeof animation.position === "number" ? animation.position : 0);
const tweenDuration = animation.duration ?? clipDuration;
for (const keyframe of animationKeyframes(animation)) {
if (!hasGroupProperty(keyframe.properties, group)) continue;
const absoluteTime = toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage);
keyframes.push({
...keyframe,
percentage: ((absoluteTime - clipStart) / clipDuration) * 100,
tweenPercentage: keyframe.percentage,
propertyGroup: group,
animationId: animation.id,
});
}
}
return keyframes;
}Canonical helper at gsapShared.ts:246-290 (verified present at head):
toClipPercentage(gsapShared.ts:246-254) doesMath.round(((absoluteTime - clipStart) / clipDuration) * 100000) / 1000(0.001% precision).toClipKeyframes(gsapShared.ts:262-290) wraps it, uses the identicaltweenStart/tweenDurationfallback, setstweenPercentage,propertyGroup: anim.propertyGroup,animationId.- The canonical docstring names the reason verbatim: "Shared by the cache writers so they cannot drift in precision or in which identity fields they record."
groupKeyframes open-codes the same field shape while skipping the Math.round(... * 100000) / 1000 rounding step. sourceGroups (.tsx:51-59) only pushes an animation into its own animation.propertyGroup bucket, so anim.propertyGroup === group inside groupKeyframes — calling toClipKeyframes(animation.keyframes, animation, ...) per animation, then filtering by hasGroupProperty, is a drop-in equivalent that inherits the canonical precision. Refactor cost: minimal.
Why still latent, not live:
timelineKeyframeSelectionKeywithgroupAware=truenamespaces the selection key as${propertyGroup}:${animationId}:...— disjoint from the collapsed-row's cache-fed namespace, so a coarser rounding here can't orphan a selection across the two rendering surfaces.- No consumer mounts
TimelinePropertyLanesat this SHA (Rames R3 finding #1). Zero live surface until the stack tip lands.
Suggested fix (unchanged from R1): replace the inner-loop body with keyframes.push(...toClipKeyframes(animationKeyframes(animation), animation, clipStart, clipDuration).filter((kf) => hasGroupProperty(kf.properties, group))). Follow-up, not a block.
Fix-internal remaining-silent-X audit
Adversarial pass across the six internal-boundary categories (state-accumulation discard, return-boundary invariant, library defaults, precedence, session ownership, discovery completeness) — no new remaining-silent-X survives at head that R1 didn't already document:
- State-accumulation discard.
seen.current.clips = new Set()on project change — old Set GC'd.seen.current.sourcere-pointed. No leak. - Return-boundary invariant.
getTimelinePropertyLanesends with.filter((lane) => lane.keyframes.length > 0)— every returned lane has ≥1 keyframe. Empty-in-group lanes drop. Holds. - Library defaults.
animation.duration ?? clipDuration(Rames R2 :72) is a real silent fallback; masks upstream-parser missing-duration. Miguel says fix at tip. - Precedence.
globalEaseusesgroupAnimations[0]unconditionally (Rames R2 :141); display ease diverges from per-keyframe edit routing. Miguel says fix at tip. - Session / resource ownership.
usePlayerStore.getState().reset()in tests + per-hook-instanceseenref — no cross-instance leakage. - Discovery completeness.
sourceGroupsfor-of loop is exhaustive over animations with apropertyGroup.hasGroupPropertyfilter is per-keyframe (Rames R3 nit — correct behaviour, wants a doc comment). No skip path.
Standards lens re-run
Empty-count required:
- bare
as Tcasts (production): 0 - non-null
!./
What
Adds expandable per-property keyframe lanes and segment easing controls.
Why
A clip-level summary alone cannot expose which property owns a keyframe or which segment ease is being edited.
How
Renders property-specific diamonds, connecting segments, accessible ease controls, and navigation affordances from the canonical lane model. This is B4 of the Family B draft Graphite stack.
Test plan
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/04-pr-2686-deferred-review-findings.md:packages/studio/src/player/components/useAutoExpandKeyframedClips.ts:14— "Later user collapse sticks and never bounces back open" only holds within one hook-component lifetimepackages/studio/src/player/components/TimelinePropertyLanes.tsx:141— globalEase fallback for a group is taken from animations[0] regardless of which animation a keyframe belongs topackages/studio/src/player/components/useAutoExpandKeyframedClips.ts:20— Project switch that reuses the previous project's Map identity silently leaves new project fully collapsedpackages/studio/src/player/components/TimelinePropertyLanes.tsx:41— synthesizeFlatTweenKeyframes runs twice per flat-tween animation per renderSupersedes #2686, which was closed when
mainwas rewritten to unwind an early landing of this stack. Same head commit, same review history.R1 review follow-ups
Approved with no high findings. The 5 low/informational findings are parked in
.scratch/studio-timeline-family-b/issues/09-family-b-v2-r1-deferred.md.