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