Skip to content

Make positive and destructive buttons and yellow status text readable (WCAG AA) - #1873

Draft
dawsontoth wants to merge 1 commit into
claude/1839-warning-variant-contrastfrom
claude/1859-positive-destructive-contrast
Draft

dawsontoth wants to merge 1 commit into
claude/1839-warning-variant-contrastfrom
claude/1859-positive-destructive-contrast

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

⊙ Problem

Two shared Button variants and the hand-rolled yellow status text failed WCAG AA (4.5:1 for normal-size text). Button variant="positive" put white text on --green (2.13:1), and variant="destructive" put white text on --destructive (3.73:1). Its /90 hover lightened the fill on light surfaces, which made it worse (3.36:1). Yellow chip and status text was --yellow on its own tint or on a white card (1.71:1 to 1.97:1). The issue says dark mode is fine, and that holds for the yellow text, but not for the buttons: their text sits on the fill rather than on the page, so both buttons failed in both themes. Closes #1859.

❓ Your call: #1859 suggested a separate PR for the buttons, since about two dozen callers inherit them. I kept the buttons and the yellow text together because each change is a class swap and they share one contrast table. The issue's ErrorComponent row is left unchanged; see Alternatives.

💡 Solution

  • Positive: keeps the --green fill and switches to dark text (text-black-dark), which is 8.78:1. This is the remedy fix(ui): make the warning Alert and Button readable in light mode #1860 used for the warning button (black on yellow).
  • Destructive: moves to red-600 with white text, 4.73:1. Hover now darkens to red-700 (6.37:1) instead of lightening with /90. red-600 (#e7000b) is a more saturated version of the old #ef4444, so it still reads as plain red. White text stays because that is the convention for a destructive action, and red-600 is the lightest Tailwind red that clears 4.5:1 for white.
  • Yellow text: becomes amber-800 in light mode and stays --yellow in dark mode (text-amber-800 dark:text-yellow), as ClusterCard already does. amber-700, the shade ClustersList uses, isn't enough here: the cluster status pills sit on the lavender page, where it drops to 4.36:1.

❓ Your call: these are Tailwind palette classes on the variants, following #1860, not changes to the --destructive and --yellow tokens. A single token can't do both jobs. A red dark enough to carry white text (red-600) is 2.31:1 as text on the dark card, so changing --destructive would fix the button and break every dark-mode text-destructive. I filed the token-level failures as #1872 (P3), along with the sibling chip hues (green, blue, pink and red, several of which also fail in dark mode) and text-destructive body text, which is 3.76:1 on white.

Final ratios, measured in Chrome from the repo's own Tailwind build (details under Verification). AA is 4.5:1 for all of these.

element before (light / dark) after, light (card / page / popover) after, dark (card / page / popover)
Button positive 2.13 / 2.13 8.78 / 8.78 / 8.78 8.78 / 8.78 / 8.78
Button positive, hover 2.13 / 2.13 9.51 / 9.41 / 9.51 7.70 / 7.28 / 7.28
Button and Badge destructive 3.73 / 3.73 4.73 / 4.73 / 4.73 4.73 / 4.73 / 4.73
Button and Badge destructive, hover 3.36 / 4.20 6.37 / 6.37 / 6.37 6.37 / 6.37 / 6.37
yellow chip text on bg-yellow/10 1.84 / 4.69 6.61 / 6.13 / 6.61 4.69 / 8.15 / 8.78 (unchanged)
yellow text on the surface 1.97 / 5.57 7.09 / 6.54 / 7.09 5.57 / 9.55 / 10.03 (unchanged)

The "before" column is on the card, or the darkest-margin surface where they differ. The button rows don't depend on the surface, because the text sits on an opaque fill.

⚖️ Alternatives

  • Positive: a darker green fill with white text. green-700 gives 4.90:1 at rest, but its /90 hover is 4.13:1 on a white card, and the button would no longer be the brand green that positiveOutline, the badges and the usage meter use.
  • Destructive: dark text on --destructive. That is 5.02:1 and keeps the token, but black on red reads like a warning rather than a destructive action, and it has less margin than red-600 with a darker hover.
  • Destructive: red-700 with the /90 hover. That is 6.37:1 at rest and at least 5.79:1 on hover, but the fill is visibly darker than today's red. red-600 stays closer to the current color.
  • ErrorComponent's default text-red (issue table: 3.56:1), left unchanged. Its only red text is the CardTitle at text-2xl, which is 24px and counts as large text under WCAG, so the bar is 3:1. It measures 3.56:1 on the light card and 3.09:1 on the dark card. Normal-size text inside it is CardDescription, which uses text-muted-foreground. The CORS notice overrides the color to yellow, which was 1.97:1 and failed even the large-text bar, so it now uses text-amber-800 dark:text-yellow (7.09:1).

❓ Your call: if you want ErrorComponent's title to clear 4.5:1 too, text-pink-700 is the closest darker shade of --red (5.89:1 on white). It would still need a dark-mode shade, because --red on the dark card is only 3.09:1. I left it alone because it already passes the large-text threshold.

🔧 Changes

✅ Verification

Route: class-level unit tests, plus a rendered measurement against the real Tailwind build. Styling needs no backend, but every caller sits behind a signed-in cluster view, so I didn't drive the app.

  • src/components/ui/filledVariants.test.tsx (new, jsdom) renders the real <Button variant="positive">, and the destructive Button and Badge. It asserts the fill and text classes, the red-700 hover, and that no bg-destructive (or hover:bg-destructive/90) class remains. StatusBadge.test.ts and the new MethodBadge.test.tsx assert text-amber-800 plus dark:text-yellow, and no bare text-yellow, for 4xx and PUT. The 4xx boundary assertions now key on the bg-yellow/10 tint. Fails-on-base: all five new assertions fail with claude/1839-warning-variant-contrast's source.
  • Rendered check: I compiled src/index.css with the repo's @tailwindcss/node 4.3.3 for the exact before and after class lists, laid each one out on the light card, page and popover and on the dark card, page and popover, and loaded the page in Chrome 152. The page painted each element's computed background chain and text color onto a canvas, so Chrome did its own color conversion and compositing, and computed the WCAG ratio from those pixels. That produced the table above, and it agrees with an independent oklch-to-sRGB calculation to within 0.04. The same check confirmed that the build emits dark:text-yellow and that the text color under .dark is #ffa500. Visually, the positive button is black on green, the destructive button is white on a plain red, and the yellow chips read as amber on a yellow tint.
  • Gates: npx vitest run over src/components, src/features/instance/apis and src/features/cluster passed 37 files and 379 tests. npx tsc -b, npx oxlint --format stylish . and npx dprint check all exited 0. The pre-commit hook's full suite passed: 398 files, 3,670 tests.
  • Cross-model review, one round: codex (graded) and gemini ran. The Cursor leg failed because git fetch could not sign through the 1Password SSH agent, and the Harper domain leg failed on an expired Claude OAuth session, so I triaged the findings by hand. No defect was found, so there was no second round.

🤖 Generated with Claude Code (Anthropic Claude Opus); posted via @dawsontoth.

Related PRs: #1860 overlaps (stacked base; adjacent variant lines in buttonVariants.tsx)
Complexity: easy

Review-Coverage: authored=claude; ran=gemini,codex; blocked=cursor-composer(no-receipt),domain(auth); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=1; full=1 @ ff4371a

Review-Attention: read ~3m (raised: degraded review) @ ff4371a

… readable

White text on the positive button's green fill measured 2.13:1 and on the
destructive fill 3.73:1, in both themes since the text sits on the fill.
Yellow chip and status text was 1.84:1 to 1.97:1 in light mode. All fail
WCAG AA's 4.5:1.

The positive button keeps its green fill and takes dark text (8.78:1), the
remedy the warning button already uses. The destructive button and badge
move to red-600 with white text (4.73:1), darkening to red-700 on hover
(6.37:1) instead of the lightening /90 hover, which would drop below AA in
light mode. Yellow text in the cluster status and safe-mode pills, the usage
"Cycle exhausted" chip and meter, the API explorer's PUT and 4xx badges and
the CORS-disabled notice is amber-800 in light mode (6.13:1 or better) and
stays yellow in dark mode, as ClusterCard already does.

Closes #1859

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request improves color contrast and accessibility (WCAG AA compliance) across various UI components. It replaces low-contrast white text on green and destructive backgrounds with higher-contrast alternatives, such as dark text on green and bg-red-600 for destructive elements. It also updates yellow text on light backgrounds to use text-amber-800 in light mode while retaining dark:text-yellow for dark mode. Corresponding unit tests have been added and updated to verify these style changes. There are no review comments, and I have no additional feedback to provide.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant