fix(client): address semantic theme review feedback

This commit is contained in:
Marco Beretta 2026-07-25 13:50:42 +02:00
parent be0ece2fa7
commit f1b287fdb9
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
18 changed files with 181 additions and 31 deletions

View file

@ -32,8 +32,8 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
const validTheme = isDark(theme) ? 'dark' : 'light';
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
const authInputClassName =
'webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-accent-primary focus:outline-none focus-visible:border-accent-primary';
const authSecretInputClassName = `${authInputClassName} h-auto pr-12`;
'webkit-dark-styles transition-color peer h-auto w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-accent-primary focus:outline-none focus-visible:border-accent-primary';
const authSecretInputClassName = `${authInputClassName} pr-12`;
const authLabelClassName =
'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-accent-primary rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4';
const authSecretButtonClassName =

View file

@ -37,8 +37,8 @@ const Registration: React.FC = () => {
// only require captcha if we have a siteKey
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
const authInputClassName =
'webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-accent-primary focus:outline-none focus-visible:border-accent-primary';
const authSecretInputClassName = `${authInputClassName} h-auto pr-12`;
'webkit-dark-styles transition-color peer h-auto w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-accent-primary focus:outline-none focus-visible:border-accent-primary';
const authSecretInputClassName = `${authInputClassName} pr-12`;
const authLabelClassName =
'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-accent-primary rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4';
const authSecretButtonClassName =

View file

@ -105,7 +105,7 @@ function RequestPasswordReset() {
},
})}
aria-invalid={!!errors.email}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-accent-primary focus:outline-none"
className="webkit-dark-styles transition-color peer h-auto w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-accent-primary focus:outline-none"
placeholder=" "
/>
<label

View file

@ -117,7 +117,7 @@ test('renders login form', () => {
setError={jest.fn()}
/>,
);
expect(getByLabelText(/email/i)).toBeInTheDocument();
expect(getByLabelText(/email/i)).toHaveClass('h-auto');
expect(getByLabelText(/password/i)).toBeInTheDocument();
});

View file

@ -1,10 +1,10 @@
import reactRouter from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { render, waitFor, screen } from 'test/layout-test-utils';
import * as mockDataProvider from 'librechat-data-provider/react-query';
import type { TStartupConfig } from 'librechat-data-provider';
import * as miscDataProvider from '~/data-provider/Misc/queries';
import * as endpointQueries from '~/data-provider/Endpoints/queries';
import { render, waitFor, screen } from 'test/layout-test-utils';
import * as miscDataProvider from '~/data-provider/Misc/queries';
import * as authMutations from '~/data-provider/Auth/mutations';
import * as authQueries from '~/data-provider/Auth/queries';
import Registration from '~/components/Auth/Registration';
@ -83,7 +83,7 @@ const setup = ({
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
startupConfig: useGetStartupConfigReturnValue.data,
});
const mockUseGetBannerQuery = jest
jest
.spyOn(miscDataProvider, 'useGetBannerQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetBannerQueryReturnValue);
@ -120,7 +120,7 @@ jest.mock('react-router-dom', () => ({
test('renders registration form', () => {
const { getByText, getByTestId, getByRole } = setup();
expect(getByText(/Create your account/i)).toBeInTheDocument();
expect(getByRole('textbox', { name: /Full name/i })).toBeInTheDocument();
expect(getByRole('textbox', { name: /Full name/i })).toHaveClass('h-auto');
expect(getByRole('form', { name: /Registration form/i })).toBeVisible();
expect(getByRole('textbox', { name: /Username/i })).toBeInTheDocument();
expect(getByRole('textbox', { name: /Email/i })).toBeInTheDocument();

View file

@ -102,8 +102,8 @@
.t-icon-swap .t-icon {
grid-area: 1 / 1;
transition:
opacity var(--icon-swap-dur) var(--icon-swap-ease),
filter var(--icon-swap-dur) var(--icon-swap-ease),
opacity var(--icon-swap-dur) var(--icon-swap-ease),
filter var(--icon-swap-dur) var(--icon-swap-ease),
transform var(--icon-swap-dur) var(--icon-swap-ease);
will-change: opacity, filter, transform;
}
@ -180,10 +180,14 @@ html {
--surface-destructive-hover: var(--red-800);
--surface-chat: var(--white);
--border-light: var(--gray-200);
--border-light-alpha: 1;
--border-medium-alt: var(--gray-300);
--border-medium: var(--gray-300);
--border-medium-alpha: 1;
--border-heavy: var(--gray-400);
--border-heavy-alpha: 1;
--border-xheavy: var(--gray-500);
--border-xheavy-alpha: 1;
--border-destructive: var(--red-600);
--status-success: var(--green-600);
--status-success-subtle: var(--green-50);
@ -218,7 +222,7 @@ html {
--text-secondary-alt: var(--gray-400);
--text-tertiary: var(--gray-500);
--text-warning: var(--amber-500);
--text-destructive: var(--red-600);
--text-destructive: var(--status-error);
--link: var(--blue-400);
--link-hover: var(--blue-300);
--link-visited: 192 132 252;
@ -282,9 +286,13 @@ html {
--surface-secondary: var(--gray-50);
--surface-tertiary: var(--gray-100);
--border-light: 0 0 0;
--border-light-alpha: 0.1;
--border-medium: 0 0 0;
--border-medium-alpha: 0.15;
--border-heavy: 0 0 0;
--border-heavy-alpha: 0.2;
--border-xheavy: 0 0 0;
--border-xheavy-alpha: 0.25;
}
.gizmo.dark {
--text-primary: var(--gray-100);
@ -343,12 +351,12 @@ html {
}
.border-token-border-light {
border-color: #ececf1;
border-color: rgb(var(--border-light));
border-color: rgb(var(--border-light) / var(--border-light-alpha, 1));
}
.border-token-border-medium {
border-color: #d9d9e3;
border-color: rgb(var(--border-medium));
border-color: rgb(var(--border-medium) / var(--border-medium-alpha, 1));
}
.bg-token-surface-secondary {
@ -1278,7 +1286,7 @@ button {
overflow-x: auto;
overscroll-behavior-x: contain;
padding-bottom: 0.25rem;
scrollbar-color: rgb(var(--border-medium)) transparent;
scrollbar-color: rgb(var(--border-medium) / var(--border-medium-alpha, 1)) transparent;
scrollbar-width: thin;
}
@ -1287,11 +1295,11 @@ button {
}
.markdown-table-wrapper::-webkit-scrollbar-thumb {
background-color: rgb(var(--border-medium));
background-color: rgb(var(--border-medium) / var(--border-medium-alpha, 1));
}
.markdown-table-wrapper::-webkit-scrollbar-thumb:hover {
background-color: rgb(var(--border-heavy));
background-color: rgb(var(--border-heavy) / var(--border-heavy-alpha, 1));
}
/* Show scrollbar only on hover */
@ -1301,7 +1309,7 @@ button {
}
.scrollbar-hover:hover {
scrollbar-color: rgb(var(--border-medium)) transparent;
scrollbar-color: rgb(var(--border-medium) / var(--border-medium-alpha, 1)) transparent;
}
.scrollbar-hover::-webkit-scrollbar-thumb {
@ -1310,7 +1318,7 @@ button {
}
.scrollbar-hover:hover::-webkit-scrollbar-thumb {
background-color: rgb(var(--border-medium));
background-color: rgb(var(--border-medium) / var(--border-medium-alpha, 1));
transition-delay: 0s;
}
@ -2172,7 +2180,7 @@ html {
border-radius: 0.7rem;
border-width: 1px;
border-style: solid;
border-color: rgb(var(--border-light));
border-color: rgb(var(--border-light) / var(--border-light-alpha, 1));
background-color: rgb(var(--surface-primary));
padding: 0.5rem;
color: rgb(var(--text-primary));

View file

@ -24,6 +24,14 @@ export function getThemeFromEnv() {
if (getEnv('TEXT_TERTIARY')) theme['rgb-text-tertiary'] = getEnv('TEXT_TERTIARY');
if (getEnv('TEXT_WARNING')) theme['rgb-text-warning'] = getEnv('TEXT_WARNING');
// Link and accent colors
if (getEnv('LINK')) theme['rgb-link'] = getEnv('LINK');
if (getEnv('LINK_HOVER')) theme['rgb-link-hover'] = getEnv('LINK_HOVER');
if (getEnv('LINK_VISITED')) theme['rgb-link-visited'] = getEnv('LINK_VISITED');
if (getEnv('ACCENT_PRIMARY')) theme['rgb-accent-primary'] = getEnv('ACCENT_PRIMARY');
if (getEnv('ACCENT_PRIMARY_HOVER'))
theme['rgb-accent-primary-hover'] = getEnv('ACCENT_PRIMARY_HOVER');
// Surface colors
if (getEnv('SURFACE_PRIMARY')) theme['rgb-surface-primary'] = getEnv('SURFACE_PRIMARY');
if (getEnv('SURFACE_SECONDARY')) theme['rgb-surface-secondary'] = getEnv('SURFACE_SECONDARY');

View file

@ -0,0 +1,36 @@
import { getThemeFromEnv } from './getThemeFromEnv';
const originalThemeEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) => key.startsWith('REACT_APP_THEME_')),
);
const clearThemeEnv = () => {
Object.keys(process.env)
.filter((key) => key.startsWith('REACT_APP_THEME_'))
.forEach((key) => delete process.env[key]);
};
beforeEach(clearThemeEnv);
afterAll(() => {
clearThemeEnv();
Object.assign(process.env, originalThemeEnv);
});
describe('getThemeFromEnv', () => {
it('loads link and accent colors', () => {
process.env.REACT_APP_THEME_LINK = '1 2 3';
process.env.REACT_APP_THEME_LINK_HOVER = '4 5 6';
process.env.REACT_APP_THEME_LINK_VISITED = '7 8 9';
process.env.REACT_APP_THEME_ACCENT_PRIMARY = '10 11 12';
process.env.REACT_APP_THEME_ACCENT_PRIMARY_HOVER = '13 14 15';
expect(getThemeFromEnv()).toEqual({
'rgb-link': '1 2 3',
'rgb-link-hover': '4 5 6',
'rgb-link-visited': '7 8 9',
'rgb-accent-primary': '10 11 12',
'rgb-accent-primary-hover': '13 14 15',
});
});
});

View file

@ -7,7 +7,7 @@
border-radius: 1rem;
border-width: 1px;
border-style: solid;
border-color: rgb(var(--border-light));
border-color: rgb(var(--border-light) / var(--border-light-alpha, 1));
background-color: rgb(var(--surface-primary));
padding: 0.5rem;
color: rgb(var(--text-primary));

View file

@ -5,7 +5,7 @@
attribute is absent and the ring always shows the safe fallback. */
html[data-input-modality='pointer'] .lc-field:focus,
html[data-input-modality='pointer'] .lc-field:focus-visible {
border-color: rgb(var(--border-light));
border-color: rgb(var(--border-light) / var(--border-light-alpha, 1));
box-shadow: none;
outline: none;
}

View file

@ -205,6 +205,8 @@ REACT_APP_THEME_TEXT_PRIMARY=33 33 33
REACT_APP_THEME_TEXT_SECONDARY=66 66 66
REACT_APP_THEME_SURFACE_PRIMARY=255 255 255
REACT_APP_THEME_SURFACE_SUBMIT=4 120 87
REACT_APP_THEME_LINK=37 99 235
REACT_APP_THEME_ACCENT_PRIMARY=18 110 107
```
### 2. Create a Theme Loader
@ -470,4 +472,4 @@ When adding new theme colors:
## License
This theme system is part of the @librechat/client package.
This theme system is part of the @librechat/client package.

View file

@ -12,6 +12,13 @@ export const darkTheme: IThemeRGB = {
'rgb-text-tertiary': '89 89 89', // #595959 (gray-500)
'rgb-text-warning': '245 158 11', // #f59e0b (amber-500)
// Link and accent colors
'rgb-link': '96 165 250', // #60a5fa (blue-400)
'rgb-link-hover': '147 197 253', // #93c5fd (blue-300)
'rgb-link-visited': '192 132 252', // #c084fc (purple-400)
'rgb-accent-primary': '65 167 157', // #41a79d
'rgb-accent-primary-hover': '109 200 185', // #6dc8b9
// Ring colors (not defined in dark mode, using default)
'rgb-ring-primary': '89 89 89', // #595959 (gray-500)

View file

@ -12,6 +12,13 @@ export const defaultTheme: IThemeRGB = {
'rgb-text-tertiary': '89 89 89', // #595959 (gray-500)
'rgb-text-warning': '245 158 11', // #f59e0b (amber-500)
// Link and accent colors
'rgb-link': '37 99 235', // #2563eb (blue-600)
'rgb-link-hover': '29 78 216', // #1d4ed8 (blue-700)
'rgb-link-visited': '147 51 234', // #9333ea (purple-600)
'rgb-accent-primary': '18 110 107', // #126e6b
'rgb-accent-primary-hover': '10 79 83', // #0a4f53
// Ring colors
'rgb-ring-primary': '89 89 89', // #595959 (gray-500)

View file

@ -10,6 +10,13 @@ export interface IThemeRGB {
'rgb-text-tertiary'?: string;
'rgb-text-warning'?: string;
// Link and accent colors
'rgb-link'?: string;
'rgb-link-hover'?: string;
'rgb-link-visited'?: string;
'rgb-accent-primary'?: string;
'rgb-accent-primary-hover'?: string;
// Ring colors
'rgb-ring-primary'?: string;
@ -66,6 +73,11 @@ export interface IThemeVariables {
'--text-secondary-alt': string;
'--text-tertiary': string;
'--text-warning': string;
'--link': string;
'--link-hover': string;
'--link-visited': string;
'--accent-primary': string;
'--accent-primary-hover': string;
'--ring-primary': string;
'--header-primary': string;
'--header-hover': string;
@ -94,10 +106,14 @@ export interface IThemeVariables {
'--surface-fixed-hover': string;
'--text-fixed': string;
'--border-light': string;
'--border-light-alpha': string;
'--border-medium': string;
'--border-medium-alpha': string;
'--border-medium-alt': string;
'--border-heavy': string;
'--border-heavy-alpha': string;
'--border-xheavy': string;
'--border-xheavy-alpha': string;
'--brand-purple': string;
'--presentation': string;
}
@ -111,6 +127,11 @@ export interface IThemeColors {
'text-secondary-alt'?: string;
'text-tertiary'?: string;
'text-warning'?: string;
link?: string;
'link-hover'?: string;
'link-visited'?: string;
'accent-primary'?: string;
'accent-primary-hover'?: string;
'ring-primary'?: string;
'header-primary'?: string;
'header-hover'?: string;

View file

@ -0,0 +1,33 @@
import applyTheme from './applyTheme';
const semanticProperties = [
'--link',
'--link-hover',
'--link-visited',
'--accent-primary',
'--accent-primary-hover',
];
afterEach(() => {
semanticProperties.forEach((property) => document.documentElement.style.removeProperty(property));
});
describe('applyTheme', () => {
it('applies link and accent colors from runtime themes', () => {
applyTheme({
'rgb-link': '1 2 3',
'rgb-link-hover': '4 5 6',
'rgb-link-visited': '7 8 9',
'rgb-accent-primary': '10 11 12',
'rgb-accent-primary-hover': '13 14 15',
});
expect(document.documentElement.style.getPropertyValue('--link')).toBe('1 2 3');
expect(document.documentElement.style.getPropertyValue('--link-hover')).toBe('4 5 6');
expect(document.documentElement.style.getPropertyValue('--link-visited')).toBe('7 8 9');
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('10 11 12');
expect(document.documentElement.style.getPropertyValue('--accent-primary-hover')).toBe(
'13 14 15',
);
});
});

View file

@ -31,6 +31,11 @@ function mapTheme(rgb: IThemeRGB): Partial<IThemeVariables> {
'rgb-text-secondary-alt': '--text-secondary-alt',
'rgb-text-tertiary': '--text-tertiary',
'rgb-text-warning': '--text-warning',
'rgb-link': '--link',
'rgb-link-hover': '--link-hover',
'rgb-link-visited': '--link-visited',
'rgb-accent-primary': '--accent-primary',
'rgb-accent-primary-hover': '--accent-primary-hover',
'rgb-ring-primary': '--ring-primary',
'rgb-header-primary': '--header-primary',
'rgb-header-hover': '--header-hover',

View file

@ -11,9 +11,11 @@
* client/src/style.css (`html`, `.dark`, `.gizmo` blocks). Those variables hold
* bare `R G B` channel triplets, so `cssVar` wraps them as
* `rgb(var(--x) / <alpha-value>)`. This makes opacity modifiers work, e.g.
* `bg-surface-primary/50`. shadcn-compatible tokens still hold HSL triplets, so
* they are wrapped in `hsl(...)`; any direct `var(--token)` usage in plain CSS
* must wrap the channel triplet itself, e.g. `color: rgb(var(--text-primary))`.
* `bg-surface-primary/50`. Tokens with an intrinsic alpha use a companion
* `--x-alpha` variable that is multiplied by Tailwind's opacity modifier.
* shadcn-compatible tokens still hold HSL triplets, so they are wrapped in
* `hsl(...)`; any direct `var(--token)` usage in plain CSS must wrap the channel
* triplet itself, e.g. `color: rgb(var(--text-primary))`.
*/
const palette = {
@ -47,6 +49,7 @@ const palette = {
};
const cssVar = (name) => `rgb(var(${name}) / <alpha-value>)`;
const cssVarWithAlpha = (name) => `rgb(var(${name}) / calc(var(${name}-alpha, 1) * <alpha-value>))`;
const hslVar = (name) => `hsl(var(${name}))`;
/**
@ -105,11 +108,11 @@ function createTailwindColors() {
'surface-fixed-hover': cssVar('--surface-fixed-hover'),
'text-fixed': cssVar('--text-fixed'),
'border-light': cssVar('--border-light'),
'border-medium': cssVar('--border-medium'),
'border-light': cssVarWithAlpha('--border-light'),
'border-medium': cssVarWithAlpha('--border-medium'),
'border-medium-alt': cssVar('--border-medium-alt'),
'border-heavy': cssVar('--border-heavy'),
'border-xheavy': cssVar('--border-xheavy'),
'border-heavy': cssVarWithAlpha('--border-heavy'),
'border-xheavy': cssVarWithAlpha('--border-xheavy'),
'border-destructive': cssVar('--border-destructive'),
'status-success': cssVar('--status-success'),

View file

@ -0,0 +1,20 @@
const { createTailwindColors } = require('./createTailwindColors');
describe('createTailwindColors', () => {
it('combines intrinsic border alpha with Tailwind opacity modifiers', () => {
const colors = createTailwindColors();
expect(colors['border-light']).toBe(
'rgb(var(--border-light) / calc(var(--border-light-alpha, 1) * <alpha-value>))',
);
expect(colors['border-medium']).toBe(
'rgb(var(--border-medium) / calc(var(--border-medium-alpha, 1) * <alpha-value>))',
);
expect(colors['border-heavy']).toBe(
'rgb(var(--border-heavy) / calc(var(--border-heavy-alpha, 1) * <alpha-value>))',
);
expect(colors['border-xheavy']).toBe(
'rgb(var(--border-xheavy) / calc(var(--border-xheavy-alpha, 1) * <alpha-value>))',
);
});
});