Skip to content

fix(studio): announce real track numbers and wire lane aria-controls - #2848

Merged
miguel-heygen merged 19 commits into
mainfrom
fix/studio-track-numbers-aria
Jul 28, 2026
Merged

fix(studio): announce real track numbers and wire lane aria-controls#2848
miguel-heygen merged 19 commits into
mainfrom
fix/studio-track-numbers-aria

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

Timeline track accessibility: screen readers announced a raw floating point sort key, and the disclosure caret pointed at the wrong subtree.

Bugs fixed

  1. Screen readers announce "Hide track 0.16666666666666666". The timeline's track key is a fractional z-order sort value (an expanded sub-composition child gets host.track + n / (siblings + 2)), and the track header built its visibility label straight from it. The Track N fallback used when a track holds no labelled element had the same defect.
  2. The undo history entry has the same fractional label. Hiding an expanded sub-composition child recorded "Hide track 0.16666666666666666" in the history stack.
  3. The layer disclosure caret's aria-controls names a 0x0 element. It pointed at a div in the sticky label column. That subtree is not empty, it holds the per-lane keyframe controls, but its children are all absolutely positioned so the div computes to 0x0. The diamonds the caret visibly reveals live on the canvas instead, so the caret expands two disjoint subtrees and was naming the less useful one.
  4. Every lane id collides with a second timeline on the page. The fix for bug 3 minted timeline-lanes-track-${row} from the render row alone. Two TimelineLanes instances (a mini-timeline in a modal beside the main one) would both mint timeline-lanes-track-0, and every caret's aria-controls would resolve to whichever mounted first. Not reachable with today's single consumer, fixed before it gains a second.
  5. A track with no display row is announced as a row that does not exist. trackDisplayNumber returned trackOrder.length + 1 for a key it could not find. That is indistinguishable from a real answer, so the label announced a row the user can see is wrong and nothing upstream could tell it had guessed.

Why

A screen reader user cannot act on "track 0.16666666666666666". There is no such track from their point of view, and no way to map it back to what they are looking at.

How

Pass a track's 1-based display row alongside its key. The row number goes in every label, the key keeps routing every callback (visibility toggle, lane context menu) because those need the real sort value.

Give the key-to-display-row conversion a single owner (timelineTrackDisplay.ts) and route both the header labels and the undo history label through it, so the two cannot drift apart again.

For the caret, TimelinePropertyLanes renders one static wrapper and takes the id. Static, not relative, so it establishes no containing block and the absolutely-positioned lanes keep resolving against the track-content div with identical geometry. TimelineLanes mints the id, since it is the only place that sees both ends of the disclosure, and mounts the wrapper in both disclosure states so the reference still resolves while collapsed.

The id prefix comes from useId, so it is per instance rather than per render row. React embeds colons in that value, which are legal in an id and in aria-controls but need escaping in a CSS #id selector, so they are stripped and the prefix stays plain.

trackDisplayNumber returns number | null, and trackDisplaySuffix drops the number from the label when there is no row: Hide track rather than an invented Hide track 4. Both callers build the order from the same elements the key came from, so null is unreachable by construction today.

TimelineLaneBaseProps moves to its own module. It is the contract shared by TimelineCanvas and TimelineLanes, and lifting it out keeps TimelineLanes.tsx under the 600-line cap.

Test plan

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

Studio suite green on this branch: 3087 passing, 0 failures. Typecheck, lint and format clean.

The undo history regression test toggles a track keyed 1 / 6 on purpose. A test on track 0 formats cleanly and proves nothing.

Manual testing here means driving the timeline in the browser and reading the resulting DOM, plus unit assertions on the exact aria-label and aria-controls strings and on the resolved target's contents. No live NVDA / VoiceOver / JAWS pass was run, so the accessibility tree is verified through the DOM contract an AT reads, not through an AT.

Not covered

  • Sixth of a 7-PR stack.
  • This covers the track visibility labels, the Track N fallback, the undo history label, and the disclosure caret. It is not a full timeline screen reader audit.
  • No live screen-reader verification (see the test plan above).
  • aria-controls still names only the canvas lanes wrapper, not both subtrees the caret expands. The attribute accepts a space-separated list, but naming the label column too surfaces a subtree a sighted user is not watching appear; the call site documents the choice. The label-column controls stay keyboard-reachable either way.
  • The Track N / Hide track N strings are not i18n-wrapped, matching the rest of the studio surface, which has no i18n layer to wrap them with.

…me percentages

A ruler press with no pointer movement settled the playhead at t=0 instead
of the clicked time. handlePointerUp replays pendingClientXRef, which only
the pointermove path wrote, so a plain click fell back to the ref's initial
0 and overwrote the correct pointerdown seek. Seed the ref on pointerdown.

The keyframe retime move branch also returned the raw quotient while the
resize branch rounded to 3dp, so values like 74.81203007518799% landed in
the user's source and churned the diff on every drag. Round at the point of
computation so the no-op test and the written value agree.
The 24x24 WCAG 2.5.8 overlay sits on a wrapper that outranks the diamonds,
so on a segment narrower than 24px it overhung them and won their hit test at
fit zoom. Gate the overlay on the clear span between the two diamonds and let
the button keep its 16x16 box below that.

Also renames the pointer target suite to say it asserts the classes that
produce the size, not the measured geometry, which happy-dom cannot see.
…ide press

Split hex-draft ownership so the hex input is the sole author of its
own text while editing (updateColorDraft no longer stamps a canonical
hex back over every keystroke), fixing snap-back on backspace and the
silent wrong-colour clobber on non-repeating hex values. Route hex
typing through the shared gesture transaction so outside-click and
Escape settle/cancel it like the other inspector fields, instead of
relying on a private onBlur commit that never fires once the panel
unmounts on outside-click.
Swap the panel's hand-rolled bubble-phase mousedown listener for the
shared useContextMenuDismiss hook, which adds Escape support and fixes
outside-click dismissal when a canvas gesture (e.g. marquee start)
calls preventDefault on pointerdown, which otherwise suppresses the
mousedown compat event entirely. Also wires up dialog ARIA (role,
aria-modal, id/aria-controls) between the trigger and panel.
The panel does not trap focus and leaves the rest of the editor operable,
so aria-modal would tell assistive tech the whole app is inert while it is
open. role=dialog plus aria-controls and Escape is the correct non-modal
disclosure shape.
The gesture resolver gated on six digits while parseCssColor accepts both
lengths, so #F00 previewed as nothing and committed nothing. The old onBlur
path parsed it, making this a behavioural loss rather than a pre-existing gap.

Also asserts that an incomplete hex is restored on outside-click, not merely
left uncommitted.
A sub-composition tween's resolvedStart is composition-local, while the timeline
element resolved for it is the sub-comp HOST, whose start is main-timeline
absolute. toClipPercentage subtracted the two frames from each other, so a host
mounted at 1.5s cached its 0s tween at -12% and its last tween's end keyframe at
88% instead of 100%. A clip-relative percentage can never be negative.

resolveClipTimingBasis now returns the clip start in the frame the tween's own
times are measured in: the composition mount (expandedParentStart for an
expanded child, the parent composition clip's start otherwise, 0 for a
root-composition element) is subtracted, and a sub-comp inner element that falls
back to its host's window starts at 0 in that window. It moves to gsapShared so
the post-commit cache writer can share it instead of resolving its own basis,
which also gives that writer the sub-comp host fallback it was missing.
buildExpandedElements synthesized DOM-only sub-composition children against
the top-level element rather than the parentHost it resolves immediately
after. Under two-level nesting every child row therefore inherited the
top-level window instead of its own host's, so the rows drew at the wrong
offset and duration.
The sub-composition drill-in replaced the host row with its children. Since
expansion is also driven by the playhead alone (paused auto-expand), an
ordinary seek into a sub-composition made the host row disappear, taking its
keyframe lane with it: diamonds render per row from keyframeCache.get(
elementKey), so no row means no diamonds. Reproduced live with no drag at
all, seek 0 gave 3 diamonds, seek 7.68 gave 0, seek 0.2 gave 3.

Make the expansion additive instead. The host row stays and its children are
appended directly below it. The synthetic fractional lanes already used for
children sit strictly between the host's lane and the next integer, so the
host keeps its own row without colliding with anything.

The time-keyed auto-expand itself is unchanged.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-track-numbers-aria branch from 23bed54 to d6aa760 Compare July 28, 2026 16:09
@miguel-heygen
miguel-heygen force-pushed the fix/studio-playhead-keyframe-target branch from ff16b42 to 295f98b Compare July 28, 2026 16:09
Drilling two levels deep spared only the top-level row, so the middle host
lost its row and its keyframe lane with it. Spare every host between the
drilled one and the top, and anchor the children under the deepest host that
actually has a row.

Also stops resolveClipTimingBasis handing back a main-timeline start when a
clip names a parent composition that is absent from the element list. The
mount is unknowable there, so the child's own window is the only safe frame.
Two halves of one inversion in the expanded timeline lanes: the tweens
that should show were filtered out, and a tween that should not be there
was the only survivor.

Lane classification read the parser's whole-tween verdict, which is
undefined for anything spanning more than one property group. `{x,
opacity}` is the canonical HyperFrames entrance tween, so five of the
seven tweens in the swiss-grid graphics example had no caret, no
reserved row and no diamonds. Classify per property instead, through one
helper both the rendered lanes and the reserved row heights count
through so they cannot drift again.

Attribution matched an unanchored leading id, so `#stat3 .block` was
filed under `#stat3`. The child's diamonds landed on its ancestor and
collided with the ancestor's own tween at the shared percentage, which
the same-percentage merge then resolved by dropping the ease. Route
attribution through resolveSelectorElementIds, which anchors a
whole-selector id and otherwise resolves through the live preview DOM,
and anchor its no-DOM fallback so a descendant selector resolves to
nothing rather than to its ancestor. The merge rule is unchanged.

Also brings the last property-lane call site onto the shared clip timing
basis: an expanded sub-composition child's start is host-absolute while
its tweens are local to its own file.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-playhead-keyframe-target branch from 295f98b to 37140af Compare July 28, 2026 17:03
@miguel-heygen
miguel-heygen force-pushed the fix/studio-track-numbers-aria branch from d6aa760 to 9cec37c Compare July 28, 2026 17:03

@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.

R1 adversarial review — accessibility lens set (12 a11y + 12 editor-UI)

Head: 9cec37c15834000377fbeb08a5e56fede95abf21 (base fix/studio-playhead-keyframe-target, downstack pending)
Verdict: APPROVE — no P0 / P1. This is a clean fix with the right seams.

The PR does two independent things and both hold up under grep:

  • Announcement layer: TimelineElement.track is a fractional z-order sort key (host.track + n / (siblings + 2)), so labels built from it read out "Hide track 0.16666666666666666". The fix threads a separate trackDisplayNumber (1-based row) through the announcement path — TimelineTrackHeader's aria-label + title, the Track N fallback, and toggleTimelineTrackHidden's undo-history label — while every callback and lookup still routes by the raw track key. Ownership of key → display row is single-sourced in the new timelineTrackDisplay.ts, and both call sites (renderer + undo history) go through it, so the header and the history entry cannot drift apart.
  • aria-controls target: the caret's aria-controls used to name a <div> in the sticky label column whose children were absolute-positioned, so it computed to 0x0 and held no diamonds. TimelinePropertyLanes now wraps its lanes in one static <div id={id}>; static (not position: relative) so it is not a containing block and every absolutely-positioned lane still resolves against the track-content div — verified explicitly by leaves every lane's inline offsets untouched by the wrapper. TimelineLanes mints the id (only place that sees both ends of the disclosure), keys it by the integer row index rather than by the fractional trackNum (so ids can never be ...-0.16666666666666666, which would also break CSS #id selectors), and mounts the wrapper for the track's keyframe clip in both disclosure states so aria-controls resolves while collapsed.

Lens sweep

  • aria-controls id validity — grep for TimelinePropertyLanes JSX invocations: only one production caller (TimelineLanes.tsx), always passing id={lanesId}. Only one wrapper minted per track-row (guarded by isTrackKeyframeClip = STUDIO_KEYFRAMES_ENABLED && elementKey === keyframeClipKey, at most one such element per track). No hits for #timeline-lanes-track- anywhere else in the repo — no orphaned CSS/JS depending on the old ergonomics. When STUDIO_KEYFRAMES_ENABLED is off, keyframeClip becomes null and both the caret (!keyframeClip || lanes.length === 0PlainTrackHeader, no aria-controls) and the wrapper are dropped in lockstep. No dangling reference in that branch.
  • Track number stability — display row shifts when tracks are added/removed above; this is inherent to any 1-based announcement scheme and dramatically better than the fractional key it replaces. Not a defect.
  • Route-vs-announce split at both surfacesTimelineTrackHeader.test.tsx announces the display track number but toggles with the real fractional key and TimelineLanes.test.tsx hands the visibility toggle the real track key, not the display index + hands the lane context menu the real track key, not the display index pin both callback contracts. The undo history file adds labels the undo entry with the display row, not the fractional track key on track: 1 / 6 explicitly — a test on track: 0 would format cleanly and prove nothing (the PR body flags this too).
  • role / aria-label — the wrapper is a role-less <div> (fine for an aria-controls target); the per-group lane rows keep role="group" aria-label="{group} keyframes". LayerDisclosureRow's caret keeps aria-expanded + aria-label="Expand/Collapse {name} keyframes". No new interactive element is un-named.
  • aria-live / focus / tabindex / reduced-motion / contrast — nothing new added and nothing existing touched.
  • Consumer/producer split (aria-controls has one) — verified. The caret and the wrapper are minted in the same map iteration in TimelineLanes (id derived from the same row), so they cannot fall out of sync within a single render.
  • Multi-drag remountdoes not remount the lanes while a multi-clip drag slides the formation pins node identity (.toBe(before), not just .not.toBeNull()) across a preview-input update, which was the failure mode the propertyLanes-key comment describes.
  • Byte-identical geometry — asserted lane-by-lane on style.top/.left/.width/.height in the new TimelinePropertyLanes test, plus a specific guard that wrapper.style.position === "" (a slip to relative would silently reflow every lane).
  • Blast radius on the new wrapperTimelinePropertyLanes has one production caller (TimelineLanes.tsx); a fragment→<div> change would only have broken direct-child selectors, and there are none in either the diff or the rest of the studio tree.
  • Editor-UI 12 lens set — key/callback split, virtualization (unchanged; already fallow-ignore noted for the render loop), remount-safety, memoization (useMemo deps unchanged), keyboard reachability of the eye (still on the always-mounted layer row), pointer-cancel semantics (stopPropagation preserved), RTL neutrality, no useEffect synchronization introduced, prop-drilling minimal (two new props: trackDisplayNumber and lanesId, both documented at declaration).

Findings

Nit 1 — real-AT verification unclaimed in body. packages/studio/src/player/components/TimelineLanes.tsx:140 / undo history label at timelineTrackVisibility.ts:214. Unit tests assert the DOM aria-label string, but the PR body's "Manual testing performed" checkbox does not commit to an NVDA/VoiceOver pass. The DOM assertion is a strong proxy — aria-label is what the AT reads — but shipping this without a live SR spot-check is an evidence gap given the bug is by definition about what an AT user hears. Non-blocking; a one-line "Verified with that the row announces Hide track 2 after fractional-key toggle" in the PR body would close the loop.

Nit 2 — aria-controls could be space-separated to name both subtrees. packages/studio/src/player/components/LayerDisclosureRow.tsx:48 — the disclosure expands two disjoint subtrees (the label-column property rows and the canvas diamond lanes), and the code comment at TimelineTrackHeader.tsx:362 already acknowledges that. aria-controls accepts a space-separated list of ids; a version that names both would let AT surface both when following the reference. The current choice (canvas lanes only, because that's what a sighted user watches appear) is defensible ergonomics, not a defect. Non-blocking; the label-column controls are still keyboard-reachable via normal traversal after the caret is toggled.

Nit 3 — Track ${displayNumber} fallback string is not localized. packages/studio/src/player/components/TimelineLanes.tsx:157, TimelineTrackHeader.tsx:65. Studio does not appear to i18n elsewhere in this file — this matches the surrounding surface, so this is a "note for later" only. If Studio ever adopts i18n, Hide track {n} / Show track {n} / Track {n} are all announcement-critical strings that will need wrapping.

Verdict

Grade: A — surgical, correctly seamed, thorough tests. The split of "fractional key routes callbacks; 1-based row routes announcements" is captured in the type contract (trackNumber vs trackDisplayNumber are distinct required props with JSDoc), not just in convention, so future regressions have a real chance of being caught at the boundary. The three nits above are all non-blocking.

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 9cec37c.

Really clean a11y fix — three bugs found, three landed together, all with tests that exercise the actual failure mode. The timelineTrackDisplay.ts single-owner pattern is the right shape: trackDisplayNumber(timelineTrackOrder(elements), trackNum) now owns every user-facing row number (header labels, Track N fallback, undo history entries), so the fractional z-order key can't leak through any of them again. The TimelineTrackHeader.tsx prop-shape split (trackNumber for callbacks, trackDisplayNumber for labels) makes the invariant impossible to violate at the type level — good downstream defense.

The aria-controls retarget is the tricky one and worth calling out for anyone reviewing follow-ups:

  • Wrapper subtree change is correct. Old: the id lived on a static div in the sticky label column, whose children were absolute-positioned inside a .relative — so the box computed to 0x0 and the diamonds a sighted user watched appear were actually in a different subtree (canvas side). Screen readers following the reference landed on an empty box. New: the id lives on TimelinePropertyLanes's <div id={id}> on the canvas subtree, with the diamond lanes as DOM children (still absolute-positioned so the box still computes to 0x0 in-flow, but the accessibility tree walks DOM children regardless of visual extent — which is what NVDA / VoiceOver / JAWS all do).
  • Always-mounted in both disclosure states (TimelineLanes.tsx:452-462isTrackKeyframeClip gates mounting, not showsLanes) so aria-controls resolves while collapsed too, with animations={[]} feeding an empty inner tree. Confirmed by the collapsed-state test at TimelineLanes.test.tsx:209.
  • Multi-drag key stability (key={${clipKey}-property-lanes} at TimelineLanes.tsx:460) prevents the passenger-transform-wrapper from remounting mid-drag. Directly asserted by the node-identity test at TimelineLanes.test.tsx:221.

Concerns

Latent id-uniqueness assumption. lanesId = timeline-lanes-track-${row} at TimelineLanes.tsx:140 uses the 0-based render row as the id suffix. Unique within one TimelineLanes instance. But if Studio ever renders two TimelineLanes on the same page (e.g., a preview timeline + main timeline, or a mini-timeline in a modal), every track's aria-controls id collides across instances — #timeline-lanes-track-0 matches whichever instance mounts first. Non-blocking now, but useId() for a stable prefix on the enclosing component would future-proof it against a second consumer.

Screen-reader verification is manual, unspecified. The PR body says "Manual testing performed" but doesn't say which screen reader(s). Given the wrapper's 0x0-in-flow shape, a quick VoiceOver + NVDA pass on the caret's expand announcement + subsequent lane navigation would confirm the accessibility tree actually surfaces the diamonds as expected — the DOM contract is right, but the AT rendering surface is the ground truth.

Nit

timelineTrackDisplay.ts:22-23const row = trackOrder.indexOf(track); return row < 0 ? trackOrder.length + 1 : row + 1;. The < 0 branch always announces "the row it would land on at the end" (length + 1), even when a drag-preview is snapping between existing rows (say between row 2 and row 3). Probably fine for the current callers (visibility labels + undo history don't fire during in-flight drag), but if a future call site announces during drag-preview, the label would drift from the visible insertion caret.

Nothing blocking, CI green (perf, regression, preview all pass on the head SHA). LGTM from my side.

Review by Rames D Jusso

animIdForProp matched on the parser's whole-tween propertyGroup, which is
undefined for a legacy mixed tween such as {x, opacity}. Such a tween never
matched, so an edit to either property fell through to the selection's
default animation, a different tween than the lane the user is editing.
Resolve through animationLaneGroups, the same per-keyframe helper the
rendered lanes and the reserved row heights already count groups with.
"Add keyframe at playhead" on an element with no id authored the bare class
buildStableSelector hands back, so one add on a `.group` wrote
`tl.to(".group", ...)`: a tween that animates all five siblings and that
resolveSelectorElementIds reads back as all five, collapsing their timeline
rows into one. It survived a reload, so the written file stayed un-editable.

writeTargetSelector is the write-side counterpart to selectorFromSelection
(which must keep returning the exact string findTweenAtTime compares against).
It resolves the element's own identity to a selector that addresses exactly
one element: `#id`, else `[data-hf-id="..."]`, else the selection's selector
when it is already unique, else a `:nth-child` path anchored on the nearest
identifiable ancestor (the selector + selectorIndex pair, resolved through the
DOM the index was counted in).

Applied to the two paths that author a NEW tween: the no-animation branch of
useEnableKeyframes and commitKeyframeAtTimeImpl. replace-with-keyframes still
writes the selection's own selector, since retargeting a tween the author
aimed at a whole group is a different decision from adding a keyframe.
writeTargetSelector returned the selection's bare selector whenever the
structural walk failed, including when a live DOM was there to check
against. An element detached between selecting and committing takes that
path, so the add re-authored the exact `.group` string the function exists
to replace. Return null instead: a failed walk against a live DOM is
evidence, not absence of it. Callers that cannot drop a user edit opt back
in with `?? selectorFromSelection` where the trade is visible.

The replace-with-keyframes paths had the mirror defect. The server deletes
and re-adds the tween, so their target string is a full rewrite, and they
derived it from the selection: promoting a set on a tween already narrowed
to `#scene > div:nth-child(3)` widened it back onto every class sibling,
undoing the narrowing an earlier add had made. They now keep the tween's
own authored target, matching what eight sibling commit modules already do.
…he lanes

The timeline's track key is a fractional z-order sort value, and the header
built its visibility label straight from it, so screen readers announced
"Hide track 0.16666666666666666". A track's 1-based display row is now passed
alongside the key: the row number goes in every label, the key keeps routing
every callback (visibility toggle, lane context menu). The same fix covers the
`Track N` fallback used when a track holds no labelled element.

The layer disclosure caret's aria-controls named a div in the sticky label
column. That subtree is not empty, it holds the per-lane keyframe controls, but
its children are all absolutely positioned so the div computes to 0x0, and the
diamonds the caret visibly reveals live on the canvas instead. The caret expands
two disjoint subtrees and was naming the less useful one. TimelinePropertyLanes
now renders one static wrapper (static, not relative, so it establishes no
containing block and the absolutely-positioned lanes keep resolving against the
track-content div with identical geometry) and takes the id. TimelineLanes mints
that id, since it is the only place that sees both ends of the disclosure, and
mounts the wrapper for the track's keyframe clip in both disclosure states so
the reference still resolves while collapsed.

TimelineLaneBaseProps moves to its own module: it is the contract shared by
TimelineCanvas and TimelineLanes, and lifting it out keeps TimelineLanes.tsx
well under the 600-line cap instead of pushing past it.
The timeline track key is a fractional z-order sort key: an expanded
sub-composition child gets `host.track + n / (siblings + 2)`. The undo
history entry for the eye toggle interpolated that key directly, so
hiding an expanded child recorded "Hide track 0.16666666666666666".

Give the key-to-display-row conversion a single owner
(timelineTrackDisplay.ts) and route both the track header labels and the
history label through it, so the two cannot drift apart again. The raw
key still routes the callbacks and lookups that need it.

Adds a regression test that toggles a track keyed 1 / 6; a test on track
0 formats cleanly and proves nothing.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-playhead-keyframe-target branch from 37140af to f04cdb7 Compare July 28, 2026 18:39
@miguel-heygen
miguel-heygen force-pushed the fix/studio-track-numbers-aria branch from 9cec37c to 477916c Compare July 28, 2026 18:39
vanceingalls
vanceingalls previously approved these changes Jul 28, 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.

R2 re-review — approval carries

Head: 477916cbe25dd6dd1877c09df3d826cd60b609e5 (was 9cec37c15834000377fbeb08a5e56fede95abf21 at R1).

Delta since R1. The R1 head's single commit was split during rebase into two: 1faa0cbd (fractional-key labels + aria-controls retarget + timelineLaneProps.ts split) and 477916cb (undo-history label routed through the shared display-row owner). The base advanced by 3 commits from the downstack. Combined PR diff against the current base is the same 13-file shape I stamped at R1: same file set, same +/- envelope (+618 / -129), same two new modules, same new tests. No behavior change from the reorganization; the second commit merely lifts the row + 1 derivation out of TimelineLanes.tsx's inline JSX into trackDisplayNumber(displayTrackOrder, trackNum), which is the same function toggleTimelineTrackHidden now calls — so the header label and the history label are guaranteed to render the same row number for the same track key.

Invariant recheck at new head.

  • Byte-identical geometry — TimelinePropertyLanes.tsx still wraps in one static <div id={id}>, and leaves every lane's inline offsets untouched by the wrapper still pins style.top/left/width/height lane-by-lane plus expect(wrapper.style.position).toBe(""). HOLDS.
  • Type-contract split — TimelineLaneBaseProps still lives in timelineLaneProps.ts and is imported as type by both TimelineCanvas.tsx and TimelineLanes.tsx. TimelineTrackHeader still requires trackNumber (fractional key) and trackDisplayNumber (1-based row) as separate props with JSDoc. HOLDS.
  • aria-controls always resolves — TimelinePropertyLanes still returns the identified wrapper even when animations is empty (still renders the identified wrapper when there are no lanes to show), and TimelineLanes.tsx still mounts it for the track's keyframe clip in both disclosure states (isTrackKeyframeClip, not showsLanes, gates the mount). HOLDS.

R1 nits — outcome at new head.

  • Nit 1 (no NVDA/VoiceOver claim in body): UNCHANGED. PR body still checks "Manual testing performed" without naming a screen reader. Rames flagged the same at R1; non-blocking, would still be closed by a one-line "Verified on " in the body.
  • Nit 2 (aria-controls could space-separate both subtrees): UNCHANGED. Caret still names only the canvas lanes wrapper. The new inline comment in TimelineTrackHeader.tsx:353 explicitly documents why (a sighted user watches the canvas diamonds appear, not the label-column controls), so the choice is now narrated at the call site.
  • Nit 3 (Track ${n} fallback not i18n-wrapped): UNCHANGED. Still Track ${displayNumber} at TimelineLanes.tsx:157, consistent with the rest of the studio surface.

Fresh adversarial pass — new code in 477916cb.

  • timelineTrackDisplay.tstimelineTrackOrder dedupes via Set then sorts ascending; track keys are deterministic (integer or host.track + n / (siblings + 2)), so same-value floats compare equal. trackDisplayNumber falls back to trackOrder.length + 1 for indexOf === -1; documented as "the row it would land on at the end". Rames' R1 nit about this branch always announcing the end row (even for drag-preview insertions between existing rows) still applies but has no current caller during drag — both call sites (visibility labels + undo history) fire outside in-flight drag. Non-blocking.
  • timelineTrackVisibility.ts — order derived from timelineElements (all elements), then filter to element.track === track. If a caller ever toggles a track with no elements, displayNumber returns fallback length + 1, which reads as "one past the last visible row" — degenerate but not wrong. Non-blocking; hard to see a caller reaching this path.
  • New tests key at 1 / 6, not 0 — the PR body explicitly calls out "a test on track 0 formats cleanly and proves nothing", which is the discipline these regression tests need. Both Hide and Show paths asserted.
  • Header/history parity — both label sites now go through trackDisplayNumber with the same input shape (displayTrackOrder upstream, timelineTrackOrder(timelineElements) in the undo path). One theoretical drift: if a caller of toggleTimelineTrackHidden passes a timelineElements array that differs from what TimelineCanvas derived displayTrackOrder from at the same moment, the numbers could disagree. Every producer I could find hands the same source-of-truth store selector to both — non-blocking.
  • Latent id-uniqueness (Rames' R1 concern): still timeline-lanes-track-${row}, still unique within one TimelineLanes instance. No new consumers introduced. Non-blocking.

CI: 8 pass, 4 skipping, 1 pending (Graphite mergeability — expected for a stacked PR).

Verdict

Grade: A — approval carries. Delta is a commit-shape reorganization plus a small readability tidy (lifting row + 1 into the shared helper), no material change. Three R1 nits unchanged, all still non-blocking.

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.

R2 reviewed at 477916c (delta from 9cec37c).

The R2 delta on this PR is a hoisted helper rather than a response to R1 — resolveAnimIdForProperty in TimelinePropertyLanes.tsx, exported so the property panel can route an edit to the tween that actually animates the property's group instead of the selection's default animation. Reads clean; the docblock names the exact bug it fixes (mixed tweens like {x, opacity} had undefined propertyGroup and got dropped from the match).

Test coverage for the new helper is thorough — mixed tween routes both groups to itself, non-matching groups fall through, single-group tween wins over mixed for its own property. Nice.

One small note on the helper: animations?.find((a) => animationLaneGroups(a).includes(group)) returns first-parsed match. If a source ever has TWO tweens that both animate the property's group (e.g. two opacity tweens), the router picks whichever the parser saw first, which isn't obvious from a caller. Not surfacing as a concern since it matches the previous behavior and the docblock is scoped to the mixed-tween bug — just a seam to be aware of.

R1 concerns from pullrequestreview-4800579862 are unchanged in this delta:

  • lanesId = \timeline-lanes-track-${row}` (TimelineLanes.tsx:140) — row-based key would collide if two TimelineLanesinstances ever co-mount. Not exercised today, butuseId()`-prefixed would future-proof cheaply.
  • Screen-reader manual-test claim doesn't specify which SRs — VoiceOver + NVDA pass would confirm the 0x0-in-flow wrapper actually surfaces diamonds into the AT tree.
  • trackDisplayNumber always-length+1 fallback for an unfound track (timelineTrackDisplay.ts:11) — likely-unreachable, but a sentinel or invariant assert would beat silently returning a plausible-looking row.

None are blockers; leaving here rather than re-stacking inline comments.

Review by Rames D Jusso

Two latent defects in the announcement path this branch adds.

- lanesId was keyed by render row alone, so a second TimelineLanes on the
  page (a mini-timeline beside the main one) would mint the same
  timeline-lanes-track-0 and every caret's aria-controls would resolve to
  whichever instance mounted first. The prefix now comes from useId, with the
  colons stripped so the id stays a legal CSS selector.
- trackDisplayNumber returned trackOrder.length + 1 for a key it could not
  find, which is indistinguishable from a real row: the label announced a row
  the user can see is wrong and nothing upstream could tell it had guessed. It
  returns null now, and trackDisplaySuffix drops the number from the label
  rather than inventing one.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Both R1 concerns that were code-actionable are fixed here at ba0d6406d, and the third is answered in the body rather than by code.

lanesId uniqueness (Rames, R1 and R2). Fixed. The prefix now comes from useId(), so it is per instance rather than per render row; two TimelineLanes on one page can no longer both mint timeline-lanes-track-0. React embeds colons in that value, which are legal in an id and in aria-controls but need escaping in a CSS #id selector, so they are stripped and the prefix stays a plain identifier. Locked by a test that mounts two instances, asserts the two id sets are disjoint, and asserts every id still matches /^[A-Za-z][\w-]*$/ (the existing aria-controls tests resolve through querySelector('#' + id), so a colon would have broken them silently).

trackDisplayNumber fallback (Rames, R1 nit and R2). Fixed, taking the sentinel option. It returns number | null now: trackOrder.length + 1 was indistinguishable from a real row, so a label could announce a row the user can see is wrong with nothing upstream able to tell it had guessed. A new trackDisplaySuffix drops the number from the label instead, giving Hide track rather than an invented Hide track 4. Both callers build the order from the same elements the key came from, so null stays unreachable by construction; the point is that a future caller that does reach it degrades honestly. New timelineTrackDisplay.test.ts covers the order, the row, the null, and both label shapes.

Screen-reader claim (Via nit 1, Rames R1). Not fixable by code, so the body now states it plainly instead of leaving "Manual testing performed" to imply more than was done: manual testing here means driving the timeline in the browser and reading the DOM, plus unit assertions on the exact aria-label and aria-controls strings and the resolved target's contents. No live NVDA / VoiceOver / JAWS pass was run, and "No live screen-reader verification" is now an explicit line under Not covered.

Via nit 2 (space-separated aria-controls) and nit 3 (i18n) are recorded under Not covered with the reasoning, rather than changed. Nit 2 is an ergonomics call the call site already narrates; nit 3 has no i18n layer to wrap against.

Rames' R2 note on resolveAnimIdForProperty picking the first parsed match when two tweens animate the same group: agreed it is a seam, and it matches the prior behaviour, so leaving it. Worth saying which way it would have to break to matter: the caller is a panel edit, so a second same-group tween would send the edit to whichever tween the parser saw first rather than the lane the user clicked. That is a distinct bug from the mixed-tween one this helper closes and would need its own fix at the lane, not here.

PR body updated with both new bugs. Studio suite: 3087 passing, 0 failures. Typecheck, lint, format clean.

@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.

R3 reviewed at ba0d640 (delta from 477916c — silent restack alongside the #2849 R2 restack).

Clean R3. Two of three R2 concerns closed silently:

  • lanesId uniqueness — CLOSED. TimelineLanes.tsx:93 now mints lanesIdPrefix from useId() (with colons stripped so the id stays legal inside a CSS #id selector). Two TimelineLanes instances co-mounted (mini-timeline in a modal + main timeline) no longer collide on ...-track-0. Docblock spells out the exact scenario — nice.

  • trackDisplayNumber always-length+1 fallback — CLOSED. timelineTrackDisplay.ts:26-32 now returns number | null, with the reasoning that "the only numbers available to return are indistinguishable from a real answer: a label would announce a row the user can see is wrong, and nothing upstream would ever learn it had guessed." Plus a companion trackDisplaySuffix helper — null → "", non-null → " 3" — that propagates the null case through every label (Track${suffix}, Hide track${suffix}, etc.) so no site invents a number. TimelineTrackHeader.tsx:59 and timelineTrackVisibility.ts:218 both flow through it. Same shape I was hoping for.

Still open (not restated inline):

  • The screen-reader manual-test claim in the PR body still doesn't specify which SRs were tested — a VoiceOver + NVDA pass would confirm the 0x0-in-flow TimelinePropertyLanes wrapper surfaces the diamond children into the AT tree as intended.

Nothing net-new in this delta introduces problems. Ready from where I sit; leaving as COMMENTED (stamp routing per usual).

Review by Rames D Jusso

@miguel-heygen
miguel-heygen changed the base branch from fix/studio-playhead-keyframe-target to main July 28, 2026 19:42
@miguel-heygen
miguel-heygen dismissed vanceingalls’s stale review July 28, 2026 19:42

The base branch was changed.

@miguel-heygen
miguel-heygen merged commit b1632f6 into main Jul 28, 2026
50 of 51 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-track-numbers-aria branch July 28, 2026 19:57
dahans-msft2 pushed a commit to dahans-msft2/hyperframes that referenced this pull request Aug 6, 2026
…umbers-aria

fix(studio): announce real track numbers and wire lane aria-controls
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