facebook / facebook/astryx

[Bug] SelectableCard/Thumbnail/ClickableCard lose their hover overlay at 0.4.6 — #5247's :where() guard drops the StyleX specificity boost 7→3

Open
#5,442 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
13.1k
Forks
1.1k
Avg merge
1d 14h
Merged PRs (30d)
669

Description

## Description

#5247 added a zero-specificity guard to every self-`:hover` selector:

```
:hover → :hover:where(:not(:disabled,[aria-disabled="true"]))
```

That reasoning is right and `:where()` does weigh (0,0,0). But on `SelectableCard`, `Thumbnail` and `ClickableCard` the `:hover` key **also targets a pseudo-element** (`:hover::after`), and adding the guard changed the StyleX **priority bucket** that key lands in. StyleX derives its `:not(#\#)` specificity boost from the bucket index, so the hover rule's boost dropped from **7 IDs to 3**, while the resting `background-color: transparent` on the same `::after` kept all 7.

`:not(#\#)` contributes ID-level specificity, and IDs dominate class count:

| | selector | specificity |
|---|---|---|
| 0.4.5 hover | `.xi14tyy.xi14tyy:hover` + 7×`:not(#\#)` + `::after` | **(7,3,1)** → wins |
| 0.4.7 hover | `.x1912f9e.x1912f9e.x1912f9e:hover:where(…)` + 3×`:not(#\#)` + `::after` | **(3,4,1)** → loses |
| both, resting | `.xyhc2n1` + 7×`:not(#\#)` + `::after` | **(7,1,1)** |

The resting reset now out-specifies the hover rule, in the same `@layer astryx-base`, so **the hover overlay never paints**. `SelectableCard` and `Thumbnail` lose their only hover affordance (WCAG 1.4.1). This is Astryx-vs-Astryx — no consumer CSS is involved (see Reproduction).

Expected: an enabled card paints `--color-overlay-hover` on its `::after` on hover, as at 0.4.5.
Actual: the `::after` stays `rgba(0, 0, 0, 0)` at 0.4.6 and 0.4.7.

### The rules, verbatim from `dist/astryx.css`

0.4.5:
```css
@media (hover: hover){.xi14tyy.xi14tyy:hover:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{background-color:var(--color-overlay-hover)}}
.xyhc2n1:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{background-color:transparent}
.x1k7wiig:active:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{background-color:var(--color-overlay-pressed)}
```

0.4.7 — note the boost count on line 1 only:
```css
@media (hover: hover){.x1912f9e.x1912f9e.x1912f9e:hover:where(:not(:disabled,[aria-disabled="true"])):not(#\#):not(#\#):not(#\#)::after{background-color:var(--color-overlay-hover)}}
.xyhc2n1:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{background-color:transparent}
.x1k7wiig:active:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::after{background-color:var(--color-overlay-pressed)}
```

The resting and `:active` atomics are byte-identical across the two releases. Only the hover rule moved, and the source diff is one line (`SelectableCard.tsx:86` and its twins):

```diff
hoverOnPointer: {
'@media (hover: hover)': {
- ':hover::after': {
+ ':hover:where(:not(:disabled,[aria-disabled="true"]))::after': {
backgroundColor: colorVars['--color-overlay-hover'],
```

### Root cause — a tokenizer in `@stylexjs/babel-plugin` 0.19.0

`getCompoundPseudoPriority()` computes the bucket for a key mixing a pseudo-class with a pseudo-element, and **bails out whenever any part contains `(`**:

```js
const PSEUDO_PART_REGEX = /::[a-zA-Z-]+|:[a-zA-Z-]+(?:\([^)]*\))?/g;

function getCompoundPseudoPriority(key) {
const parts = key.match(PSEUDO_PART_REGEX);
if (!parts || parts.length <= 1 || parts.some(p => p.includes('('))) return; // ← here
...
}
```

Two things go wrong, and the second is what triggers the bail-out:

1. `PSEUDO_PART_REGEX` uses `\([^)]*\)`, which cannot match **nested** parentheses. On `:where(:not(:disabled,[aria-disabled="true"]))` it stops at the first `)`, producing the malformed part `:where(:not(:disabled,[aria-disabled="true"])`.
2. That part contains `(`, so `.some(p => p.includes('('))` fires and the function returns `undefined`. `getPriority()` falls through past the pseudo-element branch (the key doesn't *start* with `::`) into `getPseudoClassPriority()`, which does `key.split('(')[0]` → `":hover:where"` → not in the table → the **`?? 40` default**.

Running the plugin's own functions over the four real keys:

| key | priority |
|---|---|
| `::after` | 5000 |
| `:active::after` | 5170 |
| `:hover::after` | **5130** |
| `:hover:where(:not(:disabled,[aria-disabled="true"]))::after` | **40** |

A 5130-priority rule sorts into a late bucket and earns 7 boost `:not(#\#)`s; a 40-priority rule sorts early and earns 3. The guard is specificity-neutral at the CSS level, but it is **not bucket-neutral** — and the bucket sets the boost.

### Scope — exactly one rule library-wide

I diffed every `:hover` rule in `astryx.css` between 0.4.5 and 0.4.7 and cross-referenced which atomics are co-carried on one element in `dist/**`:

- 27 `:hover` rules at 0.4.5, 28 at 0.4.7; **27 took the guard.**
- Of those, **exactly 1 lost ID-boost** (7 → 3): `background-color::after`.
- It is also the **only** guarded hover rule targeting a pseudo-element at all — the precondition for entering `getCompoundPseudoPriority()`.
- Scanning all rules for "a resting rule out-specifies a co-carried hover rule for the same property + pseudo-element" returns **0 hits at 0.4.5 and 1 at 0.4.7** — this one.

Blast radius: the three card components, plus any future `:hover::before` / `:hover::after` that takes the guard.

## Suggested fix

**The cheap one:** all three components already apply the hover class conditionally — `!isDisabled && styles.hoverOnPointer` (`SelectableCard.tsx:370`, `ClickableCard.tsx:318`) and `isInteractive && styles.hoverOnPointer` (`Thumbnail.tsx:418`). A disabled card never receives the atomic, so **the guard is redundant on these three**: reverting those three keys to bare `:hover::after` restores the overlay and loses none of #5247's protection.

That needs an escape hatch in the new lint rule — `@astryx/no-hover-on-disabled` is documented as deliberately unconditional and is autofixable, so it would re-add the guard. Teaching it to skip keys that also target a pseudo-element would be narrower than a per-site disable comment, and that exclusion is exactly the at-risk set.

**The real one:** fix the tokenizer in StyleX so a functional pseudo-class doesn't collapse a compound key to the `?? 40` default — match nested parens, or strip balanced `:where(…)` / `:is(…)` groups before tokenizing since they contribute no specificity, and drop the `.some(p => p.includes('('))` bail-out. `:where()` is the recommended way to write a specificity-neutral guard, so any library adopting it on a `:hover::after` key hits this. I searched `facebook/stylex` and found no prior report; happy to file there if you'd prefer that be the primary.

**A gate:** #5247's audit is zero-tolerance in one direction only — no disabled element may paint a hover state. Its inverse would have caught this on the same sweep: *an **enabled** interactive element must paint **something** on hover.* Cheap, given the Chromium harness already exists.

## Reproduction

Rendered against **`astryx.css` alone** — no consumer stylesheet, no consumer theme, only the four tokens the overlay reads:

```
===== @astryxdesign/core 0.4.5 =====
ClickableCard ::after overlay: PAINTS
background-color: rgba(0, 0, 0, 0) -> rgba(26, 27, 34, 0.05)
SelectableCard ::after overlay: PAINTS
Thumbnail ::after overlay: PAINTS

===== @astryxdesign/core 0.4.7 =====
ClickableCard ::after overlay: *** FLAT ***
SelectableCard ::after overlay: *** FLAT ***
Thumbnail ::after overlay: *** FLAT ***
```

By hand:

1. Install `@astryxdesign/core@0.4.7`, import `reset.css` then `astryx.css`, and set `data-astryx-theme` / `data-theme` on `` (the generated theme CSS `@scope`s every token block to those attributes — without them no token resolves).
2. Render `content`.
3. Hover it and read `getComputedStyle(el, '::after').backgroundColor`.
4. It stays `rgba(0, 0, 0, 0)`. Repeat on `0.4.5` and it becomes `rgba(26, 27, 34, 0.05)`.

Two measurement notes, both of which cost me a false reading:

- **Kill transitions first** (`* { transition: none !important }`). The overlay transitions `background-color` and animations don't reliably advance in headless Chrome — a mid-flight read looks exactly like this bug.
- **`@media (hover: hover)` must be satisfied.** A touch-emulating context makes the rule inert for an unrelated and legitimate reason.

## Astryx Version

`@astryxdesign/core@0.4.6` and `@0.4.7` (good at `@0.4.5`). `@stylexjs/babel-plugin@0.19.0`.

## Environment

Headless Chrome over CDP, macOS 15 (Darwin 25.5.0), Node 24. Measured 2026-08-24.

---

Context, not a request: we pin `@astryxdesign/core` exactly (no caret) and carry a patch restoring the overlay with an unlayered rule naming `.x1912f9e` / `.xyhc2n1` / `.x1k7wiig` by hash — a caret range could rehash those and silently unhook it. Nothing is blocked on our side, but the patch is hash-coupled to your internals and we'd like to delete it.

One triage note in case it's useful: our first instinct was that this was our own bug — we re-emit a lot of Astryx atomics from our own StyleX and have been bitten by that collision class repeatedly. What settled it was rendering the card's class list against `astryx.css` alone, with no bytes of ours on the page. Stripping the consumer's CSS out is a fast way to triage any report of this shape, in either direction.

Contributor guide

Open the contributing guide

Research direction

Start with the hoverOnPointer definitions at SelectableCard.tsx:86 and its Thumbnail and ClickableCard twins, then inspect their conditional uses at SelectableCard.tsx:370 and ClickableCard.tsx:318. Run the Chromium reproduction with transitions disabled and a hover-capable context, and compare 0.4.5 with 0.4.7. Done means enabled cards paint the ::after hover overlay while disabled cards do not.

Written by the indexing model from the issue text.

Assessment

Tech stack
css, typescript
Domain
accessibility, frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.