diff --git a/client/src/components/Chat/Messages/HoverButtons.tsx b/client/src/components/Chat/Messages/HoverButtons.tsx
index 119156049e..49ae50c4cd 100644
--- a/client/src/components/Chat/Messages/HoverButtons.tsx
+++ b/client/src/components/Chat/Messages/HoverButtons.tsx
@@ -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,
diff --git a/client/src/components/Chat/Messages/__tests__/HoverButtons.spec.tsx b/client/src/components/Chat/Messages/__tests__/HoverButtons.spec.tsx
new file mode 100644
index 0000000000..e19d7eb40e
--- /dev/null
+++ b/client/src/components/Chat/Messages/__tests__/HoverButtons.spec.tsx
@@ -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(
+
+
+
+
+
+
+ ,
+ );
+
+ const editButton = container.querySelector(`#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');
+ });
+});
diff --git a/client/src/style.css b/client/src/style.css
index 10b74256d6..10c17133b8 100644
--- a/client/src/style.css
+++ b/client/src/style.css
@@ -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);
diff --git a/packages/client/src/theme/semanticTokens.spec.ts b/packages/client/src/theme/semanticTokens.spec.ts
index 883b668942..2745379689 100644
--- a/packages/client/src/theme/semanticTokens.spec.ts
+++ b/packages/client/src/theme/semanticTokens.spec.ts
@@ -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 = [
+ 'rgb-surface-primary',
+ 'rgb-surface-primary-alt',
+ 'rgb-surface-secondary',
+ 'rgb-surface-dialog',
+ 'rgb-surface-chat',
+ 'rgb-presentation',
+];
+
+const neutralTextTokens: Array = [
+ 'rgb-text-primary',
+ 'rgb-text-secondary',
+ 'rgb-text-secondary-alt',
+ 'rgb-text-tertiary',
+];
+
+const statusTextTokens: Array = ['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,
+ surfaces: Array,
+): 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([]);
+ });
+});
diff --git a/packages/client/src/theme/themes/dark.ts b/packages/client/src/theme/themes/dark.ts
index ec7b4b73ef..81d31503dd 100644
--- a/packages/client/src/theme/themes/dark.ts
+++ b/packages/client/src/theme/themes/dark.ts
@@ -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)
diff --git a/packages/client/src/theme/themes/default.ts b/packages/client/src/theme/themes/default.ts
index 3e2a4c75eb..021abd6d68 100644
--- a/packages/client/src/theme/themes/default.ts
+++ b/packages/client/src/theme/themes/default.ts
@@ -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
diff --git a/packages/client/src/theme/utils/applyTheme.spec.ts b/packages/client/src/theme/utils/applyTheme.spec.ts
index 0b4c50ad82..a10d1f0710 100644
--- a/packages/client/src/theme/utils/applyTheme.spec.ts
+++ b/packages/client/src/theme/utils/applyTheme.spec.ts
@@ -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');
});
});