From 8a9c6b21664190911f2cfddb978f73ef7bc25d8b Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:18:45 +0200 Subject: [PATCH] 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. --- packages/client/src/theme/README.md | 119 +++++++++++------- packages/client/src/theme/themes/dark.ts | 19 +++ packages/client/src/theme/themes/default.ts | 19 +++ packages/client/src/theme/types/index.ts | 53 ++++++++ .../client/src/theme/utils/applyTheme.spec.ts | 35 ++++++ packages/client/src/theme/utils/applyTheme.ts | 17 +++ 6 files changed, 219 insertions(+), 43 deletions(-) diff --git a/packages/client/src/theme/README.md b/packages/client/src/theme/README.md index 6d78fd6b89..0d7a8fbf00 100644 --- a/packages/client/src/theme/README.md +++ b/packages/client/src/theme/README.md @@ -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) / )` 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) / )', + 'surface-primary': 'rgb(var(--surface-primary) / )', + 'brand-purple': 'rgb(var(--brand-purple) / )', // ... other colors }, }, @@ -121,11 +135,9 @@ module.exports = { ```tsx function MyComponent() { return ( -
+

Hello World

- +
); } @@ -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 - + ``` @@ -252,7 +277,7 @@ import { useTheme } from '@librechat/client'; function ThemeToggle() { const { theme, setTheme } = useTheme(); - + return ( + ); @@ -417,15 +451,14 @@ const themes = { function App() { const [selectedTheme, setSelectedTheme] = useState('default'); - + return ( - + @@ -444,12 +477,12 @@ import { getThemeFromEnv } from './utils'; function App() { const envTheme = getThemeFromEnv(); - + return ( - {/* Your app content */} diff --git a/packages/client/src/theme/themes/dark.ts b/packages/client/src/theme/themes/dark.ts index 94f8798103..6d00ae5c5b 100644 --- a/packages/client/src/theme/themes/dark.ts +++ b/packages/client/src/theme/themes/dark.ts @@ -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 diff --git a/packages/client/src/theme/themes/default.ts b/packages/client/src/theme/themes/default.ts index c99e6a0861..2d131ad51f 100644 --- a/packages/client/src/theme/themes/default.ts +++ b/packages/client/src/theme/themes/default.ts @@ -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 diff --git a/packages/client/src/theme/types/index.ts b/packages/client/src/theme/types/index.ts index a7ff8b1d27..2076793d94 100644 --- a/packages/client/src/theme/types/index.ts +++ b/packages/client/src/theme/types/index.ts @@ -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; diff --git a/packages/client/src/theme/utils/applyTheme.spec.ts b/packages/client/src/theme/utils/applyTheme.spec.ts index 93258afeb3..d5b139cecb 100644 --- a/packages/client/src/theme/utils/applyTheme.spec.ts +++ b/packages/client/src/theme/utils/applyTheme.spec.ts @@ -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'); + }); }); diff --git a/packages/client/src/theme/utils/applyTheme.ts b/packages/client/src/theme/utils/applyTheme.ts index 30666e133a..d1e73945f9 100644 --- a/packages/client/src/theme/utils/applyTheme.ts +++ b/packages/client/src/theme/utils/applyTheme.ts @@ -31,6 +31,7 @@ function mapTheme(rgb: IThemeRGB): Partial { '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 { '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', };