-
Notifications
You must be signed in to change notification settings - Fork 0
Review 4690
Delta re-review at the author's exact head, after our standing changes-requested and the treegrid scope ruling.
Review-4690.md did not exist when this round ran; the record below is
reconstructed from the PR's own review history on GitHub.
Verdict: request changes. The PR removed the row-level tree ARIA
(aria-level, aria-expanded) from useTableTreeData to clear eight serious
aria-conditional-attr axe entries, on the grounds that those attributes are
invalid on a plain <table>'s rows.
What we asked for, verbatim:
Hmm well this seems wrong. If we include tree data the table should probably be a treegrid. — inline,
packages/core/src/Table/plugins/tree/useTableTreeData.test.tsx:320
I think the fix might be to apply the treegrid row when including tree data. We will likely need to make a version of useTreeGridFocus to achieve the right behaviors. It's quite a bit though. Let me know if you wanna do it! — review body
So the round-1 findings were:
- [BLOCKS] Deleting the row ARIA removes the hierarchy from the accessibility tree instead of making it valid. The container is the invalid part, not the rows.
- [BLOCKS] A treegrid role without a keyboard model regresses below the plain table, so role and keyboard have to ship together.
Held for a ruling. The author asked whether v1 should be row focus or cell navigation, naming the cost correctly: the table becomes one tab stop and in-cell controls (sort headers, chevrons, selection checkboxes) stop being individually tabbable. That is a scope decision, not a review call, and it was queued.
Ruling, decided 2026-08-27: treegrid v1 uses row focus and documents that scope. Compose the existing tree and grid focus primitives; document that full cell navigation is outside the first version; existing interactive descendants must remain reachable; combinations requiring cell navigation are not silently claimed as supported.
The author rebuilt against that ruling in c88f6579.
#4690 feat(Table): treegrid semantics and row-focus keyboard model for the tree plugin, by @AKnassa (bucket: external contributor — not in .github/ENGOWNERS or .github/DESIGNOWNERS; gh api .../collaborators/AKnassa/permission → read)
c88f65799985124624b7ffa3ae4415264f9edeee
Author-authored, single-parent, on top of the author's own upstream merge
ed8eea94 — a real reply, not a main merge:
f520cc65 parents=c534f579 2026-08-03 AKnassa fix(Table): drop row tree ARIA outside treegrid
ed8eea94 parents=f520cc65,e6507a95 2026-08-27 AKnassa Merge upstream/main <- not an author reply
c88f6579 parents=ed8eea94 2026-08-27 AKnassa feat(Table): treegrid role + row-focus keyboard model
main has not moved under the touched files since e6507a95
(git log e6507a95..origin/main -- packages/core/src/Table packages/core/src/hooks/useTreeFocus.ts is empty), so nothing silently invalidated the PR.
LOOP VERSION: 1.6.0
AUDIT RUBRIC: 1.13 (Component-Audit-Rubric.md:3, public facebook/astryx wiki, read at gate 1)
LANE: full
WHY: changed public API (TableRenderProps.ref, TableTreeRowMeta.posInSet/setSize), a new behavior default (row tab stop + keyboard model), an Effect newly instantiated in Table's render path, a prior unresolved changes-requested, and a visible new state. Every one of these is a fast-lane disqualifier.
WHY 1: A tree table emits aria-level and aria-expanded on its <tr>s while the container is a plain <table>, so axe reports eight serious aria-conditional-attr violations and the attributes are invalid in context.
WHY 2: Invalid-in-context row ARIA is not merely a lint failure — a screen reader given role=table ignores level and expansion, so a person using one hears a flat list of rows and cannot tell depth, parentage, or which branches are open.
WHY 3: The job this surface exists for is reading hierarchy — an org chart, a file tree, a rolled-up report. Someone who cannot perceive depth or expansion state cannot do that job at all; they get the leaf rows with no idea what they belong to.
USER-FACING PROBLEM: A screen-reader user reading a tree table hears an undifferentiated list of rows — no depth, no parent, no open/closed state — so the hierarchy the table exists to show is not available to them.
PROBLEM SEVERITY: broken task — the task (perceive the hierarchy) cannot be completed by that user at all. Evidenced by the eight TableTree::*::aria-conditional-attr entries the repo had to suppress in .github/a11y-baseline.json.
VERDICT: clear
When a tree table actually has expandable rows, the table stops calling itself a plain table and calls itself a treegrid, which is the thing whose rows are allowed to carry depth and open/closed state. Because a treegrid promises a keyboard model as well as a name, the rows also become a single stop you can arrow through — down and up between visible rows, right and left to open, close, enter a child or step back to a parent. Responsibility sits with the tree plugin, which already owns what the hierarchy is, and it borrows the keyboard algorithm the tree list already uses rather than writing a second one. Flat data is left exactly as it was, so nothing changes for a table that has no hierarchy to describe.
SOLUTION (2 decisions · ~286 runtime lines of 734)
- Treegrid semantics:
role="treegrid"on the table while any row is expandable, andaria-posinset/aria-setsizebeside the existingaria-level/aria-expanded. → answers the stated problem. - Row-focus keyboard model: one roving tab stop across the visible rows, arrow/Home/End/Enter/Space, applied only when a row itself owns focus. → answers the stated problem; the ruling requires it to ship with the role.
Two seams the above required, neither independently shippable:
TableRenderProps.ref (a plugin can reach the <table>, merged with the consumer's ref) and useTreeFocus resolving the focused item through itemSelector instead of a hardcoded role="treeitem".
Every decision traces to the stated problem or to the ruling. Nothing is piggybacked.
BURDEN: low — no new component state, no new Effect written by this diff, no timers, no global or document listeners, no new theme surface. Two element-scoped React handlers on the <table>, two optional fields on an existing meta type, one optional field on an existing render-props type, and reuse of a landed hook.
BURDEN MATCH: proportionate — a broken-task accessibility problem, answered by adopting the platform's own pattern and an existing internal primitive rather than by new machinery.
VERDICT: clear
OWNER: useTableTreeData (the tree plugin) owns treegrid semantics and lifecycle; useTreeFocus owns the tree keyboard algorithm.
TIER 1: the Table plugin pipeline (transformTable, transformBodyRow), the shared focus-outline utility, mergeRefs.
TIER 2: none.
SEAMS: the plugin pipeline (an earlier plugin's table ref and htmlProps handlers), the consumer's own ref, sibling plugins (selection, sorting), flat data, RTL, nested tree tables.
BEHAVIOR UNIT: useTreeFocus — a named, exported, separately documented hook with its own focused tests, extended here with two cases that drive a non-treeitem host. Not inline.
| seam | driven result |
|---|---|
| built-in columns, default story | works — role=treegrid, one row tab stop, full keyboard model |
tree + selection (core-tabletree--with-selection) |
works — row stop plus every row's checkbox and chevron still in the Tab order; Space on the checkbox toggles it and is not intercepted |
tree + sibling sorting (core-tabletree--with-sibling-sorting) |
works — aria-posinset follows the rendered (sorted) order: Product Engineering 1/2, Platform 2/2 |
flat data (core-tabletree--flat-data-is-a-no-op) |
works — role stays the native table, no row ARIA, no tabindex
|
RTL (direction: rtl global) |
works — horizontal arrows flip: ArrowRight on a collapsed row steps to the parent, ArrowLeft expands |
an earlier plugin's ref on the <table>
|
works — Table.test.tsx and useTableTreeData.test.tsx both drive it; neither ref displaces the other |
| nested tree tables | per-instance useId marker in the row selector; an inner table's rows cannot join the outer roving set |
<Table> with custom children
|
the row transforms never run, so a children table with the tree plugin would get role="treegrid" over rows carrying no tree ARIA. Nothing in the repo combines them and the combination has no motivating consumer — recorded, not filed |
Two Effects coordinating refs/timers would force a behavior-unit block; this diff
writes none. The one layout effect in play is useTreeFocus's own tab-stop sync,
already inside the named unit.
VERDICT: clear
Screen-reader users of any app whose tree table has expandable rows get the hierarchy back: depth, parentage, open/closed, and position-in-set are exposed where they were previously either absent or invalid.
Keyboard users of those same tables get a row-level arrow model where before they had only the per-row chevron buttons. They also get one extra Tab stop per table, ahead of the in-cell controls.
Builders who already ship a tree table are enrolled without opting in: their
table's exposed role changes from table to treegrid, and their keyboard path
gains the row stop. A test querying getByRole('table') on such a table stops
finding it. This is the change's real reach and it is what the BREAKING slot and
the finding below are about.
Newly exposed: nothing. Flat data is byte-identical, and the resting pixels of a tree table are byte-identical (below).
VERDICT: note — the change reaches every existing tree-table consumer without an opt-in
The smallest real call site is unchanged — that is the point of the migration guarantee:
const {visibleData, treeConfig} = useTableTreeState({data, idKey: 'id'});
const tree = useTableTreeData(treeConfig);
<Table data={visibleData} columns={columns} idKey="id" plugins={{tree}} />;The consumer writes nothing new and gets something different — which is exactly the shape that has to be judged as surface rather than as a bug fix.
| change | public? | class | doc'd? | verdict | |
|---|---|---|---|---|---|
+ |
TableRenderProps.ref?: Ref<HTMLTableElement> |
yes — Table types barrel |
BodyRowRenderProps.ref, already landed and used by this same plugin's row transform |
yes — JSDoc on the field | ok |
+ |
TableTreeRowMeta.posInSet?: number |
yes | the existing {id, level, hasChildren, isExpanded} structural-meta set |
useTableTreeData.doc.mjs, both docs and docsDense
|
ok |
+ |
TableTreeRowMeta.setSize?: number |
yes | same | same | ok |
~ |
useTreeFocus(options.itemSelector) — now also resolves which item owns focus |
yes — @astryxdesign/core/hooks
|
widened meaning, not a new field |
useTreeFocus.doc.mjs, both blocks |
ok |
~ |
<Table> renders role="treegrid" when the tree plugin has expandable rows |
yes — rendered output | new to the system; scoped by the ruling | changeset + both doc blocks + story doc | see BREAKING |
OSSIFICATION: TableRenderProps.ref is the table-level twin of BodyRowRenderProps.ref, which already ships and which this very plugin already merges into — a member of a landed class, not a new concept. The two TableTreeRowMeta fields extend an existing structural-meta record with the two remaining treegrid position attributes; optional, so a hand-built config that omits them emits neither attribute. itemSelector's widened meaning has exactly one other caller of this hook on main — TreeList, which passes no itemSelector and therefore resolves through the unchanged [role="treeitem"] default; its 127 focused tests pass. Cost of being wrong on any of these is a deprecation cycle on an optional field, which is the cheap end.
VERDICT: clear
No new theme targets, no new tokens, no new variables. The focused row draws the
shared ring from focusOutlineStyles.focusVisible and overrides only the
offset (treeRowFocus, outlineOffset: calc(-1 * --focus-outline-width) at
:focus-visible) so the ring lands inside the row instead of on its neighbours.
Overriding the offset is the one thing focusOutline.stylex.ts explicitly
permits a component to do: "A component may still override the OFFSET (a ring
that must sit inset, or clear of a field border); width, style and color are not
restated anywhere." Width, style and colour all still come from the
--focus-outline-* tokens, so a theme's ring override reaches the row.
VERDICT: clear
BEHAVIOR: yes. For every existing consumer whose tree data has expandable
rows: the <table>'s exposed role changes from table to treegrid, and the
visible rows acquire one roving tab stop ahead of the in-cell controls. Nobody
opts in; the upgrade enrols them. Driven at the exact head — core-tabletree--default reports role=treegrid, and the Tab order is TR Engineering → BUTTON Collapse row → BUTTON Expand row × 3.
API: no — all three additions are optional; nothing stops compiling.
VISUAL: no at rest. before__at-rest__main.png and after__at-rest__head.png are byte-identical (sha256 85b967d4… for both), which is the expected contract here: the diff paints nothing new until a row takes keyboard focus.
THEME: no — no target added, removed or moved; the shared ring's tokens still resolve.
VERDICT: BLOCKS — a behaviour change to released surface is shipping as a patch changeset (see JUDGEMENT finding 1)
EFFECTS: the diff writes none. It instantiates useTreeFocus, which carries
one pre-existing useIsomorphicLayoutEffect (no dep array) that repairs the
roving tab stop after each commit. That Effect is new to Table's render path, so
it is measured rather than described.
| Effect + deps | external system | why render/handler cannot do it | measured render cost | lifetime + cleanup | focused test |
|---|---|---|---|---|---|
useIsomorphicLayoutEffect []-less, useTreeFocus.ts:322
|
the DOM's tabindex on the rows |
the correct tab stop depends on which rows exist after the commit; a handler cannot see rows that mount because of someone else's state change |
no render passes added — it sets attributes, never state; useTableTreeData-perf.test.tsx passes unchanged |
lives with the table, no listener to tear down |
useTreeFocus.test.tsx (roving-tabindex cases) + useTableTreeData.test.tsx roving-tabindex block |
RENDER: no measurable delta. Measured in Chromium at the exact head on
core-tabletree--expand-and-collapse-all, instrumenting tabindex
get/setAttribute on TR and the row querySelectorAll:
expand-all (9 -> 13 visible rows) 14 getAttribute 4 setAttribute 1 querySelectorAll
single toggle (13 visible rows) 6 getAttribute 0 setAttribute 1 querySelectorAll
probe-kit/table-treegrid-rowfocus.cjs sibling: /tmp/perf4690.cjs, SB_PORT=6283
So the cost is one tabindex read per visible row per commit, with writes
only when the stop actually moves — the redundant-write guard at
useTreeFocus.ts:280 is doing its job. That is the system's documented
"write the DOM, don't re-render" pattern, not a smell.
LISTENERS/OBSERVERS: two React handlers on the <table> element
(onKeyDown, onFocus). Nothing on document or window; no observers; they
unmount with the table.
LAYOUT: no forced reflow on a render path. The one getComputedStyle is
isRtlElement(treeRef.current), called lazily inside the horizontal-arrow branch
of the keydown handler — the standard isRtlElement.ts's own docblock sets
("invoke this lazily (on keydown, not on render)").
BUNDLE: no new dependency.
VERDICT: clear
VISUAL CHECK: manual frames required
WHY: the change introduces a visible state that did not exist before (a table row
holding keyboard focus, drawing a ring) and simultaneously claims resting pixels
are unchanged. Neither is settled by the visual job: Stable visual regression
on this head is green but explicitly deferred ("Broad stable scope is deferred
to the release gate"), and no story covers a focused row.
Before (main 2458b03b) |
After (head c88f6579) |
|---|---|
![]() |
![]() |
sha256 of both PNGs: 85b967d45d65c9dae14ed68ab81b0369b2fb3ba4d260774bc03b7b8c8356d5d2.
Byte-identical output is the expected contract for this pair — the diff adds no
resting paint, only a :focus-visible rule — so the identity is the finding, not
a sensor failure.
SENSOR RECEIPT — both frames, differing only on Build:
| Sensor | Expected | Observed | Pass? |
|---|---|---|---|
| Build | before 2458b03b… @ worktrees/main :6100 · after c88f6579… @ worktrees/review-4690-c88f6579-2pm :6283 |
as expected, git rev-parse in each root |
✅ (the only sensor that differs) |
| Story | core-tabletree--default |
same | ✅ |
| Theme |
neutral (.storybook/preview.tsx:157 initialGlobals.astryxTheme) |
data-astryx-theme=neutral |
✅ |
| Color mode |
light (preview.tsx:158) |
computed color-scheme: light
|
✅ |
| Direction | ltr |
computed ltr on the subject |
✅ |
| Viewport/media | 1100×520 @1, no forced-colors, no reduced-motion, fine pointer, hover | same | ✅ |
| Rendered state | tree column reads Engineering | Platform | Product Engineering | Design | Operations (authored from the story's default expansion before either page was opened) |
same on both | ✅ |
| Subject geometry | exactly 1 .astryx-table, visible, non-zero, in viewport |
1, visible, in viewport | ✅ |
| Settled render | fonts loaded, no Storybook error screen, zero page/console errors | same | ✅ |
| Image |
before__at-rest__main.png / after__at-rest__head.png, 1100×520, 22 949 B each |
same | ✅ |
main has no focusable rows, so this state has no "before" by construction;
Claim 1 is its control.
| After — row focused, light | After — row focused, dark |
|---|---|
![]() |
![]() |
The ring paints as a full-row rectangle inset inside the row, in both schemes,
despite the table's border-collapse: collapse (BaseTable.tsx:61) — the case
where an outline on a <tr> most often fails to render. Reached by real
keystrokes (Tab, then ArrowDown), not .focus(), so :focus-visible is
honest.
SENSOR RECEIPT — as above, plus:
| Sensor | Expected | Observed | Pass? |
|---|---|---|---|
| Rendered state |
focused=Platform ring=2px solid focusVisible=true (authored from the contract: the second visible row owns focus and draws the shared ring) |
same, both frames | ✅ |
| Color mode | light frame light; dark frame dark via the colorMode global, asserted through the rendered color-scheme
|
as expected | ✅ |
| Viewport | light 1100×520 @1 · dark 1100×420 @1 | same | ✅ |
Computed ring, read at the same moment: light 2px solid rgb(38,38,38),
dark 2px solid rgb(235,235,235), offset -2px, :focus-visible true.
VERDICT: clear
REMEDY SEARCH: not triggered — no proven visual defect
Driven in real Chromium at the exact head, port 6283, via
probe-kit/table-treegrid-rowfocus.cjs. Rows below are marked
driven, auto-covered (CI ran it on this head) or not looked at.
-
APG pattern named and implemented — driven. WAI-ARIA treegrid, row focus.
role=treegridon the table;aria-level1-based;aria-expandedon parents only;aria-posinset/aria-setsizeper sibling group. Observed at rest: Engineering 1/3 lvl 1 expanded, Platform 1/2 lvl 2, Product Engineering 2/2 lvl 2, Design 2/3 lvl 1, Operations 3/3 lvl 1 (noaria-expanded, correctly — it is a leaf). - Keyboard, full model — driven. ArrowDown/ArrowUp across visible rows; ArrowRight expands a collapsed row in place then enters its first child; ArrowLeft steps child→parent then collapses the parent; Home/End; Enter toggles. Each transition confirmed against both the focused element and the visible-row count.
-
RTL arrow flipping — driven. With Storybook's
direction: rtlglobal, ArrowRight on a collapsed row moves to the parent and ArrowLeft expands — mirrored, asuseGridFocus/useListFocusdo it. -
Interactive descendants stay reachable — driven. This is the ruling's own
condition, and it is a claim about consumer-composed DOM, so it is driven, not
read. Tab order on
core-tabletree--default:TR Engineering→BUTTON Collapse row→ three moreBUTTON Expand row. With the selection plugin: header checkbox →TR→ row checkbox → row chevron → next row's checkbox → next row's chevron. Exactly one row is a tab stop; every in-cell control keeps its own. -
In-cell keys are not intercepted — driven. ArrowDown with the chevron
focused leaves focus on the chevron and the row count unchanged. Space with a
selection checkbox focused toggles it (
false → true) and focus stays on the input. -
Focus is not dropped to
<body>by any shipped affordance — driven. Every path the component itself offers keeps focus on a surviving element: ArrowLeft-to-collapse leaves focus on the parent row, the chevron and the expand-all header control are themselves the focused element when activated. A programmatic collapse of an ancestor while a descendant row holds focus does drop to<body>(the row unmounts under focus) and the roving stop survives; no shipped affordance reaches that state, and collapsed subtrees unmounting is pre-existingmainbehaviour, so this is recorded rather than filed. -
axe — auto-covered, and the strong direction.
pr-a11yis green onc88f6579and the PR deletes eight baseline entries rather than adding any: the wholeTableTree::*::aria-conditional-attrblock comes out of.github/a11y-baseline.json(−40 lines, +0).pr-rtlis also green on this head. -
i18n — clear. No new user- or AT-facing string; no catalog change; the
chevron's existing translated
Expand row/Collapse rowlabels are untouched.
VERDICT: clear
| slot | verdict |
|---|---|
| PROBLEM | clear |
| SOLUTION | clear |
| ARCHITECTURE | clear |
| IMPACT | note — reaches every existing tree-table consumer without an opt-in |
| API | clear |
| THEMING | clear |
| BREAKING | BLOCKS — behaviour change to released surface shipping as patch
|
| PERFORMANCE | clear |
| VISUAL | clear |
| A11Y & I18N | clear |
Read for what the labels cannot carry: IMPACT's third paragraph and BREAKING's
BEHAVIOR line are the same fact seen from two sides — "their table's exposed
role changes from table to treegrid, and their keyboard path gains the row
stop" — and the slot that OWNS it is neither. It is owned by the changeset,
which is the only artefact that tells a consumer an upgrade will do that. That
is why the block is filed against .changeset/…:2 and not against
useTableTreeData.tsx. No note or clear slot above contains a sentence
describing a person who is stuck, unreachable, unheard, or lost — the A11Y slot
in particular describes the opposite.
GOAL: met — the stated problem (invalid row ARIA hiding the hierarchy) is
gone at the head: role=treegrid is driven, the eight axe suppressions are
deleted rather than replaced, pr-a11y is green, and the keyboard model the
ruling required ships with it. Both round-1 findings are fixed and both are
deleted from this review.
DISPOSITION:
- Round-1 finding 1 (row ARIA deleted instead of made valid) → fixed, deleted.
- Round-1 finding 2 (role without a keyboard model) → fixed, deleted.
- Ruling conditions (row focus v1 · docs name the limit · interactive descendants reachable) → all met; the docs carry the v1 limit in
docs.description,docsDense.description, abestPractices"do not" entry, and the story doc, and reachability is driven above. - Changeset category vs. what the change does to released surface → blocks now.
-
<Table children>+ tree plugin would yieldrole="treegrid"over untransformed rows → accepted: no consumer combines them, nothing in the repo does, and the combination has no motivating use case. Recorded here so the next round does not re-derive it. - Programmatic ancestor-collapse under a focused descendant row drops focus to
<body>→ accepted: unreachable through any shipped affordance; collapsed-subtree unmounting is pre-existing.
ADVICE: proven existing pattern — .changeset/layer-dismissal-stack.md (#4881) is the landed precedent for exactly this shape: a behaviour change to shipped surface with no API rename, classified [breaking] / minor, whose own text warns "One behavior change worth knowing about if you listen for Escape yourself…". .changeset/banner-collapsible-content.md (#5255) is the second member. The repo's gate states the mechanism itself: "A [breaking] changeset must be minor (0.x.y -> 0.(x+1).0, the breaking tier under caret ranges); every other category must be patch" (scripts/check-changesets.mjs:8-10). No implementation is invented — the ask is the category the repo's own rule assigns.
AUTHOR CAN PROCEED: yes — the defect, the constraint and the acceptance criterion are all proven and public. Acceptance: the changeset declares '@astryxdesign/core': minor with a [breaking] category, so the release that carries it is the one breaking changes land in and a ^0.5.0 consumer is not upgraded into it silently. No human decision is withheld: the scope question was ruled on 2026-08-27, and release timing stays the maintainer's and does not block the author.
WORST OUTCOME: "their table's exposed role changes from table to treegrid, and their keyboard path gains the row stop" — for a consumer who took what the changelog called a patch. → request changes, which is the verdict.
CONFIRMATION (the block, a second way): found by reading the changeset frontmatter; confirmed independently by the repo's own gate, which states the coupling and the reason — "A [breaking] changeset must be minor (0.x.y -> 0.(x+1).0, the breaking tier under caret ranges); every other category must be patch" (scripts/check-changesets.mjs:8-10) — and by the author's own changeset text, which already documents the consumer break ("a test that queried getByRole('table') on a tree table queries treegrid now"). Three sources, none of them my reading of the runtime code.
COMMIT TYPE: checked and honest. feat(Table) claims consumer-visible new behaviour, and that is what the diff does; the risk assessment above agrees with the title rather than contradicting it.
RISK CLASS: not low-risk — new public surface and a changed behaviour default, so a missing doc or story would have been required rather than a nit. Both are present.
JUDGEMENT NEEDED: none — the scope decision was ruled on 2026-08-27 and this head is built against it; the API additions are all members of landed classes.
request changes
-
[BLOCKS] The changeset ships this as
patch, but it changes what released surface means for consumers who never opted in → whoever already ships a tree table takes a routine^0.5.0update and their table's exposed role becomestreegridand their keyboard path grows a row tab stop;getByRole('table')stops finding it, and the changelog said patch ·.changeset/a11y-table-row-aria-expanded.md:2
Thanks for rebuilding this on the ruling — treegrid semantics, row focus, in-cell controls left alone, the v1 limit in the docs. Deleting eight baseline entries instead of adding any is the right shape.
One thing before it goes in. The changeset is patch, but this changes what shipped surface means: every existing tree table becomes role="treegrid" and grows a row tab stop, and nobody opted in. Under ^0.5.0 a patch installs on a routine update, so getByRole('table') starts failing in someone's suite with nothing in the changelog explaining it. #4881 shipped the same shape as [breaking]/minor, which is the tier that doesn't auto-install.
-'@astryxdesign/core': patch
+'@astryxdesign/core': minor
---
-[feat] Table tree plugin ...
+[breaking] Table tree plugin ...Everything else looks done — flip that and I'll take it.
If you'd rather talk it through with someone, we're in Discord.
-
.changeset/a11y-table-row-aria-expanded.md:2—'@astryxdesign/core': patch. Every existing tree table changes role and tab order on upgrade; wants[breaking]/minor, like #4881.
-
<Table>with customchildrenplus the tree plugin would putrole="treegrid"over rows the plugin never transformed. No consumer combines them; not the contributor's to close. - A programmatic ancestor-collapse under a focused descendant row drops focus to
<body>; unreachable through any shipped affordance, and collapsed subtrees unmounting is pre-existing. - Vercel's docsite deployment is red on this head — and also on #5612 and #5608, while green on #5613 and #5609. The repo's own
docsite-testjob is green here. Not attributable to this PR.
TIME total 34m
setup 17m worktree + install + storybook dev on 6283 (warm main reused: yes)
reading 8m kit, rulings, prior review, diff, source
measuring 9m 4 probe runs + 4 frames, 1 re-measure after the first probe read Storybook's args table
writing 8m draft + critic passes
waste 9m fast-install.py stalled ~4m in its lsof +D donor-idle scan under three concurrent installs, then a hand cp -c -R ran ~10m to 53% before being abandoned; a plain pnpm install finished the same tree in 28s. Second item: the first probe used a bare document.querySelector('table'), which selects Storybook's hidden sb-argstableBlock — one probe run thrown away.
- Nothing material. The one composition left undriven is
<Table children>with the tree plugin, recorded above with why it is not reachable.
Not posted. The verdict above is a drafted request-changes; no public PR
action was taken at c88f6579 — no review, no comment, no label, no merge, no
auto-merge.
The blocker is the loop's own delivery rule, not anything about the PR. The reviewer brief's absolute rules are read-only — "Never post, comment, push, or approve. Draft text only" — and R2e keeps a major API change or large behaviour refactor on the never-auto-post list, with request-changes named as the last permission the loop earns. This PR changes a core component's exposed ARIA role and keyboard model and adds three public type members, so it sits inside that class.
Two things follow from that, and both are already true:
- Our standing
CHANGES_REQUESTEDfrom 2026-08-13 is still live and still blocks the PR, so nothing is lost by not posting a second one — and only the same account can clear it (R31b). - The author is not left waiting on a question. Every round-1 finding is fixed, the ruling's conditions are met, and the one remaining item is a two-word changeset edit with the precedent named.
Not posted this round.



