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);