Skip to content

Review 5420

Cindy Zhang edited this page Aug 26, 2026 · 2 revisions

#5420 — feat(Table): expose theme targets for the sort and filter affordances

freddymeta (contributor; head on facebook/astryx) · OPEN, changes requested · view on GitHub

Verdict: request changes — the mechanism, not the PR.

Head reviewed: 9b0d421e70466984fb76715485c0d43fb05e7366, unmoved across three gates. No prior reviews. One of a SET of four rows of #5417 — set-level findings on Review-5426.

Three gates, and the verdict moved once, on a design ruling rather than on evidence:

gate verdict what moved it
1-2 approve & merge everything measurable checked out; both decisions held
3 request changes Cindy ruled one target, not two, with the glyph on currentColor — and checking it turned up a live a11y defect

Problem

Astryx paints the sort arrow and filter funnel bare at 0.35 opacity until you hover the header; EPS wants them legible at rest so a column reads as sortable before you touch it. Both controls render inside Astryx's own plugins — no class, no data attribute, no wrapper to interpose, no renderX prop. The only move today is .astryx-table-header-cell button[aria-haspopup='dialog'], a CSS rule that depends on an ARIA attribute never changing.

Solution (2 decisions · ~52 runtime lines of 89)

1. three targets: the sort <button> and its icon wrapper (both reflecting
   direction), the filter trigger (state: active)
   — useTableSortable.tsx:312, :320 · useTableFiltering.tsx:922
2. themingTargets.test.ts discovers component sources at ANY depth under src
   — themingTargets.test.ts:361 sourceDirs()

Decision 2 is right and should not be split out. He offered; he was correct not to. Confirmed three ways rather than taken on his word:

old guard + all four PRs merged   → 294 GREEN   ← the fix is load-bearing
new guard + plain main            → 294 GREEN   ← catches nothing today
new guard + all four PRs merged   → 302 GREEN

R14b answered on its own evidence: the catch on main is zero because no nested source rendered a themeProps() class before this PR — the guard closes a hole rather than fixing a break.

Impact

Nobody today — nothing consumes the keys.

Builders writing a theme, once one does. Under the PR as written, a themer restyles the sort affordance by reaching two classes and has to know which one carries which property: colour on one, opacity on the other, silent when wrong. Under the ruling they set a colour on the button and the glyph follows, including on hover.

End users, once the opacity comes off — and this is the part neither the PR nor the ruling anticipated. The resting arrows currently fail WCAG AA and pass afterwards. See below.

A11y in the good direction, either way: a theme can stop keying on aria-haspopup='dialog', which makes that attribute safe to change.

These work in a production build — #5410 merged the same morning.

API

no API change. color="inherit" is an existing Icon prop value; sourceDirs() is module-private to the test file.

Theme targets

As written:

+ astryx-table-sort-button     → the sort <button>, reflecting direction  (public, permanent)
+ astryx-table-sort-indicator  → the <span> wrapping its Icon, direction  (public, permanent)
+ astryx-table-filter-button   → the funnel <button>, state: active       (public, permanent)

Asked for astryx-table-sort-indicator to be deleted before it ships — the cheap moment, since nothing consumes it and removing it later is breaking.

Ossification

table-sort-button and table-filter-button are a named class with merged members — codeblock-copy-button, timestamp-copy-button, input-clear-button, token-remove-button, tab-scroll-button. Both keep their place.

table-sort-indicator is the one that fails, and for the reason R1g-surface exists: a second spelling of one axis. Same shape as Step.icon / Step.indicator on #5201 — the duplicate axis that only becomes obvious when both lines are written out.

Breaking

  • API — no.
  • Visual — no as written; yes under the ruling, which repaints the resting glyph in every sortable table. That is the substance, not a side effect.
  • Theme — no.

Far side of the bound. The unsorted state is what EPS wants and direction is null there — no class token, no data-direction, so only base reaches it. Verified it does, in both versions.

Performance & resources

Nothing, either version. Two mergeProps calls replacing two stylex.props spreads, once per sortable column — not per row. No effect, no listener, no observer, no layout read. The ruling's version makes one fewer themeProps call than the PR.

Visual evidence

Real Chromium, 760px, Storybook dev at the PR head. Frames published to assets/pr-5420 on the fork; two embedded in the posted review.

what it shows frame
colour on the button — the header text follows too ruling__one-target-colour.png (posted)
the ruling working — themed glyph, untouched header shape__C-ruling-themed.png (posted)
opacity: 0.35 on the button — every header at 35% ruling__one-target-opacity.png
two targets — glyph red, header untouched ruling__two-target-glyph-only.png
today's unsorted rest state shape__A-today-rest.png

Probes: probe-kit/sort-indicator-currentcolor-5420.cjs, sort-one-target-consequence-5420.cjs, sort-one-target-shape-5420.cjs, sort-glyph-contrast-5420.cjs. SB_PORT=6610.

Testing the ruling before drafting the redirect

Cindy asked whether it was true today before it was written up. Four questions, all driven in real Chromium:

1. Does the Icon already paint currentColor? Yes — the glyph does; the Icon overrides it. defaultIcons.tsx:32 makes every registry glyph stroke: 'currentColor'. useTableSortable.tsx:330 passes color={direction != null ? 'accent' : 'secondary'}, and colorStyles.secondary sets the currentColor the glyph resolves against.

colour on .astryx-table-sort-button, as shipped   → glyph UNCHANGED
same, with the Icon inheriting                    → glyph rgb(1,2,3)

Icon already has the mode (color="inherit", Icon.tsx:77-79) and 29 core components already use it. One word, cited rather than argued (R3k).

2. Can the opacity ride on the button? No. The button contains the column header text as well as the glyph, so anything set on it reaches the label — opacity: 0.35 on the button fades every header to 35% (ruling__one-target-opacity.png). That is the case for the wrapper existing, and it is an argument about opacity, which the ruling replaces with colour.

3. Buildable as stated? Yes, with one line she did not name — the header label span has to pin its own colour, or the button's bleeds into it. With that:

A today          label rgb(82,82,82)   glyph rgb(115,115,115) @ opacity 0.35
B ruling, rest   label rgb(82,82,82)   glyph rgb(178,178,178) @ opacity 1
C ruling, themed label rgb(82,82,82)   glyph rgb(200,30,30)   ← ONE target
GUARANTEE  rest rgb(1,2,3) → hover rgb(4,5,6)   PASS

4. The filter button — her logic applies and she did not mention it. Same blocker, no label to bleed into, and the PR already gives it exactly one target. A colour set on it does not reach the glyph, so the PR claims something it does not do. That is a defect in the shipped diff, not a shape question.

The contrast finding — measured because the ruling rests on it

Cindy pointed at Design Conventions for the value, and checking it turned the redirect from a convention alignment into an a11y fix.

Two lines of the wiki apply. The Reorder contract (Design-Conventions.md:299):

"blanket 50% opacity … is an allowed temporary contrast exception; restore normal contrast immediately on drop or cancel, and never use this treatment for a resting or persistent state."

And the colour principle (:212): "neutrals are chosen by role", with low contrast — "any fg/bg below WCAG AA (4.5:1 text, 3:1 large/UI)" — listed as a smell.

Measured against the real header backdrop, both schemes:

glyph vs header AA (3:1 UI)
light, today @0.35 rgb(206,206,206) 1.57:1 FAIL
light, token alone rgb(115,115,115) 4.74:1 pass
dark, today @0.35 rgb(82,82,82) 1.94:1 FAIL
dark, token alone rgb(163,163,163) 6:1 pass

So no new token is needed. The component already names the right one — color="secondary" — and the 0.35 is what stops it rendering. Dropping the opacity lets the existing role token through, and the resting arrows go from failing AA to passing it in both schemes.

A trap worth recording: dark mode goes through Storybook's colorMode global (preview.tsx:125), not page.emulateMedia. The first run reported an identical white backdrop for both schemes — the same class of error lib.cjs documents for direction.

Judgement

request changes

1. [BLOCKS] `astryx-table-sort-button` and `astryx-table-sort-indicator` are two
   spellings of one axis, and neither carries the glyph's colour
   → a themer sets a colour on the sort button — the obvious target, the one the
     PR's description points at — and nothing happens; they then have to
     discover colour lives on the indicator and opacity does too
                                        · useTableSortable.tsx:320, :330
   confirmed twice: measured in Chromium (reaches: false; with the Icon
   inheriting, true), AND read off Icon.tsx:77-79 + defaultIcons.tsx:32.

2. [BLOCKS] the same on the filter button, where there is only one target
   → the PR says the single `table-filter-button` target lets a theme restyle
     the funnel; a colour set on it does not reach the glyph
                                        · useTableFiltering.tsx:935
   confirmed by the same probe: reaches: false.

3. [BLOCKS] the resting glyph fails WCAG AA in both schemes
   → the sort arrows on every table are at 1.57:1 in light and 1.94:1 in dark
     against the header, where AA wants 3:1 for a UI glyph — a person scanning
     a table cannot see which columns are sortable
                                        · useTableSortable.tsx:324
   confirmed twice: computed from the composite AND against the token alone
   (4.74:1 / 6:1), both schemes, real backdrops.

4. [not blocking] the guard fix is right and should not be split out
                                        · themingTargets.test.ts:361

The review, as posted

Thanks — the problem is right, and keeping the guard fix here is right too; I checked and the old one stays green with these three unchecked.

The two sort targets are one axis, and neither carries the glyph. Set a colour on astryx-table-sort-button — the obvious one — and nothing happens; the funnel has the same problem with only one target. Both icons want color="inherit", then the colour goes on the buttons and astryx-table-sort-indicator comes out. Spinner went this way in #5408.

colour on the button today with the label pinned
header text follows too only the glyph

Drop the resting opacity: 0.35 with it — inline below. Composited, the arrows sit at 1.57:1 against the header where AA wants 3:1; --color-icon-secondary alone is 4.74:1.

Then a browser test to hold it: colour on the button target, assert the glyph paints it, hover, assert it follows. theme-var-reachability.js is the shape.

If you'd rather talk it through, we're in Discord.

[Reviewed by Robohands]

Three inlines:

  • useTableSortable.tsx:327color="inherit" on this one (it's secondary two lines down) and the button's colour reaches the glyph — the registry SVGs are already currentColor.
  • useTableSortable.tsx:324This resting opacity: 0.35 is what puts the arrows at 1.57:1. Drop it and let secondary render — the header label span then needs its own colour so the button's doesn't bleed into it.
  • useTableFiltering.tsx:932Same here — the funnel's Icon sets its own colour, so the single target above doesn't reach it.

144 prose words against the 150 cap.

What changed before posting

The draft's closing paragraph was "One thing I can't settle for you — which grey it lands on is ours to pick. I'll come back with that." Cindy answered it from the wiki instead: the treatment is already ruled (opacity is drag-only) and the value is a role token the component already names. The paragraph became two clauses of citation, which is also what brought the comment under cap — the overage had been the escalation.

The contrast detail moved from the body into the inline at :324. It is line-anchored, so that is its home (R2h), and R2 says delete or relocate rather than compress.

For the nightly follow-up

  1. Did color="inherit" land on both iconsuseTableSortable.tsx:330 and useTableFiltering.tsx:935?
  2. Did astryx-table-sort-indicator come out of both the component and useTableSortable.doc.mjs (EN + zh)?
  3. Did the opacity come off, and did the header label get its own colour? Without the second, the button's colour bleeds into the column title — check the frame, not the diff.
  4. Did the guarantee test appear? The right host is CI's theme-layers job (ci.yml:485), which is gated on docsite_only != true, so it fires on a Table change — unlike pr-a11y, which scopes to packages/core/src component dirs and would skip. That is the mistake #5410's own addendum caught in itself.
  5. Re-measure the contrast after the change, both schemes. The claim is that it goes from failing to passing; that should be verified on the new head, not assumed from this record.

If nothing has moved in two days, this is the nudge ladder (R24) — one nudge.

Not verified

  • Which grey the rest state lands on if the team decides secondary at full strength is too heavy. The measurement says it passes AA; whether it reads right is a design call nobody has had to make yet, because the opacity has been hiding it.
  • Whether color="inherit" changes anything under forced-colors. Both resolve through the cascade, so I expect not, and I did not drive it.

Re-review — 2026-08-25

Versions: Review Loop unversioned (pre-1.0) · Component Audit Rubric 1.10
Head: df3dc14f · Verdict: request changes

The runtime work satisfies every prior ask. The remaining hold is the browser contract: the guard is not invoked by CI, reads before Storybook renders, and checks inherited color rather than the SVG's painted stroke/fill.

Review as posted:

Thanks — my last review asked for one target per affordance, button-owned color, inherited SVG paint, no resting opacity, and a browser guard. The runtime work now matches all five asks.

The guard is still the hold. It is not called by CI, and on this head it reads before Storybook renders and fails both cases. After waiting, it checks svg.color, so a fixed stroke still passes while the glyph paints the wrong color. Could a maintainer wire it into the Storybook Chromium job, wait for each target, and assert the painted stroke/fill?

[Reviewed by Robohands]

Clone this wiki locally