Repository navigation
feat(studio): expose timeline treegrid semantics - #2713
miguel-heygen wants to merge 1 commit into
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
f191fe9 to
2d0f39e
Compare
f517fbd to
11d12c1
Compare
2d0f39e to
6970bd9
Compare
11d12c1 to
e1d4a4c
Compare
6970bd9 to
974a0f9
Compare
e1d4a4c to
228e426
Compare
974a0f9 to
0224171
Compare
228e426 to
1716f68
Compare
0224171 to
56c56ca
Compare
1716f68 to
2260abb
Compare
56c56ca to
6079dcd
Compare
2260abb to
1dfe7d8
Compare
6079dcd to
ed633c9
Compare
1dfe7d8 to
c4c95f4
Compare
c4c95f4 to
c8e7f3b
Compare
0b635c5 to
61e5b35
Compare
c8e7f3b to
3869327
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
R1 adversarial review — treegrid semantics
The semantic contract is right and thoroughly tested. Roles compose validly (treegrid > rowgroup > row > rowheader / gridcell), aria-rowindex sequence is asserted end-to-end, aria-expanded polarity is correct (attribute omitted when expandable=false, so SRs won't misannounce leaves as "collapsed"). Identity module extraction to timelineNavigationIdentity.ts gives stable JSON-encoded ids that survive reorder (element-identity keying — good), and useTimelineLogicalRows cleanly memoizes the physical-track lookup. sr-only level-2 rows preserve the logical tree even when property lanes visually collapse into the sticky header.
Adversarial pass — items to consider as follow-ups, not blockers on a semantics-only slice:
- [nit]
aria-multiselectable="true"without anyaria-selectedon rows. Per WAI-ARIA APG treegrid, SRs derive selectability presence from the attribute; without any row havingaria-selected="false|true", JAWS/NVDA won't announce a coherent selection state model. Wire this when the selection-plumbing PR in this stack lands. - [nit] No
aria-colcount/aria-colindex. The layout is a stable 2-column shape (rowheader + gridcell); APG recommends both. - [nit] Roving tabindex + key bindings not in this PR. Understood as scope carve-out (title says "expose ... semantics"), and
timelineKeyboardNavigation.tsonly picked up an identity-import rename here. Worth confirming the next-slice PR wiresArrow*/Home/End/*per APG on top of these semantics. - [info]
aria-ownson sr-only property rowheaders reparents visible widgets out of the level-1 rowheader into the level-2 row. Internally consistent, but atypical vs APG examples — a screen-reader user reaching the caret only via child property rows may be surprised. Documenting this in the design note would help future maintainers.
CI: Player perf / preview-regression / regression all SUCCESS at 3869327; UNSTABLE only because Graphite mergeability_check is IN_PROGRESS. No code smell in the diff.
— Review by Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 3869327c.
Wires the treegrid semantics onto the model from #2712. Structural split: identity generators move out of timelineKeyboardNavigation.ts into timelineNavigationIdentity.ts (natural boundary; keeps the model pure and the identity conventions reusable). useTimelineLogicalRows hooks the model into React with logicalRowsByTrack lookup for per-track slicing. TimelineTrackRow gets logicalRow/propertyRows props, becomes an outer role="rowgroup" + inner role="row" + N sr-only property rows that use aria-owns to reach visible DOM in TimelinePropertyLanes / TimelineTrackHeader. role="list" → role="treegrid" on the outer container; aria-posinset/setsize → aria-rowindex/rowcount.
Two inline: (1) 🟠 per-instance id collision on the new timelineLogicalRowCellId — the two-timelines-on-one-page invariant that used to be enforced via lanesIdPrefix no longer covers the new aria-owns targets; (2) 🟡 the useMemo cache depends on 7 refs from the store.
Verified end-to-end:
- ✅
aria-rowcountcomputed from fulllogicalRows.length(5 in the 3-track/2-property fixture). ARIA-conformant. - ✅
aria-rowindexuseslogicalIndex + 1(1-based) consistently across visible track row and sr-only property rows. - ✅
aria-levelis 1 for tracks, 2 for property rows — matches the model'slevel: 1 | 2type constraint. - ✅
aria-expanded={logicalRow.expandable ? logicalRow.expanded : undefined}— property rows and empty tracks get NO attribute (undefined skips in React 18+), tracks with lanes get true/false. Matches WAI-ARIA treegrid convention. - ✅
role="rowgroup"is valid insiderole="treegrid"per ARIA 1.2 (semantically comparable to<tbody>). - ✅
role="rowheader"on the sticky track header +role="gridcell"on the track content. First-cell-as-row-header pattern is standard treegrid. - ✅ New
expandablefield onTimelineLogicalRow— true ifflanes.length > 0; property rows and empty tracks are false. Theexpandedfield implicitly requiresexpandable=truevia thelanes.length > 0gate. - ✅ Test at
Timeline.test.ts:263-269assertsaria-ownsattribute values match the referenced cells' ids — but the test doesn't validate both nodes mount together (see gap below).
PR-body promise audit:
- ✅ "Treegrid with stable row and cell identities" — treegrid role in place, ids stable across renders (JSON.stringify of stable inputs).
- ✅ "Screen readers get the same hierarchy sighted users see" — rowgroup > row > rowheader/gridcell hierarchy matches the visible sticky-header + content layout.
- ✅ "Complete logical row count stays independent of the mounted viewport" —
aria-rowcountuseslogicalRows.length, not the virtualized subset.Timeline.virtualization.test.tsx:206assertsaria-rowcount=1000on a 1000-row fixture with only ≤16 rendered. Good. - ⚠ "One identity module owns track, property, clip, keyframe, and easing focus IDs" — mostly true, but
timelineLogicalRowCellIduses a different format (${rowId}:${cell}) from the other five (JSON.stringify([...])). Inconsistent — the cell-id format doesn't survive a copy-paste consumer that expects JSON-arrays. Minor.
Nits (body-only):
- Ponytail comment loss at
TimelineLanes.tsx:104— the removed comment "Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal beside the main one) cannot both mint …" named a specific failure mode; the new comment "One stable id namespace per timeline instance links sticky headers to canvas lanes" doesn't. Per[[project_hf_ponytail_comment_idiom]], this is exactly the kind of load-bearing why worth marking// ponytail: two timelines on one page…. Related to the inline concern abouttimelineLogicalRowCellId. - No integration test that aria-owns targets mount concurrently —
Timeline.test.ts:263-269checksaria-ownsattribute values match id strings, but doesn't calldocument.getElementById(aria-owns-value)to verify the target exists in the tree. A virtualized-out row could have a live aria-owns pointing at unmounted DOM without any test catching it. Cheap fix: assert both nodes present. - Sr-only row content text at
TimelineTrackRow.tsx:74({keyframeCount} keyframes, {easeCount} ease controls) is not localized. If HF supports non-English UI in the future, this string needs to route through the i18n boundary. Not this PR's problem, but worth naming. useTimelineLogicalRowsprop shape usesPick<TimelineLaneBaseProps, …>— good for source-of-truth typing, but couples this hook to changes inTimelineLaneBaseProps. If HF later renames a field, the hook silently updates too. Acceptable trade-off for the current single-consumer, worth naming if the hook picks up a second consumer.
Ready from my side once the id-collision concern lands, leaving as COMMENTED.
3869327 to
0c5a3e1
Compare
61e5b35 to
2981a48
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Delta-reviewed 3869327c..0c5a3e17.
| # | R1 concern | R2 verdict | Anchor |
|---|---|---|---|
| I1 | timelineLogicalRowCellId doesn't propagate the per-instance lanesIdPrefix — two timelines on one page collide on aria-owns targets |
🟢 RESOLVED WITH REGRESSION EVIDENCE | timelineNavigationIdentity.ts:19-25 — timelineLogicalRowCellId(lanesId, rowId, cell) now takes the per-instance namespace as its first arg and returns ${lanesId}-${stableId("cell", rowId, cell)}. TimelinePropertyLanes.tsx:20 promotes id from optional to required (compile-time enforcement). TimelineTrackRow.tsx:72,75 + TimelineTrackHeader.tsx:227 + TimelinePropertyLanes.tsx:227 all thread lanesId through. TimelineLanes.tsx:103-105 restored the failure-mode comment with a ponytail: marker naming the collision mode. New regression at TimelineLanes.test.tsx:257-274 renders two timeline instances of the same clip and asserts: no cell-ID overlap, and aria-owns in each host resolves only into its own host's cell IDs |
| I2 | useMemo cache depends on 7 refs from the store — potential thrash |
🟡 STANDING (soft flag) — my R1 said non-blocking, deferring to the wiring PR to verify upstream selector stability | Not addressed in the delta, which is fine per the R1 framing. Recommend the wiring PR add a targeted perf test on a dense fixture asserting the memo doesn't recompute on a no-op state update |
Nothing else surfaced in the delta. The body-nit observations from R1 (no test that aria-owns targets mount concurrently, sr-only i18n, Pick<TimelineLaneBaseProps> coupling) remain soft flags for the wiring PR — though the new TimelineLanes.test.tsx regression does verify aria-owns targets exist in the same host tree, so the first body nit is partially closed.
Ready from my side, leaving as COMMENTED.
vanceingalls
left a comment
There was a problem hiding this comment.
R2 delta re-review at 0c5a3e1 (vs R1 3869327).
Verified:
-
Per-instance namespace scoping — FIXED.
TimelineLanes.tsxmintslanesIdPrefix = \timeline-lanes${useId().replaceAll(":", "")}`(React'suseId()guarantees per-instance uniqueness across concurrent mounts), then composeslanesId = `${lanesIdPrefix}-track-${row}`. Every logical-cell ID flows throughtimelineLogicalRowCellId(lanesId, rowId, cell)intimelineNavigationIdentity.ts, which prependslanesIdbefore the stable["timeline","cell",…]JSON.aria-ownson bothrowheaderandgridcellinTimelineTrackRow.tsx` uses that helper, so no cell ID escapes the instance namespace. -
Failure-mode comment — RESTORED.
TimelineLanes.tsxcarries// ponytail: One per-instance namespace prevents aria-controls and aria-owns from resolving into a second timeline that renders the same logical rows.above thelanesIdPrefixmint, documenting exactly the failure mode the fix prevents. -
Two-timeline regression — DRIVES REAL COLLISION. New test "mints lane ids that do not collide with a second TimelineLanes on the page" mounts two independent
renderLanes()roots, then asserts: (a)firstIds ∩ secondIds = ∅for allaria-controls; (b) same for[data-property-group][id]cell IDs; (c) everyaria-ownsin each instance resolves ONLY into that instance's own cell-id set; (d) IDs remain valid CSS-id selectors. Not presence-only — dropping theuseId()prefix would make both instances produce identical IDs and (a)-(c) would all fail.
Prior nits status:
- Nit-1 (
aria-selectedon rows): DEFERRED — belongs in selection-plumbing slice (grep confirms none added). - Nit-2 (
aria-colcount/aria-colindex): SKIPPED in this PR — worth a follow-up, non-blocking. - Nit-3 (roving tabindex + Arrow/Home/End/
*): DEFERRED — logical-navigation model is added intimelineKeyboardNavigation.ts(313L) but notabIndex/onKeyDownon the treegrid yet; that's the wiring slice. - Info (aria-owns reparenting): still present, now correctly per-instance scoped.
Nice work on the collision test — the aria-owns integrity assertion (per-instance ownership) is a bonus and would catch a partial-scoping mutation the ID-set check alone wouldn't.
— Review by Via
0c5a3e1 to
4155482
Compare
2981a48 to
d247c90
Compare
|
Review follow-ups folded into exact head |
vanceingalls
left a comment
There was a problem hiding this comment.
Delta re-verify at 415548272 against prior R2 head 0c5a3e170: genuine code changes, not pure rebase noise — and every prior-R2 finding is preserved plus one prior R1 nit is now addressed.
Prior findings preserved
TimelineLanesstill mintslanesIdPrefixfromuseId()(colon-stripped) and every cell id flows throughtimelineLogicalRowCellId(lanesId, rowId, cell)intimelineNavigationIdentity.ts.- Failure-mode comment
ponytail: One per-instance namespace prevents aria-controls and aria-owns from resolving into a second timeline...restored. - Two-timeline regression test mounts two
renderLanes()roots and asserts: noaria-controlsintersection, no cell-id intersection,aria-ownsresolves only into own cell-set, ids remain valid CSS selectors.
R1 nit progress at this HEAD
aria-colcount={2}on the treegrid andaria-colindexon everyrowheader/gridcell— with regression-test coverage. Previously logged as SKIPPED; now landed.aria-selectedand roving tabindex/Arrow/Home/End/*still deferred to the selection-plumbing slice / #2715 as scoped.
Family F invariants preserved
- Element-identity keying
el.key ?? el.idviagetTimelineElementIdentity. - No non-component exports from
.tsx(identity module is.ts, hook is.ts). - No
useEffectstate-syncing —useTimelineLogicalRowsisuseMemoon stable input refs. - Test upgrades trade
innerHTMLpresence checks for semanticvisibleText+ariaLabels— assertions guard meaning, not markup.
CI: required regression SUCCESS at HEAD; base is stack branch codex/studio-timeline-f-logical-navigation-v2, so the other 7 required checks are correctly NOT_RUN and will fire on retarget. Graphite / mergeability_check still IN_PROGRESS is expected.
— Review by Via
The base branch was changed.
4155482 to
15ef84c
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 15ef84ca1 — post-retarget rebase onto main.
Delta from last-reviewed 415548272:
All 13 scope files byte-identical between 415548272 and 15ef84ca1. git patch-id --stable on the diffs matches: 8510b7a20ce142f9a08a35eb1f105ee252a8aedf.
Pure rebase, no content changes. Prior R1 resolutions (treegrid ARIA hardening with aria-colcount === "2" + aria-multiselectable absence + aria-colindex verification in TimelineLanes.test.tsx:270-284; CSS.escape comment on stableId in timelineNavigationIdentity.ts:8; extended docstring in useTimelineLogicalRows.ts:17) all stand. Nothing new to review. LGTM from my side — safe to re-stamp.
vanceingalls
left a comment
There was a problem hiding this comment.
Delta re-verify at retargeted HEAD 15ef84ca1 (base now main directly, post-#2712 merge).
Patch-equivalence: byte-for-byte identical to prior stack-branch stamp at 415548272. Verified via diff <(gh api compare/d247c9056...415548272 --jq patches) <(gh api compare/main...15ef84ca1 --jq patches) — no drift. Same 13 files, same additions/deletions per file, same commit-message header ("feat(studio): expose timeline treegrid semantics"). Clean rebase.
Prior R2 findings state (all preserved by construction):
- Per-instance
lanesIdPrefixfromuseId()onTimelineLanes. - Every cell ID via
timelineLogicalRowCellId(lanesId, rowId, cell). - "Ponytail" failure-mode comment on mint site.
- Two-timeline collision / aria-owns / CSS-id-legal regression test.
- Extracted
useTimelineLogicalRowshook. - ID identity split into
timelineNavigationIdentity.ts. TimelineTrackRowrowgroup>row with rowheader/gridcell colindex.aria-colcount+aria-colindexwith test coverage.expandableflag.
Family F invariants (el.key ?? el.id, deterministic tie-breaks, deletion fallback chain, no non-component exports from .tsx, no useEffect state-syncing): preserved (patch identical).
Required-checks rollup (HF ruleset 14211637, 8 required):
- Build: IN_PROGRESS
- Render on windows-latest: IN_PROGRESS
- Semantic PR title: SUCCESS
- Test: IN_PROGRESS
- Test: runtime contract: SUCCESS
- Tests on windows-latest: IN_PROGRESS
- Typecheck: IN_PROGRESS
- regression: SUCCESS
3/8 SUCCESS, 5/8 IN_PROGRESS, 0/8 FAILURE. mergeStateStatus: BLOCKED (CI still running).
APPROVE — patch-equivalent to prior stamp, no drift, no regressions. Land-ready pending the 5 in-flight required checks turning green.
— Review by Via
|
Superseded by #3031, which landed this reviewed Family F content on main through the consolidated tip. |

What
Expose the virtualized Studio timeline as an accessible treegrid with stable row and cell identities.
Why
Screen readers need the same hierarchy sighted users see, including tracks, expanded property lanes, row levels, selection state, and whether a row is expanded.
How
Track and property rows now publish treegrid semantics, logical row indexes, hierarchy levels, and expanded state. One identity module owns track, property, clip, keyframe, and easing focus IDs, while the complete logical row count stays independent of the mounted viewport.
This PR targets #2712 and remains one focused commit.
Test plan
The exact PR delta passes 26 focused timeline, treegrid, and zero-size virtualization tests plus Studio typecheck, Fallow, and the file-size gate.