fix: Restore WCAG AA Contrast for Text Tokens & Hide Edit Action While Streaming (#14677)

*  fix: Restore WCAG AA Contrast for Text Tokens & Hide Edit Action While Streaming

Fixes the unreadable composer placeholder and the edit pencil that appears on
hover mid-generation, plus the sibling token failures found while tracing the
root cause.

Placeholder: #13879 moved the composer from `dark:placeholder-white/60` to the
semantic `placeholder:text-text-tertiary`, but `--text-tertiary` was
`var(--gray-500)` in *both* themes, and #595959 is a dark gray. Dark mode fell
from 5.90:1 to 1.91:1. Fixed at the token (dark -> gray-400, 4.56:1) rather than
the call site: the token has 99 usages and was failing at 1.91-2.77:1 on every
dark surface. The .gizmo dark theme already uses a light gray (#999999) for the
same token, so only the default dark theme carried the inverted value.

Two more instances of the same "token never tuned per theme" bug:
- `--text-warning` was amber-500 in both themes: 2.15:1 in light across 13
  real warning strings. Now amber-700 (5.02:1).
- Light `status-{success,warning,error}` on their own `-subtle` fill measured
  3.58 / 3.07 / 4.41 -- the exact pairing Alert, Badge, Tag and Chip use for
  every status variant. Bumped to the 700 ramp (5.21 / 4.84 / 5.91). Solid
  `bg-status-*` is only used for dots, so nothing renders text on it.

Edit action: `hideEditButton` already covers `isSubmitting` and the button got
`isVisible={false}` -> `opacity-0`, but `group-hover:opacity-100` (0,2,0)
outranks bare `opacity-0` (0,1,0), so hovering the row revealed a disabled
pencil. The reveal classes are now gated on `isVisible`, with
`pointer-events-none` so the hidden button is inert.

Both token sources of truth (style.css and themes/*.ts) were updated and verified
in sync across all 67 tokens.

Tests: new HoverButtons spec covers both hover states; semanticTokens.spec.ts
gains a contrast guardrail over text tokens x surfaces and each status hue
against its subtle fill, verified to fail on the original values.
applyTheme.spec.ts now derives its expectation from the theme object instead of
pinning a hex, so retuning a hue no longer breaks an unrelated plumbing test.

* 🔤 style: Sort imports in HoverButtons spec

CI's changed-files import-order check flagged the new spec; the previous
commit bypassed the lint-staged hook that would have caught it.
This commit is contained in:
Danny Avila 2026-08-07 00:52:07 -04:00 committed by GitHub
parent 5ff46d8c67
commit 0db511fee8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 182 additions and 12 deletions

View file

@ -96,8 +96,9 @@ const HoverButton = memo(
'hover:text-text-primary hover:bg-surface-hover',
'group-hover:visible group-focus-within:visible group-[.final-completion]:visible',
!isLast &&
isVisible &&
'group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:hover)]:opacity-0',
!isVisible && 'opacity-0',
!isVisible && 'pointer-events-none opacity-0',
'focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:outline-none',
isActive && isVisible && 'active text-text-primary bg-surface-hover',
className,

View file

@ -0,0 +1,77 @@
import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot, type MutableSnapshot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EModelEndpoint, type TConversation, type TMessage } from 'librechat-data-provider';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import store from '~/store';
const conversation = {
conversationId: 'convo-1',
endpoint: EModelEndpoint.agents,
title: 'Test',
} as TConversation;
const userMessage = {
messageId: 'user-1',
conversationId: 'convo-1',
parentMessageId: null,
isCreatedByUser: true,
text: 'tell me a long story',
} as TMessage;
function renderHoverButtons(isSubmitting: boolean) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const initializeState = ({ set }: MutableSnapshot) => set(store.textToSpeech, false);
const { container } = render(
<QueryClientProvider client={queryClient}>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter>
<HoverButtons
index={0}
isLast={false}
isEditing={false}
message={userMessage}
conversation={conversation}
isSubmitting={isSubmitting}
enterEdit={jest.fn()}
regenerate={jest.fn()}
handleContinue={jest.fn()}
copyToClipboard={jest.fn()}
latestMessageId="assistant-1"
/>
</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>,
);
const editButton = container.querySelector<HTMLButtonElement>(`#edit-${userMessage.messageId}`);
if (!editButton) {
throw new Error('edit button not rendered');
}
return editButton;
}
describe('HoverButtons edit affordance', () => {
it('stays hidden on row hover while a generation is in flight', () => {
const editButton = renderHoverButtons(true);
expect(editButton).toBeDisabled();
expect(editButton).toHaveClass('opacity-0', 'pointer-events-none');
expect(editButton.className).not.toMatch(/group-hover:opacity-100/);
expect(editButton.className).not.toMatch(/group-focus-within:opacity-100/);
});
it('reveals on row hover once the generation settles', () => {
const editButton = renderHoverButtons(false);
expect(editButton).toBeEnabled();
expect(editButton).toHaveClass('group-hover:opacity-100');
expect(editButton).not.toHaveClass('pointer-events-none', 'opacity-0');
});
});

View file

@ -151,7 +151,7 @@ html {
--text-secondary: var(--gray-600);
--text-secondary-alt: var(--gray-500);
--text-tertiary: var(--gray-500);
--text-warning: var(--amber-500);
--text-warning: var(--amber-700);
--text-destructive: var(--red-600);
--link: var(--blue-600);
--link-hover: var(--blue-700);
@ -190,7 +190,7 @@ html {
--border-xheavy: var(--gray-500);
--border-xheavy-alpha: 1;
--border-destructive: var(--red-600);
--status-success: var(--green-600);
--status-success: var(--green-700);
--status-success-subtle: var(--green-50);
--status-success-border: var(--green-300);
--status-success-strong: 2 133 94;
@ -198,11 +198,11 @@ html {
--status-info-subtle: var(--blue-50);
--status-info-border: var(--blue-300);
--status-info-strong: var(--gray-500);
--status-warning: var(--amber-600);
--status-warning: var(--amber-700);
--status-warning-subtle: var(--amber-50);
--status-warning-border: var(--amber-300);
--status-warning-strong: 199 82 9;
--status-error: var(--red-600);
--status-error: var(--red-700);
--status-error-subtle: var(--red-50);
--status-error-border: var(--red-300);
--status-error-strong: 224 47 31;
@ -226,7 +226,7 @@ html {
--text-primary: var(--gray-100);
--text-secondary: var(--gray-300);
--text-secondary-alt: var(--gray-400);
--text-tertiary: var(--gray-500);
--text-tertiary: var(--gray-400);
--text-warning: var(--amber-500);
--text-destructive: var(--status-error);
--link: var(--blue-400);

View file

@ -1,5 +1,8 @@
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
import type { IThemeRGB } from './types';
import { defaultTheme } from './themes/default';
import { darkTheme } from './themes/dark';
const sharedComponents = [
'AnimatedSearchInput.tsx',
@ -26,3 +29,90 @@ describe('shared component color guardrail', () => {
});
});
});
type Rgb = [number, number, number];
/** Surfaces that carry body copy; `surface-tertiary` is chip/input fill, added per-group below. */
const canvasSurfaces: Array<keyof IThemeRGB> = [
'rgb-surface-primary',
'rgb-surface-primary-alt',
'rgb-surface-secondary',
'rgb-surface-dialog',
'rgb-surface-chat',
'rgb-presentation',
];
const neutralTextTokens: Array<keyof IThemeRGB> = [
'rgb-text-primary',
'rgb-text-secondary',
'rgb-text-secondary-alt',
'rgb-text-tertiary',
];
const statusTextTokens: Array<keyof IThemeRGB> = ['rgb-text-warning', 'rgb-text-destructive'];
/** How Alert/Badge/Tag/Chip paint every status variant: `text-status-x` on `bg-status-x-subtle`. */
const statusHues = ['success', 'info', 'warning', 'error', 'neutral'] as const;
const WCAG_AA_NORMAL = 4.5;
function toRgb(theme: IThemeRGB, token: keyof IThemeRGB): Rgb {
const parts = theme[token]?.trim().split(/\s+/).map(Number);
if (parts?.length !== 3 || parts.some(Number.isNaN)) {
throw new Error(`theme token "${token}" is not an "R G B" triplet`);
}
return [parts[0], parts[1], parts[2]];
}
function channel(value: number): number {
const c = value / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function luminance([r, g, b]: Rgb): number {
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
function contrast(a: Rgb, b: Rgb): number {
const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (lighter + 0.05) / (darker + 0.05);
}
function belowAA(
theme: IThemeRGB,
textTokens: Array<keyof IThemeRGB>,
surfaces: Array<keyof IThemeRGB>,
): string[] {
return textTokens.flatMap((text) =>
surfaces.flatMap((surface) => {
const ratio = contrast(toRgb(theme, text), toRgb(theme, surface));
return ratio < WCAG_AA_NORMAL ? [`${text} on ${surface}: ${ratio.toFixed(2)}:1`] : [];
}),
);
}
describe.each([
['default', defaultTheme],
['dark', darkTheme],
])('%s theme text contrast', (_name, theme: IThemeRGB) => {
it('keeps neutral text at WCAG AA on every surface it renders on', () => {
expect(belowAA(theme, neutralTextTokens, [...canvasSurfaces, 'rgb-surface-tertiary'])).toEqual(
[],
);
});
it('keeps warning and destructive text at WCAG AA on canvas surfaces', () => {
expect(belowAA(theme, statusTextTokens, canvasSurfaces)).toEqual([]);
});
it('keeps every status hue at WCAG AA against its own subtle fill', () => {
const failures = statusHues.flatMap((hue) =>
belowAA(
theme,
[`rgb-status-${hue}` as keyof IThemeRGB],
[`rgb-status-${hue}-subtle` as keyof IThemeRGB],
),
);
expect(failures).toEqual([]);
});
});

View file

@ -9,7 +9,7 @@ export const darkTheme: IThemeRGB = {
'rgb-text-primary': '236 236 236', // #ececec (gray-100)
'rgb-text-secondary': '205 205 205', // #cdcdcd (gray-300)
'rgb-text-secondary-alt': '153 150 150', // #999696 (gray-400)
'rgb-text-tertiary': '89 89 89', // #595959 (gray-500)
'rgb-text-tertiary': '153 150 150', // #999696 (gray-400)
'rgb-text-warning': '245 158 11', // #f59e0b (amber-500)
'rgb-text-destructive': '252 165 165', // #fca5a5 (red-300, matches status-error)

View file

@ -10,7 +10,7 @@ export const defaultTheme: IThemeRGB = {
'rgb-text-secondary': '66 66 66', // #424242 (gray-600)
'rgb-text-secondary-alt': '89 89 89', // #595959 (gray-500)
'rgb-text-tertiary': '89 89 89', // #595959 (gray-500)
'rgb-text-warning': '245 158 11', // #f59e0b (amber-500)
'rgb-text-warning': '180 83 9', // #b45309 (amber-700)
'rgb-text-destructive': '220 38 38', // #dc2626 (red-600)
// Link and accent colors
@ -63,7 +63,7 @@ export const defaultTheme: IThemeRGB = {
'rgb-border-destructive': '220 38 38', // #dc2626 (red-600)
// Status colors
'rgb-status-success': '5 150 105', // #059669 (green-600)
'rgb-status-success': '4 120 87', // #047857 (green-700)
'rgb-status-success-subtle': '236 253 245', // #ecfdf5 (green-50)
'rgb-status-success-border': '110 231 183', // #6ee7b7 (green-300)
'rgb-status-success-strong': '2 133 94', // #02855e
@ -71,11 +71,11 @@ export const defaultTheme: IThemeRGB = {
'rgb-status-info-subtle': '239 246 255', // #eff6ff (blue-50)
'rgb-status-info-border': '147 197 253', // #93c5fd (blue-300)
'rgb-status-info-strong': '89 89 89', // #595959 (gray-500)
'rgb-status-warning': '217 119 6', // #d97706 (amber-600)
'rgb-status-warning': '180 83 9', // #b45309 (amber-700)
'rgb-status-warning-subtle': '255 251 235', // #fffbeb (amber-50)
'rgb-status-warning-border': '252 211 77', // #fcd34d (amber-300)
'rgb-status-warning-strong': '199 82 9', // #c75209
'rgb-status-error': '220 38 38', // #dc2626 (red-600)
'rgb-status-error': '185 28 28', // #b91c1c (red-700)
'rgb-status-error-subtle': '254 242 242', // #fef2f2 (red-50)
'rgb-status-error-border': '252 165 165', // #fca5a5 (red-300)
'rgb-status-error-strong': '224 47 31', // #e02f1f

View file

@ -72,7 +72,9 @@ describe('applyTheme', () => {
it('ships status tokens in the bundled themes', () => {
applyTheme(defaultTheme);
expect(document.documentElement.style.getPropertyValue('--status-error')).toBe('220 38 38');
expect(document.documentElement.style.getPropertyValue('--status-error')).toBe(
defaultTheme['rgb-status-error'],
);
expect(document.documentElement.style.getPropertyValue('--surface-overlay')).toBe('89 89 89');
});
});