fix(client): expose status tokens to runtime themes and document channel format

Add the status, text-destructive and border-destructive families to IThemeRGB,
IThemeVariables, IThemeColors, mapTheme and the bundled light/dark themes so
ThemeProvider consumers can theme Alert and the status badges instead of falling
back to the stylesheet palette.

Update the theme README to document the channel-triplet contract that the RGB
migration introduced, since the previous examples used complete CSS colors that
now produce invalid declarations.
This commit is contained in:
Marco Beretta 2026-07-28 04:18:45 +02:00
parent 3dac0328ce
commit 8a9c6b2166
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
6 changed files with 219 additions and 43 deletions

View file

@ -3,6 +3,7 @@
This theme system allows you to dynamically change colors in your React application using CSS variables and Tailwind CSS. It combines dark/light mode switching with dynamic color theming capabilities.
## Table of Contents
- [Overview](#overview)
- [How It Works](#how-it-works)
- [Basic Usage](#basic-usage)
@ -17,6 +18,7 @@ This theme system allows you to dynamically change colors in your React applicat
## Overview
The theme system provides:
1. **Dark/Light Mode Switching** - Automatic theme switching based on user preference
2. **Dynamic Color Theming** - Change colors at runtime without recompiling CSS
3. **CSS Variable Based** - Uses CSS custom properties for performance
@ -32,14 +34,16 @@ The theme system operates in three layers:
3. **Tailwind Layer**: Maps CSS variables to Tailwind utility classes
### Default Behavior (No Custom Theme)
- CSS variables cascade from your app's `style.css` definitions
- Light mode uses variables under `html` selector
- Dark mode uses variables under `.dark` selector
- No JavaScript intervention in color values
### Custom Theme Behavior
- Only applies when `themeRGB` prop is provided
- Overrides CSS variables with `rgb()` formatted values
- Overrides CSS variables with bare `R G B` channel triplets
- Maintains compatibility with existing CSS
## Basic Usage
@ -66,14 +70,17 @@ function App() {
### 3. Set Up Your Base CSS
Ensure your app has CSS variables defined as fallbacks:
Ensure your app has CSS variables defined as fallbacks. Every theme variable must
hold a **bare `R G B` channel triplet**, not a complete CSS color, because the
Tailwind color map wraps them as `rgb(var(--x) / <alpha-value>)` so that opacity
modifiers such as `bg-surface-primary/50` work:
```css
/* style.css */
:root {
--white: #fff;
--gray-800: #212121;
--gray-100: #ececec;
--white: 255 255 255;
--gray-800: 33 33 33;
--gray-100: 236 236 236;
/* ... other color definitions */
}
@ -90,6 +97,13 @@ html {
}
```
Any direct use of these variables in hand-written CSS must wrap the triplet
itself: `color: rgb(var(--text-primary));`.
> **Breaking change:** earlier versions accepted complete colors
> (`--text-primary: #212121`). Hex, `rgb(...)`, and named colors now produce
> invalid declarations and must be converted to channel triplets.
### 4. Configure Tailwind
Update your `tailwind.config.js`:
@ -105,10 +119,10 @@ module.exports = {
theme: {
extend: {
colors: {
// Map CSS variables to Tailwind colors
'text-primary': 'var(--text-primary)',
'surface-primary': 'var(--surface-primary)',
'brand-purple': 'var(--brand-purple)',
// Wrap each channel triplet so opacity modifiers keep working
'text-primary': 'rgb(var(--text-primary) / <alpha-value>)',
'surface-primary': 'rgb(var(--surface-primary) / <alpha-value>)',
'brand-purple': 'rgb(var(--brand-purple) / <alpha-value>)',
// ... other colors
},
},
@ -121,11 +135,9 @@ module.exports = {
```tsx
function MyComponent() {
return (
<div className="bg-surface-primary text-text-primary border border-border-light">
<div className="border border-border-light bg-surface-primary text-text-primary">
<h1 className="text-text-secondary">Hello World</h1>
<button className="bg-surface-submit hover:bg-surface-submit-hover text-white">
Submit
</button>
<button className="bg-surface-submit text-white hover:bg-surface-submit-hover">Submit</button>
</div>
);
}
@ -134,13 +146,16 @@ function MyComponent() {
## Available Theme Colors
### Text Colors
- `text-text-primary` - Primary text color
- `text-text-secondary` - Secondary text color
- `text-text-secondary-alt` - Alternative secondary text
- `text-text-tertiary` - Tertiary text color
- `text-text-warning` - Warning text color
- `text-text-destructive` - Destructive/error text color
### Surface Colors
- `bg-surface-primary` - Primary background
- `bg-surface-secondary` - Secondary background
- `bg-surface-tertiary` - Tertiary background
@ -150,12 +165,25 @@ function MyComponent() {
- `bg-surface-chat` - Chat interface background
### Border Colors
- `border-border-light` - Light border
- `border-border-medium` - Medium border
- `border-border-heavy` - Heavy border
- `border-border-xheavy` - Extra heavy border
- `border-border-destructive` - Destructive action border
### Status Colors
Each status family has a foreground, a `-subtle` background, and a `-border`:
- `text-status-success` / `bg-status-success-subtle` / `border-status-success-border`
- `text-status-info` / `bg-status-info-subtle` / `border-status-info-border`
- `text-status-warning` / `bg-status-warning-subtle` / `border-status-warning-border`
- `text-status-error` / `bg-status-error-subtle` / `border-status-error-border`
- `text-status-neutral` / `bg-status-neutral-subtle` / `border-status-neutral-border`
### Other Colors
- `bg-brand-purple` - Brand purple color
- `bg-presentation` - Presentation background
- `ring-ring-primary` - Focus ring color
@ -168,11 +196,11 @@ function MyComponent() {
import { IThemeRGB } from '@librechat/client';
export const customTheme: IThemeRGB = {
'rgb-text-primary': '0 0 0', // Black
'rgb-text-primary': '0 0 0', // Black
'rgb-text-secondary': '100 100 100', // Gray
'rgb-surface-primary': '255 255 255', // White
'rgb-surface-submit': '0 128 0', // Green
'rgb-brand-purple': '138 43 226', // Blue Violet
'rgb-surface-submit': '0 128 0', // Green
'rgb-brand-purple': '138 43 226', // Blue Violet
// ... define other colors
};
```
@ -214,8 +242,8 @@ REACT_APP_THEME_ACCENT_PRIMARY=18 110 107
```tsx
function getThemeFromEnv(): IThemeRGB | undefined {
// Check if any theme environment variables are set
const hasThemeEnvVars = Object.keys(process.env).some(key =>
key.startsWith('REACT_APP_THEME_')
const hasThemeEnvVars = Object.keys(process.env).some((key) =>
key.startsWith('REACT_APP_THEME_'),
);
if (!hasThemeEnvVars) {
@ -233,10 +261,7 @@ function getThemeFromEnv(): IThemeRGB | undefined {
### 3. Apply Environment Theme
```tsx
<ThemeProvider
initialTheme="system"
themeRGB={getThemeFromEnv()}
>
<ThemeProvider initialTheme="system" themeRGB={getThemeFromEnv()}>
<App />
</ThemeProvider>
```
@ -252,7 +277,7 @@ import { useTheme } from '@librechat/client';
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Current theme: {theme}
@ -262,6 +287,7 @@ function ThemeToggle() {
```
### Theme Options
- `'light'` - Force light mode
- `'dark'` - Force dark mode
- `'system'` - Follow system preference
@ -273,11 +299,13 @@ If you're migrating from an older theme system:
### 1. Update Imports
**Before:**
```tsx
import { ThemeContext, ThemeProvider } from '~/hooks/ThemeContext';
```
**After:**
```tsx
import { ThemeContext, ThemeProvider } from '@librechat/client';
```
@ -287,8 +315,8 @@ import { ThemeContext, ThemeProvider } from '@librechat/client';
The new ThemeProvider is backward compatible but adds new capabilities:
```tsx
<ThemeProvider
initialTheme="system" // Same as before
<ThemeProvider
initialTheme="system" // Same as before
themeRGB={customTheme} // New: optional custom colors
>
<App />
@ -307,6 +335,7 @@ const { theme, setTheme } = useContext(ThemeContext);
## Implementation Details
### File Structure
```
packages/client/src/theme/
├── context/
@ -328,13 +357,15 @@ packages/client/src/theme/
### CSS Variable Format
The theme system uses RGB values in CSS variables:
- CSS Variable: `--text-primary: rgb(33 33 33)`
- CSS Variable: `--text-primary: 33 33 33`
- Theme Definition: `'rgb-text-primary': '33 33 33'`
- Tailwind Usage: `text-text-primary`
### RGB Format Requirements
All color values must be in space-separated RGB format:
- ✅ Correct: `'255 255 255'`
- ❌ Incorrect: `'#ffffff'` or `'rgb(255, 255, 255)'`
@ -345,22 +376,27 @@ This format allows Tailwind to apply opacity modifiers like `bg-surface-primary/
### Common Issues
#### 1. Colors Not Applying
- **Issue**: Custom theme colors aren't showing
- **Solution**: Ensure you're passing the `themeRGB` prop to ThemeProvider
- **Check**: CSS variables in DevTools should show `rgb(R G B)` format
- **Check**: CSS variables in DevTools should show a bare `R G B` triplet
#### 2. Circular Reference Errors
- **Issue**: `--brand-purple: var(--brand-purple)` creates infinite loop
- **Solution**: Use direct color values: `--brand-purple: #ab68ff`
- **Solution**: Use direct channel values: `--brand-purple: 171 104 255`
#### 3. Dark Mode Not Working
- **Issue**: Dark mode doesn't switch
- **Solution**: Ensure `darkMode: ['class']` is in your Tailwind config
- **Check**: The `<html>` element should have `class="dark"` in dark mode
#### 4. TypeScript Errors
- **Issue**: Type errors when defining themes
- **Solution**: Import and use the `IThemeRGB` interface:
```tsx
import { IThemeRGB } from '@librechat/client';
```
@ -382,16 +418,14 @@ import { useState } from 'react';
function App() {
const [isDark, setIsDark] = useState(false);
return (
<ThemeProvider
<ThemeProvider
initialTheme={isDark ? 'dark' : 'light'}
themeRGB={isDark ? darkTheme : defaultTheme}
themeName={isDark ? 'dark' : 'default'}
>
<button onClick={() => setIsDark(!isDark)}>
Toggle Theme
</button>
<button onClick={() => setIsDark(!isDark)}>Toggle Theme</button>
<YourApp />
</ThemeProvider>
);
@ -417,15 +451,14 @@ const themes = {
function App() {
const [selectedTheme, setSelectedTheme] = useState('default');
return (
<ThemeProvider
themeRGB={themes[selectedTheme]}
themeName={selectedTheme}
>
<ThemeProvider themeRGB={themes[selectedTheme]} themeName={selectedTheme}>
<select onChange={(e) => setSelectedTheme(e.target.value)}>
{Object.keys(themes).map(name => (
<option key={name} value={name}>{name}</option>
{Object.keys(themes).map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<YourApp />
@ -444,12 +477,12 @@ import { getThemeFromEnv } from './utils';
function App() {
const envTheme = getThemeFromEnv();
return (
<ThemeProvider
<ThemeProvider
// Only pass props if you want to override stored values
// If you always pass props, they will override localStorage
initialTheme={envTheme ? "system" : undefined}
initialTheme={envTheme ? 'system' : undefined}
themeRGB={envTheme || undefined}
>
{/* Your app content */}

View file

@ -11,6 +11,7 @@ export const darkTheme: IThemeRGB = {
'rgb-text-secondary-alt': '153 150 150', // #999696 (gray-400)
'rgb-text-tertiary': '89 89 89', // #595959 (gray-500)
'rgb-text-warning': '245 158 11', // #f59e0b (amber-500)
'rgb-text-destructive': '252 165 165', // #fca5a5 (red-300, matches status-error)
// Link and accent colors
'rgb-link': '96 165 250', // #60a5fa (blue-400)
@ -58,6 +59,24 @@ export const darkTheme: IThemeRGB = {
'rgb-border-medium-alt': '66 66 66', // #424242 (gray-600)
'rgb-border-heavy': '89 89 89', // #595959 (gray-500)
'rgb-border-xheavy': '153 150 150', // #999696 (gray-400)
'rgb-border-destructive': '239 68 68', // #ef4444 (red-500)
// Status colors
'rgb-status-success': '110 231 183', // #6ee7b7 (green-300)
'rgb-status-success-subtle': '2 44 34', // #022c22 (green-950)
'rgb-status-success-border': '6 95 70', // #065f46 (green-800)
'rgb-status-info': '147 197 253', // #93c5fd (blue-300)
'rgb-status-info-subtle': '23 37 84', // #172554 (blue-950)
'rgb-status-info-border': '30 64 175', // #1e40af (blue-800)
'rgb-status-warning': '252 211 77', // #fcd34d (amber-300)
'rgb-status-warning-subtle': '69 26 3', // #451a03 (amber-950)
'rgb-status-warning-border': '146 64 14', // #92400e (amber-800)
'rgb-status-error': '252 165 165', // #fca5a5 (red-300)
'rgb-status-error-subtle': '69 10 10', // #450a0a (red-950)
'rgb-status-error-border': '153 27 27', // #991b1b (red-800)
'rgb-status-neutral': '205 205 205', // #cdcdcd (gray-300)
'rgb-status-neutral-subtle': '33 33 33', // #212121 (gray-800)
'rgb-status-neutral-border': '47 47 47', // #2f2f2f (gray-700)
// Brand colors
'rgb-brand-purple': '171 104 255', // #ab68ff

View file

@ -11,6 +11,7 @@ export const defaultTheme: IThemeRGB = {
'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-destructive': '220 38 38', // #dc2626 (red-600)
// Link and accent colors
'rgb-link': '37 99 235', // #2563eb (blue-600)
@ -58,6 +59,24 @@ export const defaultTheme: IThemeRGB = {
'rgb-border-medium-alt': '205 205 205', // #cdcdcd (gray-300)
'rgb-border-heavy': '153 150 150', // #999696 (gray-400)
'rgb-border-xheavy': '89 89 89', // #595959 (gray-500)
'rgb-border-destructive': '220 38 38', // #dc2626 (red-600)
// Status colors
'rgb-status-success': '5 150 105', // #059669 (green-600)
'rgb-status-success-subtle': '236 253 245', // #ecfdf5 (green-50)
'rgb-status-success-border': '110 231 183', // #6ee7b7 (green-300)
'rgb-status-info': '37 99 235', // #2563eb (blue-600)
'rgb-status-info-subtle': '239 246 255', // #eff6ff (blue-50)
'rgb-status-info-border': '147 197 253', // #93c5fd (blue-300)
'rgb-status-warning': '217 119 6', // #d97706 (amber-600)
'rgb-status-warning-subtle': '255 251 235', // #fffbeb (amber-50)
'rgb-status-warning-border': '252 211 77', // #fcd34d (amber-300)
'rgb-status-error': '220 38 38', // #dc2626 (red-600)
'rgb-status-error-subtle': '254 242 242', // #fef2f2 (red-50)
'rgb-status-error-border': '252 165 165', // #fca5a5 (red-300)
'rgb-status-neutral': '66 66 66', // #424242 (gray-600)
'rgb-status-neutral-subtle': '236 236 236', // #ececec (gray-100)
'rgb-status-neutral-border': '205 205 205', // #cdcdcd (gray-300)
// Brand colors
'rgb-brand-purple': '171 104 255', // #ab68ff

View file

@ -9,6 +9,7 @@ export interface IThemeRGB {
'rgb-text-secondary-alt'?: string;
'rgb-text-tertiary'?: string;
'rgb-text-warning'?: string;
'rgb-text-destructive'?: string;
// Link and accent colors
'rgb-link'?: string;
@ -56,6 +57,24 @@ export interface IThemeRGB {
'rgb-border-medium-alt'?: string;
'rgb-border-heavy'?: string;
'rgb-border-xheavy'?: string;
'rgb-border-destructive'?: string;
// Status colors
'rgb-status-success'?: string;
'rgb-status-success-subtle'?: string;
'rgb-status-success-border'?: string;
'rgb-status-info'?: string;
'rgb-status-info-subtle'?: string;
'rgb-status-info-border'?: string;
'rgb-status-warning'?: string;
'rgb-status-warning-subtle'?: string;
'rgb-status-warning-border'?: string;
'rgb-status-error'?: string;
'rgb-status-error-subtle'?: string;
'rgb-status-error-border'?: string;
'rgb-status-neutral'?: string;
'rgb-status-neutral-subtle'?: string;
'rgb-status-neutral-border'?: string;
// Brand colors
'rgb-brand-purple'?: string;
@ -73,6 +92,7 @@ export interface IThemeVariables {
'--text-secondary-alt': string;
'--text-tertiary': string;
'--text-warning': string;
'--text-destructive': string;
'--link': string;
'--link-hover': string;
'--link-visited': string;
@ -114,6 +134,22 @@ export interface IThemeVariables {
'--border-heavy-alpha': string;
'--border-xheavy': string;
'--border-xheavy-alpha': string;
'--border-destructive': string;
'--status-success': string;
'--status-success-subtle': string;
'--status-success-border': string;
'--status-info': string;
'--status-info-subtle': string;
'--status-info-border': string;
'--status-warning': string;
'--status-warning-subtle': string;
'--status-warning-border': string;
'--status-error': string;
'--status-error-subtle': string;
'--status-error-border': string;
'--status-neutral': string;
'--status-neutral-subtle': string;
'--status-neutral-border': string;
'--brand-purple': string;
'--presentation': string;
}
@ -127,6 +163,7 @@ export interface IThemeColors {
'text-secondary-alt'?: string;
'text-tertiary'?: string;
'text-warning'?: string;
'text-destructive'?: string;
link?: string;
'link-hover'?: string;
'link-visited'?: string;
@ -164,6 +201,22 @@ export interface IThemeColors {
'border-medium-alt'?: string;
'border-heavy'?: string;
'border-xheavy'?: string;
'border-destructive'?: string;
'status-success'?: string;
'status-success-subtle'?: string;
'status-success-border'?: string;
'status-info'?: string;
'status-info-subtle'?: string;
'status-info-border'?: string;
'status-warning'?: string;
'status-warning-subtle'?: string;
'status-warning-border'?: string;
'status-error'?: string;
'status-error-subtle'?: string;
'status-error-border'?: string;
'status-neutral'?: string;
'status-neutral-subtle'?: string;
'status-neutral-border'?: string;
'brand-purple'?: string;
presentation?: string;

View file

@ -1,3 +1,4 @@
import { defaultTheme } from '../themes/default';
import applyTheme from './applyTheme';
const semanticProperties = [
@ -6,6 +7,13 @@ const semanticProperties = [
'--link-visited',
'--accent-primary',
'--accent-primary-hover',
'--text-destructive',
'--border-destructive',
'--status-success',
'--status-success-subtle',
'--status-success-border',
'--status-error',
'--status-neutral-border',
];
afterEach(() => {
@ -30,4 +38,31 @@ describe('applyTheme', () => {
'13 14 15',
);
});
it('applies status and destructive colors from runtime themes', () => {
applyTheme({
'rgb-text-destructive': '20 21 22',
'rgb-border-destructive': '23 24 25',
'rgb-status-success': '26 27 28',
'rgb-status-success-subtle': '29 30 31',
'rgb-status-success-border': '32 33 34',
'rgb-status-error': '35 36 37',
'rgb-status-neutral-border': '38 39 40',
});
const style = document.documentElement.style;
expect(style.getPropertyValue('--text-destructive')).toBe('20 21 22');
expect(style.getPropertyValue('--border-destructive')).toBe('23 24 25');
expect(style.getPropertyValue('--status-success')).toBe('26 27 28');
expect(style.getPropertyValue('--status-success-subtle')).toBe('29 30 31');
expect(style.getPropertyValue('--status-success-border')).toBe('32 33 34');
expect(style.getPropertyValue('--status-error')).toBe('35 36 37');
expect(style.getPropertyValue('--status-neutral-border')).toBe('38 39 40');
});
it('ships status tokens in the bundled themes', () => {
applyTheme(defaultTheme);
expect(document.documentElement.style.getPropertyValue('--status-error')).toBe('220 38 38');
});
});

View file

@ -31,6 +31,7 @@ function mapTheme(rgb: IThemeRGB): Partial<IThemeVariables> {
'rgb-text-secondary-alt': '--text-secondary-alt',
'rgb-text-tertiary': '--text-tertiary',
'rgb-text-warning': '--text-warning',
'rgb-text-destructive': '--text-destructive',
'rgb-link': '--link',
'rgb-link-hover': '--link-hover',
'rgb-link-visited': '--link-visited',
@ -68,6 +69,22 @@ function mapTheme(rgb: IThemeRGB): Partial<IThemeVariables> {
'rgb-border-medium-alt': '--border-medium-alt',
'rgb-border-heavy': '--border-heavy',
'rgb-border-xheavy': '--border-xheavy',
'rgb-border-destructive': '--border-destructive',
'rgb-status-success': '--status-success',
'rgb-status-success-subtle': '--status-success-subtle',
'rgb-status-success-border': '--status-success-border',
'rgb-status-info': '--status-info',
'rgb-status-info-subtle': '--status-info-subtle',
'rgb-status-info-border': '--status-info-border',
'rgb-status-warning': '--status-warning',
'rgb-status-warning-subtle': '--status-warning-subtle',
'rgb-status-warning-border': '--status-warning-border',
'rgb-status-error': '--status-error',
'rgb-status-error-subtle': '--status-error-subtle',
'rgb-status-error-border': '--status-error-border',
'rgb-status-neutral': '--status-neutral',
'rgb-status-neutral-subtle': '--status-neutral-subtle',
'rgb-status-neutral-border': '--status-neutral-border',
'rgb-brand-purple': '--brand-purple',
'rgb-presentation': '--presentation',
};