fix(ui): keep white on the destructive fill readable in both themes

Four states were under AA, not one: the dark badge and button at rest (3.71), the
light badge on hover (4.34) and the dark button on hover (4.40), plus the dark
destructive menu item (4.01). Badge and button had also drifted apart — one faded
to 80% on hover, the other to 90% — so each failed in a different theme.

The token itself cannot be retuned: sweeping its lightness finds no value that
serves both roles, because the same red is a fill under white text and the text
colour on the page. Darkening it for the fill would break the twenty-four
text-destructive call sites.

Tune the fill's opacity per theme instead, keeping white text and adding no tokens.
A translucent red composites toward what is behind it, so the same fade darkens it
on the dark ground and lightens it on the light one: full strength on light fading
to 90% on hover, 80% on dark dropping to 70%. Hover now raises contrast in both
themes. All four states land between 5.04 and 6.28, verified on a built bundle
against every dark surface token (worst case 5.13 on secondary).

The dark destructive menu item drops its focus wash from 20% to 10%, matching light
and restoring the label to 4.52 at the cost of a fainter highlight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-21 13:42:11 +07:00
co-authored by Claude Fable 5
parent f88427c264
commit 9f40102d6a
4 changed files with 13 additions and 4 deletions
+6 -1
View File
@@ -18,7 +18,12 @@ const badgeVariants = cva(
variant: {
blue: 'border-blue-500/20 bg-blue-500/10 text-blue-700 hover:bg-blue-500/20 dark:text-blue-400',
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
// The dark fill is translucent on purpose: composited over the dark
// ground it darkens the red, which is what keeps the white foreground
// above AA (opaque it measures 3.71). Alphas differ per theme because
// the same fade lightens the red over a white ground.
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/90 dark:bg-destructive/80 dark:hover:bg-destructive/70',
green: 'border-green-500/20 bg-green-500/10 text-green-800 hover:bg-green-500/20 dark:text-green-400',
orange: 'border-orange-500/20 bg-orange-500/10 text-orange-700 hover:bg-orange-500/20 dark:text-orange-400',
outline: 'text-foreground',
+5 -1
View File
@@ -24,7 +24,11 @@ const buttonVariants = cva(
},
variant: {
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
// Translucent dark fill on purpose — over the dark ground it darkens
// the red, keeping the white foreground above AA (opaque it is 3.71).
// Mirrors the destructive Badge; keep the two in step.
destructive:
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 dark:bg-destructive/80 dark:hover:bg-destructive/70',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
+1 -1
View File
@@ -75,7 +75,7 @@ function ContextMenuItem({
return (
<ContextMenuPrimitive.Item
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/10 relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
inset && 'pl-8',
className,
)}
+1 -1
View File
@@ -81,7 +81,7 @@ function DropdownMenuItem({
return (
<DropdownMenuPrimitive.Item
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/10 relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className,
)}