fix(ui): give link text its own token instead of reusing the fill colour

--primary is a fill paired with white --primary-foreground, and as text on dark
it measures 3.12:1 on --card (2.89:1 through the upload overlay's tint) — below
AA. There is no lightness that clears AA in both roles: text needs L>=0.59 and
white-on-fill needs L<=0.545, so the window is empty. Add --link, measured live
at 6.34:1 on --card in dark and 15.59:1 in light, and point the link button
variant, the table/file-manager/prompt hover links and the two drag overlays at
it. The icon at flow-assistant-messages.tsx keeps --primary: it is a graphic and
clears the 3:1 bar.

The gate probed exactly one button variant, so the failure had been living under
a waiver instead: probe every variant off the union the way the badge half
already does, and drop the color-contrast waiver on the Screenshots source URL —
that node is the link variant, and axe passes it now. The dark link row fails at
3.12 against the previous colour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-25 23:46:51 +07:00
co-authored by Claude Opus 4.8
parent 7fcecdc6b4
commit 0030bfb6cd
10 changed files with 40 additions and 17 deletions
-1
View File
@@ -75,7 +75,6 @@ export const ROUTE_MANIFEST: RouteManifestEntry[] = [
{ rule: 'aria-progressbar-name', tabs: ['Tasks'], target: /\.bg-primary\\\/20/ },
{ rule: 'button-name', tabs: ['Files'], target: /tooltip-trigger.*size-8\[data-slot="button"\]/ },
{ rule: 'target-size', tabs: ['Files'], target: /text-blue-400|button\[aria-label="Select / },
{ rule: 'color-contrast', tabs: ['Screenshots'], target: /\.text-primary > \.font-semibold\.truncate/ },
// The same file-manager row metadata waived on /resources — the Files tab embeds it.
{ rule: 'color-contrast', tabs: ['Files'], target: /text-muted-foreground\\?\/80/ },
{
+25 -8
View File
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import type { BadgeVariant } from '@/components/ui/badge';
import type { ButtonVariant } from '@/components/ui/button';
import { badgeVariants } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
@@ -32,7 +33,16 @@ const declaredEditorTokens = (): string[] => {
return [...new Set(css.match(/--editor-[\w-]+/g) ?? [])].map((token) => token.slice(2));
};
// Keyed off the union so a newly added variant fails to compile until it is probed.
// Keyed off the unions so a newly added variant fails to compile until it is probed.
const BUTTON_VARIANTS = Object.keys({
default: true,
destructive: true,
ghost: true,
link: true,
outline: true,
secondary: true,
} satisfies Record<ButtonVariant, true>) as ButtonVariant[];
const BADGE_VARIANTS = Object.keys({
blue: true,
default: true,
@@ -91,19 +101,26 @@ for (const theme of THEMES) {
}
});
test('the destructive button clears AA at rest and on hover', async ({ page }) => {
test('every button variant clears AA at rest and on hover', async ({ page }) => {
await page.goto('/flows');
await expect(page.getByRole('row', { name: /E2E Alpha/ })).toBeVisible();
await expect(page.locator('html')).toHaveClass(theme === 'dark' ? /dark/ : /light/);
await mountContrastProbes(page, { destructive: buttonVariants({ variant: 'destructive' }) });
await mountContrastProbes(
page,
Object.fromEntries(BUTTON_VARIANTS.map((variant) => [variant, buttonVariants({ variant })])),
);
expect.soft(await measureContrast(page, 'destructive'), 'button at rest').toBeGreaterThanOrEqual(AA_NORMAL);
for (const variant of BUTTON_VARIANTS) {
expect
.soft(await measureContrast(page, variant), `button ${variant} at rest`)
.toBeGreaterThanOrEqual(AA_NORMAL);
await page.locator('[data-contrast="destructive"]').hover();
expect
.soft(await measureContrast(page, 'destructive'), 'button on hover')
.toBeGreaterThanOrEqual(AA_NORMAL);
await page.locator(`[data-contrast="${variant}"]`).hover();
expect
.soft(await measureContrast(page, variant), `button ${variant} on hover`)
.toBeGreaterThanOrEqual(AA_NORMAL);
}
});
test('editor highlight tokens clear AA on the editor surface', async ({ page }) => {
@@ -365,7 +365,7 @@ export function FileManager({
return (
<Button
aria-label={sortHeaderAriaLabel(column, direction)}
className="text-muted-foreground hover:text-primary -mx-2 flex h-auto justify-start gap-1.5 px-2 py-1 text-xs font-medium no-underline hover:no-underline"
className="text-muted-foreground hover:text-link -mx-2 flex h-auto justify-start gap-1.5 px-2 py-1 text-xs font-medium no-underline hover:no-underline"
onClick={() => toggleSort(column)}
variant="link"
>
+3 -1
View File
@@ -27,7 +27,7 @@ const buttonVariants = cva(
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',
link: 'text-link underline-offset-4 hover:underline',
outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
},
@@ -39,6 +39,8 @@ export interface ButtonProps extends React.ComponentProps<'button'>, VariantProp
asChild?: boolean;
}
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>['variant']>;
function Button({ asChild = false, className, size, type, variant, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button';
+1 -1
View File
@@ -939,7 +939,7 @@ function DataTableColumnHeader<TData, TValue = unknown>({ column, title }: DataT
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
className="text-muted-foreground hover:text-link flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => cycleColumnSort(column)}
variant="link"
>
+1 -1
View File
@@ -54,7 +54,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
className={cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
'text-muted-foreground [&>a:hover]:text-link text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
className,
)}
data-slot="empty-description"
@@ -204,7 +204,7 @@ function FlowFiles() {
{isDragging && (
<div className="bg-primary/10 border-primary pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-lg border-2 border-dashed">
<div className="text-primary flex flex-col items-center gap-2">
<div className="text-link flex flex-col items-center gap-2">
<FolderUp className="size-8" />
<span className="text-sm font-medium">Drop files to upload</span>
</div>
+1 -1
View File
@@ -469,7 +469,7 @@ function Resources() {
{isDragging && hasResources && (
<div className="bg-primary/10 border-primary pointer-events-none absolute inset-2 z-30 flex items-center justify-center rounded-lg border-2 border-dashed">
<div className="text-primary flex flex-col items-center gap-2">
<div className="text-link flex flex-col items-center gap-2">
<FolderUp className="size-8" />
<span className="text-sm font-medium">Drop files to upload</span>
</div>
@@ -336,7 +336,7 @@ function SettingsPrompts() {
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
className="text-muted-foreground hover:text-link flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
@@ -512,7 +512,7 @@ function SettingsPrompts() {
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 hover:no-underline"
className="text-muted-foreground hover:text-link flex items-center gap-2 p-0 hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
+5
View File
@@ -166,6 +166,7 @@
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-link: var(--link);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
@@ -306,6 +307,9 @@
--sidebar-accent-foreground: oklch(0.32 0 0);
--sidebar-border: oklch(0.93 0.01 240);
--sidebar-ring: oklch(0.25 0.14 245);
/* Link TEXT. --primary is a fill (paired with white --primary-foreground) and cannot double as
text: on dark it measures 3.12:1 on --card, and no lightness clears AA in both roles at once. */
--link: oklch(0.25 0.14 245);
/* Editor highlight TEXT, tuned for readable contrast on the editor surface (unlike --primary, a fill
color that fails WCAG AA as text on dark). Four distinct hues so adjacent tokens don't blur:
link = theme blue 245; variable = green 155 (matches the panel's "used" badge); tag = violet 300;
@@ -371,6 +375,7 @@
--sidebar-accent-foreground: oklch(0.92 0.02 245);
--sidebar-border: oklch(0.3 0.04 245);
--sidebar-ring: oklch(0.5 0.16 245);
--link: oklch(0.68 0.13 245);
--editor-accent: oklch(0.68 0.13 245);
--editor-variable: oklch(0.72 0.12 155);
--editor-tag: oklch(0.68 0.13 300);