mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
♿ 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:
parent
5ff46d8c67
commit
0db511fee8
7 changed files with 182 additions and 12 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue