MemberJunction / MemberJunction/MJ
Filled mjButton variants fail WCAG AA on their own label — primary in dark, success/warning/danger in light (warning worst at 2.15:1)
- Dominant language
- TSQL
- Stars
- 29
- Forks
- 6
- Avg merge
- 2d 1h
- Merged PRs (30d)
- 323
Description
Every **filled** `mjButton` variant fails WCAG 2.2 AA (4.5:1) on its own label in one theme — `primary` in dark, `success` / `warning` / `danger` in light. `warning` is worst at **2.15:1**. Only `secondary`, `flat` and `outline` clear both themes.
This affects every MJ surface with a filled button, MJExplorer included. We hit it building an embeddable widget on `@memberjunction/ng-ui-components` and corrected the two variants we render locally, but the fix belongs here — the local override is a design-system fork we'd rather delete.
## Measurements
Chromium, MJ **5.48.0**, stock tokens, no host overrides. Label vs. fill, **settled** (transitions disabled — a read taken mid-transition reports a value part-way between themes; dark `primary` reads 4.22:1 mid-flight against its settled 5.53:1).
| variant | light | dark | |
|---|---|---|---|
| `primary` | 4.92 | **3.23** | fails dark |
| `success` | **2.28** | 7.83 | fails light |
| `warning` | **2.15** | 8.31 | fails light |
| `danger` | **3.76** | 4.74 | fails light |
| `secondary` | 13.35 | 18.41 | ✓ |
| `flat` | 7.24 | 12.02 | ✓ |
| `outline` | 4.70 | 5.53 | ✓ (but see below) |
`outline` is filled **on hover** with `--mj-brand-primary` under a white label — the same 3.23:1 defect arriving one state later.
## Root cause — two opposite mechanisms
Both live in `packages/Angular/Generic/ui-components/src/lib/button/button.scss` and `.../shared-generic/src/lib/_tokens.scss` (paths as published in `dist`).
**1. `primary` fails in dark because the fill moves and the label doesn't.**
```scss
.mj-btn--primary {
background: var(--mj-brand-primary);
color: var(--mj-brand-on-primary);
}
```
The dark block redefines `--mj-brand-primary` (`brand-500` → `brand-400`) but **not** `--mj-brand-on-primary`, which stays `neutral-0`. So a white label sits on a *lighter* blue. It gets worse through the states — hover and active go further up the ramp to `brand-300` / `brand-200`, so correcting only the rest state would look fixed and still fail on interaction.
**2. `success` / `warning` / `danger` fail in light because the label moves and the fill doesn't.**
```scss
.mj-btn--success { background: var(--mj-status-success); color: var(--mj-text-inverse); }
.mj-btn--warning { background: var(--mj-status-warning); color: var(--mj-text-inverse); }
.mj-btn--danger { background: var(--mj-status-error); color: var(--mj-text-inverse); }
```
`--mj-text-inverse` flips by theme (`neutral-0` light → `neutral-900` dark), but `--mj-status-*` is **not** redefined in the dark block — `--mj-status-success` is `success-500` in both. So light gets white-on-mid-green (2.28:1) and dark gets near-black-on-mid-green (7.83:1, fine).
`--mj-text-inverse` is doing a job it isn't: it means "the opposite of body text", not "legible on *this* fill". `primary` already has the right shape in `--mj-brand-on-primary` — there's just no status equivalent, and the brand one is missing its dark redefinition.
## A subtler trap in the pressed state
`.mj-btn--success:active` expresses the press as `opacity: .9` rather than a colour change. That composites fill **and** label toward the page behind them: the fill lightens while a white label stays white, landing at **4.24:1** even after the rest state is corrected.
Two things make this easy to miss: `getComputedStyle` reports the *un-composited* colour, so a naive audit says 7.13:1; and Space/Enter matches `:active` **without** `:hover`, so the darker hover fill isn't there to cover it.
## Suggested fix
Per-variant on-colours, mirroring the shape `--mj-brand-on-primary` already establishes:
- add `--mj-status-success-on` / `-warning-on` / `-error-on`, defined per theme against the fill that variant actually paints, and point the three filled variants at them instead of `--mj-text-inverse`;
- redefine `--mj-brand-on-primary` in the dark block (or move `primary`'s dark fill down the ramp) so the white label has enough contrast at rest, hover and active;
- consider whether `:active`'s `opacity: .9` should be a fill change, so the pressed state stays measurable and doesn't quietly composite through.
Happy to open a PR if the direction looks right — say which shape you'd prefer for the token names.
## Reproducing
Any page with MJ's global button CSS and stock tokens:
```js
const btn = document.querySelector('.mj-btn--warning');
const s = getComputedStyle(btn);
console.log(s.color, s.backgroundColor); // → rgb(255,255,255) on rgb(245,158,11) = 2.15:1
```
Toggle `document.documentElement.setAttribute('data-theme','dark')` to see `primary` fail the other way. Measure **after** the transition settles.
Contributor guide
Assessment
This issue has not been assessed yet.