feat(Table): expose theme targets for the sort and filter affordances - #5420
feat(Table): expose theme targets for the sort and filter affordances#5420freddymeta wants to merge 18 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsTable (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Visual RegressionStatus: Skipped — Broad stable scope is deferred to the daily release gate. It covers 4332 trusted baseline shots instead of recapturing them for this PR. View the report Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
cixzhang
left a comment
There was a problem hiding this comment.
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 |
|---|---|
![]() |
![]() |
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]
| : sortStyles.iconWrapperUnsorted, | ||
| ), | ||
| )}> | ||
| <Icon |
There was a problem hiding this comment.
color="inherit" on this one (it's secondary two lines down) and the button's colour reaches the glyph — the registry SVGs are already currentColor.
| stylex.props( | ||
| direction != null | ||
| ? sortStyles.iconWrapperActive | ||
| : sortStyles.iconWrapperUnsorted, |
There was a problem hiding this comment.
This 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.
| : filterStyles.triggerInactive, | ||
| ), | ||
| )}> | ||
| <Icon |
There was a problem hiding this comment.
Same here — the funnel's Icon sets its own colour, so the single target above doesn't reach it.
There was a problem hiding this comment.
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]
rubyycheung
left a comment
There was a problem hiding this comment.
Keeping the sort and filter affordances visible at rest is the right accessibility improvement, but these are buttons and should mirror the standard Button/IconButton interaction states. The current change removes the distinct pointer-hover feedback entirely.
Please apply the shared hover and pressed treatments, retain the focus-visible ring for keyboard users, and reserve the accent styling for the active sorted/filtered state. This gives the controls a clear rest → hover → pressed interaction model without returning to the inaccessible 35% resting opacity.
Both controls render inside their plugins, so a theme could not reach them at all — no class, no data attribute, and no wrapper a consumer can interpose. Adds three targets: astryx-table-sort-button the sort <button>, reflecting `direction` astryx-table-sort-indicator its icon wrapper, reflecting `direction` astryx-table-filter-button the funnel trigger, with an `active` state Also makes themingTargets.test.ts discover sources at any depth under src. It walked only top-level directories, so `Table/plugins/<name>/` was exempt — including the three targets above. Nothing was failing (no nested source rendered a themeProps() class before this), so this closes the hole rather than fixing a live break: 294 -> 302 assertions. Refs #5417.
…s 3:1 Addresses the review on #5420. **Both icons read `color="inherit"` and the buttons carry the colour.** An `Icon` that names its own colour writes `color` on the glyph, which beats anything the button inherits down — so a theme could resolve `astryx-table-sort-button`, emit the rule, and watch nothing move. With the colour on the button, the target colours the thing it is named for, and `astryx-table-sort-indicator` is no longer needed: it existed only because the button could not reach its own glyph. Two targets, one per affordance. **The resting `opacity: 0.35` is gone from both.** Composited against the header it put the arrows at 1.57:1, under the 3:1 WCAG 1.4.11 asks of a UI component. They render at `--color-icon-secondary` instead — 4.74:1 in light, 6:1 in dark, both measured in Chromium. The affordances are now visible at rest rather than fading in on header hover. **The header label inside the sort button carries its own colour**, the same `--color-text-secondary` the `<th>` sets. Without it, colouring the sort target repaints the header text along with the arrow — the button holds both. Sorted state is untouched: the arrow still moves to the accent colour and still changes glyph, alongside the `aria-sort` the header already carries, so no state is signalled by colour alone. **`.github/scripts/affordance-color-reach.js`** holds all of it. It renders the built Storybook in Chromium, writes `@layer astryx-theme { .<target> { color } }` the way `Theme` does, and asserts the glyph takes it — at rest, under hover, and without dragging the header label along — then pins the resting contrast. None of that is visible from jsdom, which resolves no cascade: a unit test can assert the class is on the button and the rule is in the CSS text while the glyph still paints its own colour. Shape follows theme-var-reachability.js, the sibling guard for documented vars; this one is for an ordinary inherited property, which that enumeration cannot see. Negative control, the guard against this branch's own pre-review build: six failures across the two affordances — 1.57:1 at rest, the themed colour not reaching either glyph, and the same on hover.
The guard itself and its `pnpm guard:affordance-color` entry stay; only the
`ci.yml` step comes out, because GitHub refuses a push that touches a workflow
file from a token without `workflow` scope, and mine does not have it.
It wants three lines after the theme-var reachability step in the pr-a11y job:
- name: Run affordance colour reachability guard
run: node .github/scripts/affordance-color-reach.js --storybook-dir apps/storybook/dist
Until that lands the guard runs locally and in review, not on every PR — so it
holds this change but does not yet hold the next one.
…on states Ruby's hold. Dropping the resting `opacity: 0.35` fixed the contrast and took the only hover feedback with it — the affordances had no rest -> hover -> pressed model left at all, and the filter button drew nothing on keyboard focus either. Both now draw what every other borderless control draws: an `--color-overlay-hover` tint on hover, `--color-overlay-pressed` on press, and the shared focus ring via `focusOutlineProps.focusVisible`. The filter button gains that ring; it never had one. Hover is guarded on `@media (hover: hover)` so a touch device does not stick in the tint after a tap, and the glyph darkens a step alongside the tint so the feedback survives a forced-colors mode that drops backgrounds. Accent stays reserved for the sorted/filtered state, so "this column is sorted" never reads as "the pointer is here". Resting colour is unchanged at 4.74:1.
… mount Both of the review's holds on the guard. It read `svg.color`, which is the colour the glyph INHERITS, not the one it paints. Geometry that hardcodes a stroke leaves `color` reading the sentinel while nothing on screen moves — the exact shape the guard exists to catch, passing. It now collects the resolved `stroke`/`fill` off every painted node and asserts on those, at rest and under hover, and the resting contrast is measured on the paint too. Verified both ways: green on this head, and with `stroke` forced to rgb(9, 9, 9) it fails with "stroke/fill stayed rgb(9, 9, 9) while the inherited color read rgb(1, 2, 3)" — which is the sentence the old version could not say. It also read before the story mounted, because Storybook settles the network before it renders and `networkidle` was the only wait. It now waits for the target with a glyph inside it. One thing the paint check surfaced immediately: the affordances transition `color`, so the read taken right after the sentinel lands was catching an interpolated value — rgb(61, 62, 62) between the old colour and the new. The probe now kills transition and animation durations rather than sleeping through them: what is asserted is the settled paint, and a sleep long enough for one theme's duration is a race in another.
df3dc14 to
312eac9
Compare
Split out of #5420 at review request: this is guard infrastructure, not part of exposing Table's affordance theme targets, and it should stand or fall on its own. `themingTargets.test.ts` scanned only the top level of `src`, so a component whose sources sit a level down — Table's plugins render from `Table/plugins/<name>/` — was silently exempt from the guard. That is the drift #3741 was filed to prevent. Nothing was failing when this was written, so it closes a hole rather than fixing a live break: 294 assertions to 302. Measured rather than assumed — instrumented discovery reports core=98, lab=5, nested=2, the two being `Table/plugins/filtering` and `Table/plugins/sortable`. NOT included, deliberately: the same recursion on the sibling `derivedVarRegistry.test.ts`. I wrote it, measured it, and it discovered nothing (nested=0), because that guard also requires a doc file named after the directory and `Table/plugins/sortable/` has no `sortable.doc.mjs`. Closing that one needs the doc-fallback lookup this file already has, which is a larger change than a walk; shipping the walk alone would have been a rule that never fires.
… target each Both affordances were native buttons restating Button's interaction states. They are now the real Button and IconButton: rest, hover, pressed, the focus ring, the press transform and the reduced-motion guard are Button's, and every hand-rolled copy is deleted. The column label stays inside the sort control, so the whole heading is still the click target. Each keeps one table-scoped theme target on the control Button renders, the toggle-button shape. INV5 lets a parent hold its own target when it guarantees a distinct public visual contract, and defineTheme is why these do: parseStyleKey emits one compound selector on one element, so a theme cannot write `.astryx-table-header-cell .astryx-button` and delegation alone would mean "every button in the app" or nothing. `color` on astryx-table-sort-button now paints the glyph and leaves the column name alone: the value is routed through the derived-var registry with `replaces: true`, so defineTheme emits --_table-sort-glyph-color and no `color`. The filter trigger holds only its glyph, so plain `color` lands on it. Table.spec.md's anatomy-theming map, Table.doc.mjs's anatomy inventory and the target declarations move together, as Change coupling requires: Sort control and a new Filter control become `target`, Filter indicator glyph delegates to Icon, and the targets move to Table.doc.mjs — the doc check-knowledge.mjs reads for component:Table's target inventory. The affordance-colour-reach guard now builds its probe stylesheet with the real defineTheme instead of writing the private var by hand, covers the reflected `direction` state, and roots its @scope on the innermost themed ancestor. The visual gate's probe theme is regenerated (274 targets, 886 selectors); two of its three hunks are pre-existing drift from #5805 on main, which left the committed copy stale.
|
(Replaces my previous comment, which was far too long.) Both reviews came down to one question — who owns these two controls — and the answer is @rubyycheung — now the real @cixzhang —
Guard now builds its probe with the real Why keep the targets rather than delegate. Your call, and the one thing I'd flag: this amends the Negative controls: remove the derived-var entry → the label follows the colour again (your exact bug); revert Gates: 8957 tests pass, build/typecheck/typecheck:docs/check:repo clean, Still blocked on me: the guard's CI step needs |
cixzhang
left a comment
There was a problem hiding this comment.
Thanks — the Button/IconButton move fixes the interaction drift. Two things still block this head.
The new affordance guard is only a package script; no workflow invokes it, so the target/color contract can regress without CI noticing. Please wire it into the existing Storybook Chromium job.
Table.spec.md also still says “documentation only,” eight targets, and no runtime/target change while this head records ten targets and changes the controls. Please align its compatibility, ownership, verification, and decision sections.
[Reviewed by Robohands]
…ual changes `Table.spec.md` still read "additive documentation only", "eight current targets" in three places, and a Decision log of "None ... without introducing a component-local visual or runtime change", while this head records ten targets and rebuilds both affordances on Button. - *Compatibility* — additive targets plus the intentional visual changes, itemised: Button/IconButton's hover and pressed overlay and `scale(0.98)` press, the filter trigger's shared focus ring and `sm` square box, and the removal of the resting `opacity: 0.35`. Records that nothing is removed or renamed, and that the column heading is measured unchanged against its `<th>`. - *Ownership* — eight targets to ten. - *FR4 and the verification map* — the same, and FR4 now names `table-sort-button` and `table-filter-button` with the states they reflect. - *Decision log* — four decisions replacing "None": own targets rather than delegating to `button` (DEC-1), the derived-var routing that colours the glyph without the label (DEC-2), building on Button (DEC-3), and the opacity removal as a contrast fix rather than a preference (DEC-4). This edits an `authority: current` record, so `spec-owner-approval` will now ask for owner approval where the previous head did not. That is the gate working as intended, not something to route around. The other half of the review — wiring the affordance guard into CI — is a `.github/workflows/` change my account cannot push (`refusing to allow a Personal Access Token to create or update workflow ci.yml without workflow scope`). The verified patch is in a PR comment for a maintainer to apply, which matches how the 08-26 review framed it. Test Plan: - `scripts/check-knowledge.mjs`: records aligned - `themingTargets.test.ts` + `derivedVarRegistry.test.ts`: 518 tests pass - no remaining "eight" or "documentation only" in the record
|
Both asks addressed. The spec half is pushed as Table.spec.mdIt still read "additive documentation only", "eight current targets" in three places, and a Decision log of "None ... without introducing a component-local visual or runtime change".
Editing an The guard in CI — needs youI cannot push it: Your 08-26 review already framed this as a maintainer action, so here is the patch, verified locally rather than sketched. It goes in The patchdiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a2ad46c07..cc7a75bfd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -525,6 +525,17 @@ jobs:
name: storybook-${{ needs.build-storybook.outputs.short_hash }}
path: apps/storybook/dist
+ # For the affordance-colour guard below, which generates its probe
+ # stylesheet with the real `defineTheme` out of core's dist rather than
+ # hand-writing one. build-storybook has already built it; downloading
+ # keeps this job on an artifact instead of a second full build, the same
+ # trade pr-visual makes.
+ - name: Download built themes and core
+ uses: actions/download-artifact@v8
+ with:
+ name: dists-${{ needs.build-storybook.outputs.short_hash }}
+ path: packages
+
- name: Install Playwright
run: pnpm install --frozen-lockfile && npx playwright install chromium
@@ -569,6 +580,18 @@ jobs:
- name: Run theme var reachability guard
run: node .github/scripts/theme-var-reachability.js --storybook-dir apps/storybook/dist
+ # A theme target naming a button that holds one icon promises that a
+ # colour set on it reaches the glyph. An `Icon` that names its own colour
+ # writes `color` on the glyph and beats what the button inherits down, so
+ # the target resolves, the rule emits, and nothing moves — which is how
+ # Table's sort and filter affordances shipped. jsdom resolves no cascade,
+ # so a unit test can assert the class is on the button and the rule is in
+ # the CSS text while the glyph still paints its own colour; Chromium is
+ # where the promise is checkable. Sibling to the two guards above, and
+ # here for the same reason: this job already has the built Storybook.
+ - name: Run affordance colour reach guard
+ run: node .github/scripts/affordance-color-reach.js --storybook-dir apps/storybook/dist
+
# The layer order Astryx promises is Astryx < theme < product, and which
# declaration paints is a cascade fact no jsdom test can settle: the build has
# been wrong in both directions inside one week — unsplit, so StyleX outrankedVerified, not assertedRan it exactly as the new step invokes it, against a real Storybook build: The 4.74:1 and the held label are the two things the last two rounds doubted — measured here. Negative control, since this guard's integrity has been questioned twice. Restored the original defect ( So it fails on the defect it exists for, in all three of rest, hover and state, and it is not passing by writing the private var directly. Also: |
…age gap
`pr-rtl` fails on this head, unrelated to the review: the audit scopes every
component a PR touches and requires each to be measured or to carry a written
not-applicable reason. Table was neither, so it reported
`COV: 0 measured / 1 gap` and exited 1.
Table is plainly direction-sensitive — a table's column order mirrors — so the
answer is measurement rather than a reason. A D2 target over
`core-table--column-alignment` asserts the first and last cells of a row swap
horizontal order between LTR and RTL.
One trap worth recording, since the selector looks obviously right and is not:
`tbody tr:first-child td:first-child` matches Storybook's own hidden docs
prop-table first (inside `.sb-preparing-docs`, width 0), so `boundingBox()`
returns null and the dimension silently reports "prev/next not found" — an N-A
that still counts as a gap, not an error. Scoping to `.astryx-table` picks the
story's own table.
Test Plan:
Built Storybook and ran the audit, rather than reasoning about the selectors —
which is how the hidden-table trap above turned up:
$ node apps/storybook/rtl-audit/rtl-audit.mjs \
--storybook-dir apps/storybook/dist --filter Table
CUR RTL-ready core/table {"D2":"pass"}
COV : 1 measured / 0 verified N-A / 0 gap / 0 stale
exit 0 (was: 0 measured / 1 gap, exit 1)
The order genuinely mirrors rather than merely differing — cell centres LTR
305 < 1024, RTL 795 > 76.
# Conflicts: # apps/storybook/rtl-audit/targets.json
cixzhang
left a comment
There was a problem hiding this comment.
Thanks — the spec now matches the implementation. This exact head still defines guard:affordance-color, but no workflow invokes it, so the target/color contract can regress without CI noticing. The prepared pr-a11y wiring still needs to land before merge.
[Reviewed by Robohands]
|
Follow-up on The branch was
One trap worth passing on, because the selector looks obviously right and is not: For the record, Still needs you for the two things I cannot do: the |
# Conflicts: # apps/storybook/rtl-audit/targets.json
#5817 landed a `verified-not-applicable` reason for `core/Table` while this branch was adding a measured D2 target for the same component. With both in place the audit reports `1 stale` and exits 1 — which is the registry working as documented: "if an automatic or curated dimension later becomes applicable, the declaration is reported as stale-verified-na and must be removed or replaced with real coverage." Real coverage is what this branch adds, so the declaration goes. Its reasoning was that column order "uses table flow ... resolved by CSS for the document direction" — true, and now measured rather than asserted. Test Plan: $ node apps/storybook/rtl-audit/rtl-audit.mjs \ --storybook-dir apps/storybook/dist --filter Table CUR RTL-ready core/table {"D2":"pass"} COV : 1 measured / 0 verified N-A / 0 gap / 0 stale exit 0 (was: 0 measured / 0 gap / 1 stale, exit 1)


Implements the third row of #5417 — the one with no consumer-side workaround at all.
Problem
useTableSortablerendered its control as a bare<button>wrapping a bare<span>wrapping theIcon;useTableFilteringrendered its funnel as a bare<button aria-haspopup="dialog">. Neither carried anastryx-*class, so no token and nocomponentsentry could reach them — and because both are rendered inside the plugin, there is no wrapper a consumer can interpose and norenderXprop to route around it. The only remaining move was.astryx-table-header-cell button[aria-haspopup='dialog'], a selector keyed on an ARIA attribute.Both also re-implemented the interaction states of a button on a native
<button>, which is what @rubyycheung caught.Change
Both affordances are now the real
Button/IconButton. Rest, hover, pressed, the focus ring, thescale(0.98)press and the reduced-motion guard are Button's; every hand-rolled copy is deleted. The column label stays inside the sort control, so the whole heading is still the click target.Each keeps one table-scoped theme target on the control Button renders, the
toggle-buttonshape:astryx-table-sort-buttondirectionastryx-table-filter-buttonactiveINV5 says a part rendered by a shared primitive delegates to that primitive's target "unless the parent guarantees a distinct public visual contract". Both do, and
defineThemeis why:parseStyleKeyemits one compound selector on one element, no combinators, so a theme cannot write.astryx-table-header-cell .astryx-button. Delegating alone would mean "restyle every button in the app" or nothing. Beyond scope,colorbehaves differently on the two contracts — see below.coloron the sort target paints the glyph and leaves the column name alone. The control holds both, and the name belongs to the header cell. The value is routed to the glyph through a derived-var entry withreplaces: true, sodefineThemeemits--_table-sort-glyph-colorand nocolor— theprogress-bar-mark/text-areamechanism, and INV11 rather than a new public var. The filter trigger holds only its glyph, so plaincolorlands on it directly.Records this moves with
Table.spec.md'sanatomy-theming:v1map dispositionedSort controlasinherits: table-header-celland had no filter anatomy at all. Per "Change coupling", the map, the anatomy inventory and the targets move together, so this PR updates all three:Sort controland a newFilter controlbecometarget,Filter indicator glyphjoinsSort indicator glyphin delegating to Icon, and the prose records why the two targets survive INV5.The targets are declared in
Table.doc.mjsrather than the plugin docs, because that is the doccheck-knowledge.mjsreads forcomponent:Table's target inventory — declared on the plugin docs they were invisible to the anatomy↔target validator.Visual changes
Not "no visual change" — the changeset lists them. Hover and pressed are now Button's overlay (a background image, not a background colour) plus
scale(0.98); the filter trigger gains the shared focus ring and Button'ssmsquare box (28×28 measured, above WCAG 2.2 2.5.8's 24px); the sort control's label is measured identical to its<th>on colour, white-space, overflow, text-overflow, font size and weight, so headings are unchanged.Test plan
pnpm exec vitest run packages/core/src packages/lab/src,pnpm -F @astryxdesign/core build,typecheck,typecheck:docs,pnpm check:repo,pnpm lint:strict— output quoted in the review reply..github/scripts/affordance-color-reach.jsagainst a built Storybook, in Chromium. The probe stylesheet is no longer hand-written: it is whatdefineTheme+generateThemeCSSemit for a theme settingcoloron the target, so the derived-var expansion is exercised rather than assumed.strokeon the geometry makes it report the painted glyph ignoring the target.check-knowledge.mjsnegative control: revertingSort controltoinheritswhile the target stays declared fails withcurrent target "table-sort-button" has no anatomy entry with a target disposition.Still needs a maintainer
The CI wiring for the guard edits
.github/workflows/ci.yml, which my token cannot push (noworkflowscope). The block is in the PR discussion.