feat(studio): add timeline keyframe retiming interactions#2783
feat(studio): add timeline keyframe retiming interactions#2783miguel-heygen wants to merge 3 commits into
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
1e99115 to
514a219
Compare
5816e70 to
50a9077
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Verdict: COMMENT — retime mechanism is sound (pointer capture correct, cancel path clean, single-write-per-drag, gesture-layer neighbour clamp, rapid-second-drag identity via pendingRetimeRef is well-tested), and the widened Promise<boolean> chain in timelineCallbacks.ts / TimelineLanes.tsx / useTimelineEditCallbacks.ts matches. The observations below are polish, not blockers; the deferred findings in the PR body correctly cover the pending-clear-by-clip-%-only + data-attr semantics, so I'm not repeating them.
Head SHA: 50a90772cabc4709e36d4fdd07758aac3e0a2e57.
Adversarial lenses
Lens A — drag lifecycle — mostly clean. setPointerCapture on pointerdown, releasePointerCapture on both pointerup (TimelineClipDiamonds.tsx:351) and pointercancel (:451); touchAction: "none" on the button (:439) gets touch parity; the grab-offset is captured once on pointerdown via d.startX + d.fromClipPct (:311-315), and previewClipPct is delta-based so mid-drag re-renders don't snap-to-cursor. Two gaps:
- P2 — Escape does not cancel an in-flight drag. No key handler is armed on pointerdown. Users can back out of clip/element drags elsewhere in the app with Escape; this diamond can't. Pressing Escape here just leaves the drag running until pointerup commits it. Suggest a
document.addEventListener('keydown', …)scoped todragRef.current !== nullthat mirrors the pointercancel branch atTimelineClipDiamonds.tsx:444-452(null the ref,setPreview(null), release capture). - P3 — no auto-scroll when the pointer nears the timeline viewport edge. Long clips scrolled off-screen can't be retimed to their edges without releasing and re-scrolling.
Lens B — constraint enforcement — solid. sortedClipPcts (:190) feeds both previewClipPct and resolveKeyframeDrag, so the gesture layer clamps to immediate neighbours + clip bounds before this file ever sees the drop. The subsequent clipToTweenPercentage extrapolation is defensively clamped to the animation's own tween-% range at TimelineClipDiamonds.tsx:378-385, so a boundary drag can't reselect an out-of-range tween-% (150%) even though the mutation would clamp back. Nice.
Lens C — undo integration — clean. One onMoveKeyframe per drag, fired only on pointerup (:404); setPreview during pointermove is visual-only, never persisted. resolveKeyframeRetime returns noop at keyframeRetime.ts:74/125 when the drop resolves onto the source (< 0.1% tween epsilon or within EPSILON_TIME on flat tweens), so a click with a few px of trackpad jitter can't sneak a phantom undo entry. Nothing to add.
Lens D — multi-select drag parity — flag.
- P2 — multi-select retime is not implemented. The lane accepts
selectedKeyframes: ReadonlySet<string>andisKfSelectedatTimelineClipDiamonds.tsx:300-302reads it, butonPointerDown/onPointerUponly operate on the pressed diamond (d.kfKey,target). If two or more keyframes are selected and the user drags one, only that one moves; the rest stay put. If Family-B's plan is "multi-select drag comes in a later stack PR" that's fine — just call it out here so reviewers don't assume the shipped selection primitive implies group retime. If multi-drag is intentionally scoped out of this stack, that's worth a short comment near the retime dispatch (:369-415).
Lens E — perf & throttling — mild.
- P2 —
setPreviewon every pointermove without rAF throttling.onPointerMoveatTimelineClipDiamonds.tsx:318-337callssetPreviewper event. React 18 batches, but on a 120Hz trackpad + a densely populated expanded lane row (each diamond'sReact.memostill evaluates its props), this is one render per event. ArequestAnimationFramegate (accumulate the latest clientX, flush once per rAF) would drop most of them and matches how the timeline's clip drag path throttles elsewhere. - P3 — post-drop snap-back window.
renderPctat:293reads only frompreview(cleared on pointerup at:350) orkf.percentage(stale cache until the mutation round-trips). Between pointerup and the next cache tick, the diamond visually snaps back to its old %. ThependingRetimeRefmap fixes the identity problem for rapid double-drags but doesn't influence rendering. If the GSAP mutation is synchronous in practice this is invisible; if it isn't, addingpendingRetimeRef.current.get(kfKey)?.clipPctinto therenderPctfallback would remove the flicker for free. Not a blocker — worth a quick check on a real clip.
Facade blast-radius
onMoveKeyframe widened to Promise<boolean> across four surfaces (timelineCallbacks.ts, TimelineLanes.tsx, useTimelineEditCallbacks.ts, TimelineClipDiamonds.tsx) — the ?? Promise.resolve(false) fallback in the TimelineClipDiamonds facade (:501) preserves the "undefined-callback → non-committing" semantic. handleGsapMoveKeyframe / handleGsapResizeKeyframedTween remain fire-and-forget on the callee side; the boolean is used only by pendingRetimeRef.clearPending and by the test that asserts a false result clears pending. Consistent.
PR-body claims audit
- "
Every commit in the stack typechecks independently" + "the widening belongs here" — matches: this PR is whereTimelineClipDiamondsstarts awaiting the commit result (:404), so pulling the return-type change forward into#2781/#2782would produce a mismatched signature on the callee. Correct placement. - Test coverage claims match diff: 5 new gesture tests (click-armed no-op, retimed reselect, rapid second retime, failed-pending clear, ambiguous ease segment) + 2 flat-tween tests in the pure resolver.
- Deferred findings section correctly matches the code (I re-verified :139/:424/:456).
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 50a90772cabc.
R2 adversarial pass on the retiming interactions slice. Two blockers flagged: (1) the Promise committed signal introduced in this PR is cosmetic — the mutation is fire-and-forget through commitMutation.catch, so committed always resolves true regardless of whether the server persist actually succeeded; (2) rapid-second retime is silently discarded because resolveKeyframeTarget(fromClipPct) looks up against the still-stale cache — a keyframe with pending clipPct=75 can't be located when the cache still shows 50. These interact: the pendingRetimeRef cleanup depends on !committed, which the first blocker breaks — so a silent persist failure leaves the pending entry permanently set to a nonexistent destination.
🟢 Verified clean
- keyframeRetime.ts round3 replacing round1 is consistent with test-file updates (33.3→33.333, 63.6→63.636)
- onPointerCancel handler clearing dragRef + preview and releasing pointer capture matches the drag armed by onPointerDown
- timelineKeyframeSelectionKey degenerate cases (no propertyGroup / no animationId) round-trip through diamond render and useTimelineKeyframeHandlers.toggleSelectedKeyframe with consistent shape
- The
easeAmbiguous && animationId !== undefinedgate correctly withholds the inline ease button on collided merged segments, per new tests in TimelineClipDiamonds.test.tsx - handleGsapMoveKeyframeToPlayhead / handleGsapRemoveKeyframe are (still) invoked with correct animId+tweenPct pair by useTimelineEditCallbacks for KeyframeDiamondContextMenu delete + move-to-playhead
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.
| const anim = selectedGsapAnimations.find((a) => a.id === target.animId); | ||
| const tweenStart = anim ? resolveTweenStart(anim) : null; | ||
| if (!anim || tweenStart === null) return; | ||
| if (!anim || tweenStart === null) return false; |
There was a problem hiding this comment.
🔴 Promise committed signal is cosmetic — mutation is fire-and-forget
if (decision.kind === "move" && decision.toTweenPct != null) {
handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct);
} else if (...) {
...
handleGsapResizeKeyframedTween(...);
} else {
handleGsapUpdateMeta(...);
}
} else {
return false;
}
return true;The entire PR feature (pendingRetimeRef + clearPending on !committed) rests on onMoveKeyframe returning a Promise reflecting whether the mutation actually persisted. But handleGsapMoveKeyframe, handleGsapResizeKeyframedTween, handleGsapUpdateMeta in useGsapSelectionHandlers.ts return void (line 342: void moveKeyframe(sel, animId, fromPercentage, toPercentage);), and moveKeyframe/resizeKeyframedTween in useGsapKeyframeOps.ts use void commitMutation(...).catch(error => trackGsapSaveFailure(...)) — fire-and-forget with a catch that only fires telemetry. onMoveKeyframe: async always resolves to true immediately after firing, regardless of whether server commit rejects, selection is stale, schema validator refuses, etc. The .then((committed) => if (!committed) clearPending()) in TimelineClipDiamonds is effectively dead code for anything except the two synchronous early-returns in useTimelineEditCallbacks itself.
Fix: Await the underlying handler in a variant that returns its commitMutation promise (e.g. moveKeyframeAsync on the actions context returning Promise) and thread it through handleGsapMoveKeyframe → useTimelineEditCallbacks. Or explicitly document that committed only reflects preflight validity — but then rename it so consumers stop treating it as a persist signal.
| ? keyframesData.keyframes | ||
| : keyframesData.keyframes.filter((k) => k.animationId === target.animationId); | ||
| // Clamp to the mapped tween range: clipToTweenPercentage extrapolates | ||
| // linearly, so a boundary drag past the range would otherwise reselect |
There was a problem hiding this comment.
🔴 Rapid second retime is silently discarded — pending FROM identity doesn't survive resolveKeyframeTarget
const fromTarget = pendingBefore
? {
...target,
percentage: pendingBefore.clipPct,
tweenPercentage: pendingBefore.tweenPct,
}
: target;
...
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {The diamond sends fromTarget.percentage = pendingBefore.clipPct (e.g. 75%) into the callback. But useTimelineEditCallbacks.onMoveKeyframe locates the keyframe by calling resolveKeyframeTarget(fromClipPct), which queries the still-stale keyframeCache via resolveTimelineKeyframeTarget → keyframes.find(item => Math.abs(item.percentage - pct) < 0.2). Cache still shows pre-first-retime position (e.g. 50%), so no keyframe within 0.2 of 75% exists → returns null → if (!target || !sel) return false;. The composed retime the TimelineClipDiamonds.test.tsx test verifies at the diamond boundary never actually completes at the mutation layer. Test only asserts onMoveKeyframe was called with percentage: 75, not that anything landed.
Fix: Serialize the second mutation behind the first (chain the promises), or thread animationId + tween-% from fromTarget directly into a mutation that keys on animation identity + tween-% rather than resolving clip-% back through the stale cache. TimelineKeyframeTarget.animationId is already available end-to-end for exactly this reason.
| // position; the mutation locates the source keyframe by this identity. | ||
| const pendingBefore = pendingRetimeRef.current.get(kfKey); | ||
| const fromTarget = pendingBefore | ||
| ? { |
There was a problem hiding this comment.
🟠 Optimistic seek + selection fires synchronously before commit → ghost playhead/selection on failure
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
if (!committed) clearPending();
}, clearPending);
// A retime still targeted this exact diamond — park/select it at its
// new position, same as a plain click, or a drag that actually moved
// something looks identical to one that silently did nothing.
onClickKeyframe?.({
...target,
percentage: res.toClipPct,
tweenPercentage: newTweenPct,
});onClickKeyframe executes synchronously, immediately after the async onMoveKeyframe is fired. Both Timeline.tsx's inline onClickKeyframe (onSeek?.(absTime) + toggleSelectedKeyframe(...)) and useTimelineKeyframeHandlers' equivalent perform seek + selection based on ATTEMPTED destination — before commit resolves. If mutation later fails (via any of the two paths above, or committed=false from the sync early-return), diamond's next re-render snaps back to original clip-%, but playhead sits at res.toClipPct and selectedKeyframes still contains phantom elId:res.toClipPct key that no longer maps to any diamond. Nothing rolls that back.
Fix: Defer seek + selection into the same .then((committed) => committed && onClickKeyframe(...)) continuation, or (for immediate visual response) roll back seek/selection in .then((committed) => !committed && rollback()) branch.
| pointerEvents: "auto", | ||
| }} | ||
| onMouseEnter={() => setHoveredSegment(i)} | ||
| onMouseLeave={() => setHoveredSegment((h) => (h === i ? null : h))} |
There was a problem hiding this comment.
🟠 Segment ease button is not keyboard-reachable
onMouseEnter={() => setHoveredSegment(i)}
onMouseLeave={() => setHoveredSegment((h) => (h === i ? null : h))}
>
{hoveredSegment === i && (
<button
type="button"
data-keyframe-ease-button=""
aria-label={`Edit ${ease} easing`}Ease button is only conditionally rendered when hoveredSegment === i, and hoveredSegment is set exclusively by onMouseEnter/onMouseLeave. No keyboard-driven code path sets hoveredSegment; button doesn't exist in DOM until pointer hovers. Keyboard-only users (Tab traversal, screen-reader) cannot open segment ease editor from inline diamond row. aria-label is set correctly but only on element that never mounts without mouse hover.
Fix: Render the button always (behind opacity: 0 / focus:opacity-100 / group-hover:opacity-100 treatment) so it participates in tab order, or add onFocus/onBlur handlers on wrapper mirroring onMouseEnter/onMouseLeave.
| const dragRef = useRef<DragState | null>(null); | ||
| // Pending retime destination (clip + tween %) per keyframe key, so a rapid | ||
| // second drag composes from where the first move left the keyframe (whose | ||
| // cache entry has not rebuilt yet) instead of the stale rendered value. |
There was a problem hiding this comment.
🟠 pendingRetimeRef leaks permanently on silent persist failure
useEffect(() => {
// Clear a pending entry once the authoritative cache reflects a keyframe at
// ~its destination. Match by tolerance, not equality: cache writers round
// clip %s, so an exact check would leak an entry after every successful retime.
for (const [key, pending] of pendingRetimeRef.current) {
if (keyframesData.keyframes.some((k) => Math.abs(k.percentage - pending.clipPct) < 0.2)) {
pendingRetimeRef.current.delete(key);
}
}
}, [keyframesData.keyframes]);Pending entry is cleared in only two places: (a) !committed branch of promise callback, (b) this effect when cache reflects a keyframe at destination. Combined with finding #1 (committed always true) and finding #2 (rapid second retime fails at resolver), a silent persist failure leaves the pending entry set to a nonexistent destination. Cache never gains a keyframe at that %, so this effect never fires. On next drag of same diamond, fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage uses dead destination — mutation resolves against a keyframe that isn't there, hanging the diamond in a permanently-broken drag state until unmount.
Fix: Add a bounded lifetime for pending entries (e.g. drop after N ms with no cache update), or clear pending in the .then continuation regardless of committed once promise resolves (accepting small race with a follow-up drag).
| }: UseTimelineKeyframeHandlersInput) { | ||
| const onClickKeyframe = useCallback( | ||
| (el: TimelineElement, pct: number) => { | ||
| (el: TimelineElement, target: TimelineKeyframeTarget, options?: { seek?: boolean }) => { |
There was a problem hiding this comment.
🟠 useTimelineKeyframeHandlers refactor lands with no production consumer
export function useTimelineKeyframeHandlers({
expandedElements,
keyframeCache,
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu,
toggleSelectedKeyframe,
}: UseTimelineKeyframeHandlersInput) {At pr-2783-r3, the only files that reference useTimelineKeyframeHandlers are the hook itself and its test file. Timeline.tsx (the actual consumer of TimelineClipDiamonds) inlines its own onClickKeyframe={(el, pct) => { ... toggleSelectedKeyframe(${elKey}:${pct}) ... }} (lines 496-509 at pr-2783-r3) and doesn't call this hook at all. Hook's Target-aware refactor, new onSelectSegment path, and setActiveKeyframePct-from-target logic have no runtime effect. Tests exercise dead code — TimelineDiamondLane never calls the hook's handlers because shim TimelineClipDiamonds always down-projects targets to plain percentages, and parent Timeline.tsx wiring uses legacy percentage-only callback signature.
Fix: Either wire useTimelineKeyframeHandlers into Timeline.tsx (replacing inline handlers) in this PR so the abstraction has a live consumer, or split hook + tests into a separate follow-up that lands with its consumer. Landing tested-but-unused abstractions makes future refactors deceptively riskier.
| // shrink to fit under the strip. | ||
| const diamondSize = beatsActive | ||
| ? Math.round(clipHeightPx * 0.45) | ||
| : Math.round(LANE_H * DIAMOND_RATIO); |
There was a problem hiding this comment.
🟡 Neighbour clamp uses stale cache positions, allowing reorder across not-yet-persisted neighbour
const sorted = keyframesData.keyframes
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
.sort((a, b) => a.percentage - b.percentage);
// Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs
// the whole row to bound the dragged diamond between its immediate siblings.
const sortedClipPcts = sorted.map((k) => k.percentage);sortedClipPcts (used by clampToNeighbors in resolveKeyframeDrag / previewClipPct) is computed from cached clip-%s, not from pending destinations. Suppose keyframe A has pending clipPct=75 and neighbour B is at cached clipPct=60 (B is really to the LEFT of A per the pending state, but cache still shows A@50, B@60). Now the user drags A: d.fromClipPct = 75 (from pending), but sortedClipPcts = [..., 50, 60, ...] with A at index for 50, so right-neighbour clamp bounds A to 59.5% max — dragging A rightward from real position of 75 immediately snaps it BEFORE B, silently reordering. NEIGHBOR_EPSILON_PCT comment says clamp prevents equal/cross, but with stale positions can force cross.
Fix: Build sortedClipPcts from pending-composed positions: for each keyframe, use pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage before sorting. Recompute draggedIndex from that composed array so clamp and fromClipPct agree on which keyframe is where.
| // cache entry has not rebuilt yet) instead of the stale rendered value. | ||
| const pendingRetimeRef = useRef(new Map<string, { clipPct: number; tweenPct: number }>()); | ||
| useEffect(() => { | ||
| // Clear a pending entry once the authoritative cache reflects a keyframe at |
There was a problem hiding this comment.
🟢 pending cleanup effect matches ANY keyframe at destination, not the same one
for (const [key, pending] of pendingRetimeRef.current) {
if (keyframesData.keyframes.some((k) => Math.abs(k.percentage - pending.clipPct) < 0.2)) {
pendingRetimeRef.current.delete(key);
}
}Predicate is .some(k => |k.percentage - pending.clipPct| < 0.2) — clears whenever ANY keyframe in row happens to sit near pending destination %, not the specific keyframe identified by key. In a multi-keyframe animation, if the retime tracked silently failed (finding #5) but an UNRELATED keyframe on the same lane happens to be at ~that %, pending gets cleared falsely, and subsequent drags on originally-moved diamond fall back to stale cache position rather than pending one. Easy to hit with evenly-spaced keyframes (0/25/50/75/100) when a retime lands near an existing sibling %.
Fix: Include identity (kfKey / animationId + tweenPercentage) in predicate, e.g. keyframesData.keyframes.some(k => timelineKeyframeSelectionKey(elementId, kfTarget(k)) === key && Math.abs(k.percentage - pending.clipPct) < 0.2).
| if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); | ||
| else onClickKeyframe?.(kf.percentage); | ||
| if (e.shiftKey) onShiftClickKeyframe?.(target); | ||
| else onClickKeyframe?.(target); |
There was a problem hiding this comment.
🟢 newTweenPct clamp uses stale keyframe range for boundary-resize case
const tweenPcts = animKfs
.map((k) => k.tweenPercentage)
.filter((v): v is number => typeof v === "number");
const clampTween = (v: number) =>
tweenPcts.length
? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
: v;
const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));For within-window drag this is fine, but resolveKeyframeRetime can also return kind: 'resize' — which grows tween window and REMAPS every existing keyframe's tween-% (per pctRemap in keyframeRetime.ts). Clamp uses pre-resize min/max of tween-%s from animKfs (currently in cache), so on rightward boundary drag past tween end, newTweenPct is clamped to current max (typically 100) even though mutation is about to remap dragged keyframe onto new resized window where correct value might be lower (e.g. previous last keyframe now sits at ~83% and dragged one sits at 100). onClickKeyframe toggles a selection key against newTweenPct that doesn't match any diamond cache will produce.
Fix: For boundary-resize path, don't compute newTweenPct here at all — await cache refresh and re-derive selection, or replicate resolveKeyframeRetime's pctRemap logic locally so reselected tween-% matches post-resize world.
| }: TimelineClipDiamondsProps) { | ||
| groupAware = false, | ||
| globalEase = "none", | ||
| }: TimelineDiamondLaneProps) { |
There was a problem hiding this comment.
🟢 new Map allocated as useRef initializer argument on every render
const pendingRetimeRef = useRef(new Map<string, { clipPct: number; tweenPct: number }>());useRef(new Map(...)) evaluates new Map(...) on every render even though only the first is kept — React discards subsequent allocations. On a long timeline with many mounted TimelineDiamondLanes, wastes a Map allocation per render per lane. Cosmetic GC nit but fix is one line.
Fix: Use lazy init: const pendingRetimeRef = useRef<Map<string, {clipPct: number; tweenPct: number}> | null>(null); if (!pendingRetimeRef.current) pendingRetimeRef.current = new Map();
514a219 to
97dd041
Compare
50a9077 to
0779ac9
Compare
…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.
97dd041 to
41b0a68
Compare
0779ac9 to
32427d9
Compare

What
Adds direct timeline keyframe retiming interactions.
Why
Editors need to drag keyframes while preserving element identity, clip-relative timing, occupied-destination safety, and deterministic source updates.
How
Routes retime gestures through the shared timing model and mutation callbacks, with focused coverage for drag lifecycle and destination rules. This is B3 of the Family B Graphite stack.
Review fix folded in:
onMoveKeyframereturnsPromise<boolean>across the whole callback chain (timelineCallbacks->TimelineLanes->useTimelineEditCallbacks). This commit is whereTimelineClipDiamondsstarts awaiting the commit result, so the widening belongs here; without it the intermediate commits do not typecheck.Test plan
Every commit in the stack typechecks independently. Drag-to-retime verified in a browser: dragging a keyframe rewrites its percentage on disk while preserving both its value and its ease.
Deferred review findings
Every blocker and high finding raised on this PR is fixed in the stack. The 3 remaining low/nit findings are parked, verbatim, in
.scratch/studio-timeline-family-b/issues/03-pr-2685-deferred-review-findings.md:packages/studio/src/player/components/TimelineClipDiamonds.tsx:139— useEffect clears pending entries by clip-% only, ignoring keyframe identitypackages/studio/src/player/components/TimelineClipDiamonds.tsx:424— data-keyframe-percentage attribute is polysemouspackages/studio/src/player/components/TimelineClipDiamonds.tsx:456— onPointerCancel clears drag state but never fires the click-fallbackSupersedes #2685, which was closed when
mainwas rewritten to unwind an early landing of this stack. Same head commit, same review history.R1 review follow-ups
Fixed in this PR:
Scoped out, documented at the dispatch site: multi-select drag would have to move every selected keyframe as one mutation, which the script ops do not express yet.
The 2 remaining low findings (edge auto-scroll, post-drop snap-back window) are parked in
.scratch/studio-timeline-family-b/issues/09-family-b-v2-r1-deferred.md.R2 review follow-ups
Fixed in this PR: