🎨 feat: Add Versioned Theme Foundation (#14709)

* 🎨 feat: Add Versioned Theme Foundation

* 🧩 fix: Keep Theme-Aware Chip Actions Consistent

* 🎛️ fix: Preserve Default Theme Geometry

* 🪪 fix: Keep Theme Identity in Sync

* 🧭 docs: Define Theme Styling Policy

* 🧹 chore: Sort Theme Imports

* 🧵 fix: Preserve Theme Compatibility Contracts

* 🛡️ fix: Harden Theme Compatibility Boundaries

* 🧵 fix: Publish Theme Appearance Preset

* 🐳 fix: Include Theme Preset in Docker Build

* 🪢 fix: Preserve Legacy Theme Compatibility

* 🧭 fix: Harden Theme Lifecycle Boundaries

* 🧱 fix: Align Theme Appearance Defaults

* 🧬 fix: Record Persisted Theme Provenance

* 🧭 fix: Preserve Theme Transition State

* 🧷 fix: Preserve Legacy Theme Contracts
This commit is contained in:
Danny Avila 2026-08-10 13:41:03 -04:00 committed by GitHub
parent 7fc62023eb
commit c3a429ddcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1980 additions and 384 deletions

View file

@ -113,7 +113,7 @@ jobs:
uses: actions/cache@v4
with:
path: client/dist
key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tailwind.preset.cjs', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build client app
if: steps.cache-client-app.outputs.cache-hit != 'true'

View file

@ -129,7 +129,7 @@ jobs:
uses: actions/cache@v5
with:
path: client/dist
key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tailwind.preset.cjs', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build client app
if: steps.cache-client-app.outputs.cache-hit != 'true'

View file

@ -1,3 +1,13 @@
See CLAUDE.md.
## Frontend theming and styling
For frontend work, compose existing `@librechat/client` primitives and variants before adding
feature-local styles. Use semantic theme/Tailwind roles for color and shared appearance; do not
introduce raw palette utilities, hard-coded colors, or arbitrary theme CSS. If the system cannot
express a reusable design need, deepen the shared primitive or versioned theme-token registry
instead of copying classes into a feature. Keep genuine layout and behavior local, and document
why any new custom CSS cannot be expressed by the shared system. See the detailed policy in
`CLAUDE.md` under “Theming and styling.”
When adding or changing code that mutates user documents, invalidate the auth user document cache for affected users. This includes single-user updates and bulk role/user mutations; otherwise OpenID JWT request burst caching can serve a stale `req.user` until its TTL expires.

View file

@ -121,6 +121,33 @@ Multi-line imports count total character length across all lines. Consolidate va
- Group related components in feature directories (e.g., `SidePanel/Memories/`).
- Use index files for clean exports.
### Theming and styling
- **Compose before styling.** Search `@librechat/client` for an existing primitive, semantic
variant, or composition before adding feature-local classes or CSS.
- **Use semantic roles.** Colors and shared appearance values must come from the semantic
Tailwind/theme roles. Do not add raw palette utilities, hard-coded hex/RGB/HSL colors, or
light/dark-specific values in feature components.
- **Deepen the system when the need is reusable.** Add a focused variant to a shared primitive or
extend the canonical, versioned theme-token registry when multiple screens should share the
same design decision. Do not create shallow local wrappers that merely relocate class strings.
- **Themes are data, not arbitrary CSS.** Theme definitions may select semantic colors and shared
appearance roles. They must not contain selectors, arbitrary CSS, application behavior, or
alternate feature layouts. Preserve existing environment and stored-theme compatibility when
changing the theme engine.
- **Keep layout and behavior local.** Feature structure, responsive layout, state-driven
transitions, and specialized visualization may remain feature-owned. Expose a theme role only
when it represents a stable, reusable appearance decision; do not turn every measurement into a
global token.
- **Treat custom CSS as an exception.** Use it only when shared primitives and semantic utilities
cannot express the requirement. Keep it narrowly scoped, consume theme variables where
applicable, support light/dark and reduced motion, and add a brief code or PR explanation of why
the exception is necessary.
- **Preserve defaults and prove variability.** New theme-aware variants must reproduce the current
default appearance unless a redesign is explicitly requested. Test semantic-token use and, when
extending theme capabilities, include a deliberately different reference theme to prove that
components adapt without feature-specific overrides.
### Data Management
- Feature hooks: `client/src/data-provider/[Feature]/queries.ts``[Feature]/index.ts``client/src/data-provider/index.ts`.

View file

@ -1,3 +1,4 @@
# Domain language
- **Agent run envelope**: the versioned, JSON-safe request contract created after ingress authentication and protocol validation but before agent, provider, tool, or MCP initialization. It carries only the validated protocol payload and the minimum trusted principal identifiers. The execution host rehydrates all runtime state from those identifiers.
- **Theme definition**: a versioned, data-only description of LibreChat semantic colors and shared appearance roles, optionally specialized by light or dark mode. The theme module validates and resolves partial definitions against bundled defaults before adapters apply them. A theme definition does not contain arbitrary CSS, application behavior, or alternate feature layouts.

View file

@ -83,6 +83,7 @@ COPY client ./
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
COPY --from=client-package-build /app/packages/client/dist /app/packages/client/dist
COPY --from=client-package-build /app/packages/client/src /app/packages/client/src
COPY --from=client-package-build /app/packages/client/tailwind.preset.cjs /app/packages/client/tailwind.preset.cjs
ARG NODE_MAX_OLD_SPACE_SIZE
ENV NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}"
RUN npm run build

View file

@ -219,6 +219,17 @@ html {
--surface-fixed-hover: var(--gray-100);
--text-fixed: var(--gray-800);
--radius: 0.5rem;
--theme-control-radius: 0.75rem;
--theme-round-control-radius: 9999px;
--theme-surface-radius: 1rem;
--theme-large-surface-radius: 1.5rem;
--theme-control-height: 2.25rem;
--theme-space-compact: 0.375rem;
--theme-space-normal: 0.75rem;
--theme-font-family: Inter, sans-serif;
--theme-elevation-surface: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--theme-motion-fast: 150ms;
--theme-motion-normal: 200ms;
--switch-unchecked: 0 0% 58%;
}
.dark {

View file

@ -1,103 +1,20 @@
/**
* Theme tokens configurable at deploy time. Each entry is the `IThemeRGB` key
* without its `rgb-` prefix, and maps to `REACT_APP_THEME_<UPPER_SNAKE>`.
* Keep this list in sync with `IThemeRGB` so every applied CSS variable stays
* reachable from the environment.
*/
const THEME_TOKENS = [
'text-primary',
'text-secondary',
'text-secondary-alt',
'text-tertiary',
'text-warning',
'text-destructive',
import { themeColorTokens } from '@librechat/client';
'link',
'link-hover',
'link-visited',
'accent-primary',
'accent-primary-hover',
'ring-primary',
'header-primary',
'header-hover',
'header-button-hover',
'surface-active',
'surface-active-alt',
'surface-hover',
'surface-hover-alt',
'surface-primary',
'surface-primary-alt',
'surface-primary-contrast',
'surface-secondary',
'surface-secondary-alt',
'surface-tertiary',
'surface-tertiary-alt',
'surface-dialog',
'surface-overlay',
'surface-submit',
'surface-submit-hover',
'surface-destructive',
'surface-destructive-hover',
'surface-chat',
'surface-inverted',
'surface-inverted-hover',
'text-inverted',
'surface-fixed',
'surface-fixed-hover',
'text-fixed',
'border-light',
'border-medium',
'border-medium-alt',
'border-heavy',
'border-xheavy',
'border-destructive',
'status-success',
'status-success-subtle',
'status-success-border',
'status-success-strong',
'status-info',
'status-info-subtle',
'status-info-border',
'status-info-strong',
'status-warning',
'status-warning-subtle',
'status-warning-border',
'status-warning-strong',
'status-error',
'status-error-subtle',
'status-error-border',
'status-error-strong',
'status-neutral',
'status-neutral-subtle',
'status-neutral-border',
'text-on-status',
'brand-purple',
'presentation',
];
const toEnvName = (token) => `REACT_APP_THEME_${token.toUpperCase().replace(/-/g, '_')}`;
const toEnvName = (token) => `REACT_APP_THEME_${token.slice(4).toUpperCase().replace(/-/g, '_')}`;
/**
* Loads theme configuration from build-time environment variables. Values are
* inlined by Vite, so `REACT_APP_THEME_*` must be present when the client is
* built; changing them afterwards has no effect until the next build.
* Loads the canonical color-token registry from build-time environment variables.
* Values are inlined by Vite and continue to use the existing REACT_APP_THEME_* names.
* @param {Record<string, string | undefined>} [env] Environment source, defaults to the build-time env
* @returns {import('@librechat/client').IThemeRGB | undefined}
*/
export function getThemeFromEnv(env = import.meta.env) {
const theme = THEME_TOKENS.reduce((acc, token) => {
const theme = themeColorTokens.reduce((colors, token) => {
const value = env[toEnvName(token)];
if (value) {
acc[`rgb-${token}`] = value;
colors[token] = value;
}
return acc;
return colors;
}, {});
return Object.keys(theme).length > 0 ? theme : undefined;

View file

@ -2,6 +2,7 @@
const {
createTailwindColors,
} = require('../packages/client/src/theme/utils/createTailwindColors.js');
const libreChatTailwindPreset = require('../packages/client/tailwind.preset.cjs');
/** @type {import('tailwindcss').Config} */
module.exports = {
@ -12,6 +13,7 @@ module.exports = {
],
// darkMode: 'class',
darkMode: ['class'],
presets: [libreChatTailwindPreset],
theme: {
fontFamily: {
sans: ['Inter', 'sans-serif'],

View file

@ -20,11 +20,13 @@
"default": "./dist/index.cjs"
}
},
"./tailwind-preset": "./tailwind.preset.cjs",
"./style.css": "./dist/style.css",
"./package.json": "./package.json"
},
"files": [
"dist"
"dist",
"tailwind.preset.cjs"
],
"scripts": {
"clean": "rimraf dist",

View file

@ -0,0 +1,45 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { Button, buttonVariants } from './Button';
describe('Button', () => {
it('exposes theme-owned shape and density recipes', () => {
render(
<Button size="theme" shape="theme">
Continue
</Button>,
);
expect(screen.getByRole('button', { name: 'Continue' })).toHaveClass(
'h-theme-control',
'rounded-theme-control',
'gap-theme-compact',
);
});
it('preserves variant geometry until a shape is explicitly selected', () => {
const { rerender } = render(<Button variant="subtle">Subtle</Button>);
const button = screen.getByRole('button', { name: 'Subtle' });
expect(button).toHaveClass('rounded-xl');
expect(button).not.toHaveClass('rounded-lg');
rerender(
<Button variant="subtle" shape="theme">
Subtle
</Button>,
);
expect(button).toHaveClass('rounded-theme-control');
expect(button).not.toHaveClass('rounded-xl');
});
it('preserves subtle geometry through the exported variant helper', () => {
expect(buttonVariants({ variant: 'subtle' })).toContain('rounded-xl');
expect(buttonVariants({ variant: 'subtle', shape: null })).toContain('rounded-xl');
const themedSubtle = buttonVariants({ variant: 'subtle', shape: 'theme' });
expect(themedSubtle).toContain('rounded-theme-control');
expect(themedSubtle).not.toContain('rounded-xl');
});
});

View file

@ -4,25 +4,26 @@ import { ClassProp } from 'class-variance-authority/types';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/utils';
const buttonVariants: (
props?:
| ({
variant?:
| 'default'
| 'link'
| 'submit'
| 'outline'
| 'subtle'
| 'destructive'
| 'secondary'
| 'ghost'
| null
| undefined;
size?: 'default' | 'icon' | 'sm' | 'lg' | null | undefined;
} & ClassProp)
| undefined,
) => string = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-surface-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
type ButtonVariantOptions =
| ({
variant?:
| 'default'
| 'link'
| 'submit'
| 'outline'
| 'subtle'
| 'destructive'
| 'secondary'
| 'ghost'
| null
| undefined;
size?: 'default' | 'icon' | 'sm' | 'lg' | 'theme' | null | undefined;
shape?: 'default' | 'theme' | null | undefined;
} & ClassProp)
| undefined;
const buttonVariantRecipe = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-surface-primary transition-colors duration-theme-fast focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
@ -32,7 +33,7 @@ const buttonVariants: (
outline:
'text-text-primary border border-border-light bg-transparent hover:bg-surface-hover hover:text-text-primary',
subtle:
'rounded-xl border border-border-light bg-transparent text-text-primary hover:bg-surface-secondary focus-visible:ring-text-primary focus-visible:ring-offset-0',
'border border-border-light bg-transparent text-text-primary hover:bg-surface-secondary focus-visible:ring-text-primary focus-visible:ring-offset-0',
secondary: 'bg-surface-secondary text-text-primary hover:bg-surface-hover',
ghost: 'hover:bg-surface-hover hover:text-text-primary',
link: 'text-text-primary underline-offset-4 hover:underline',
@ -43,15 +44,34 @@ const buttonVariants: (
sm: 'h-9 rounded-lg px-3',
lg: 'h-11 rounded-lg px-8',
icon: 'size-10',
theme: 'h-theme-control gap-theme-compact px-theme-normal',
},
shape: {
default: 'rounded-lg',
theme: 'rounded-theme-control',
unset: '',
},
},
compoundVariants: [
{
variant: 'subtle',
shape: 'unset',
class: 'rounded-xl',
},
],
defaultVariants: {
variant: 'default',
size: 'default',
shape: 'unset',
},
},
);
const buttonVariants: (props?: ButtonVariantOptions) => string = (props) =>
buttonVariantRecipe(
props == null ? props : { ...props, shape: props.shape == null ? 'unset' : props.shape },
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
@ -61,12 +81,12 @@ export interface ButtonProps
const Button: React.ForwardRefExoticComponent<
ButtonProps & React.RefAttributes<HTMLButtonElement>
> = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, type = 'button', ...props }, ref) => {
({ className, variant, size, shape, asChild = false, type = 'button', ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
type={asChild ? undefined : type}
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ variant, size, shape, className }))}
ref={ref}
{...props}
/>

View file

@ -29,4 +29,21 @@ describe('Chip', () => {
expect(onRemove).toHaveBeenCalledTimes(1);
expect(onParentClick).not.toHaveBeenCalled();
});
it('exposes theme-owned shape and density recipes', () => {
render(
<Chip size="theme" shape="theme" onRemove={jest.fn()} removeLabel="Remove theme">
Themeable
</Chip>,
);
expect(screen.getByText('Themeable').parentElement).toHaveClass(
'h-theme-control',
'rounded-theme-control',
'gap-theme-compact',
);
expect(screen.getByRole('button', { name: 'Remove theme' })).toHaveClass(
'rounded-theme-control-round',
);
});
});

View file

@ -7,11 +7,12 @@ import { cn } from '~/utils';
type ChipVariantProps = {
tone?: 'neutral' | 'info' | 'success' | 'warning' | 'error' | null;
size?: 'sm' | 'md' | null;
size?: 'sm' | 'md' | 'theme' | null;
shape?: 'round' | 'theme' | null;
};
const chipVariants: (props?: ChipVariantProps & ClassProp) => string = cva(
'inline-flex max-w-full items-center gap-1 rounded-full border text-xs font-medium transition-colors',
'inline-flex max-w-full items-center gap-1 border text-xs font-medium transition-colors duration-theme-fast',
{
variants: {
tone: {
@ -24,11 +25,17 @@ const chipVariants: (props?: ChipVariantProps & ClassProp) => string = cva(
size: {
sm: 'min-h-6 px-2 py-0.5',
md: 'min-h-8 px-2.5 py-1',
theme: 'h-theme-control gap-theme-compact px-theme-normal',
},
shape: {
round: 'rounded-full',
theme: 'rounded-theme-control',
},
},
defaultVariants: {
tone: 'neutral',
size: 'sm',
shape: 'round',
},
},
);
@ -52,6 +59,7 @@ const Chip: React.ForwardRefExoticComponent<ChipProps & React.RefAttributes<HTML
leading,
onRemove,
removeLabel = 'Remove',
shape,
size,
tone,
trailing,
@ -59,7 +67,7 @@ const Chip: React.ForwardRefExoticComponent<ChipProps & React.RefAttributes<HTML
},
ref,
) => (
<span ref={ref} className={cn(chipVariants({ tone, size, className }))} {...props}>
<span ref={ref} className={cn(chipVariants({ tone, size, shape, className }))} {...props}>
{leading}
<span className="min-w-0 truncate">{children}</span>
{trailing}
@ -67,6 +75,7 @@ const Chip: React.ForwardRefExoticComponent<ChipProps & React.RefAttributes<HTML
<IconButton
label={removeLabel}
size="xs"
shape={shape === 'theme' ? 'theme' : 'round'}
className="-mr-1 text-current hover:bg-surface-hover/50"
onClick={(event) => {
event.stopPropagation();

View file

@ -19,4 +19,17 @@ describe('IconButton', () => {
const button = screen.getByRole('button', { name: 'Delete' });
expect(button).toHaveClass('bg-surface-destructive', 'size-8', 'custom-class');
});
it('exposes theme-owned shape and control sizing', () => {
render(
<IconButton label="Theme control" size="theme" shape="theme">
menu
</IconButton>,
);
expect(screen.getByRole('button', { name: 'Theme control' })).toHaveClass(
'size-theme-control',
'rounded-theme-control-round',
);
});
});

View file

@ -5,12 +5,12 @@ import { cn } from '~/utils';
type IconButtonVariantProps = {
variant?: 'default' | 'secondary' | 'ghost' | 'destructive' | null;
size?: 'xs' | 'sm' | 'md' | 'lg' | null;
shape?: 'round' | 'square' | null;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'theme' | null;
shape?: 'round' | 'square' | 'theme' | null;
};
const iconButtonVariants: (props?: IconButtonVariantProps & ClassProp) => string = cva(
'inline-flex shrink-0 items-center justify-center rounded-full text-text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary disabled:pointer-events-none disabled:opacity-50',
'inline-flex shrink-0 items-center justify-center text-text-primary transition-colors duration-theme-fast focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
@ -25,10 +25,12 @@ const iconButtonVariants: (props?: IconButtonVariantProps & ClassProp) => string
sm: 'size-8',
md: 'size-9',
lg: 'size-10',
theme: 'size-theme-control',
},
shape: {
round: 'rounded-full',
square: 'rounded-lg',
theme: 'rounded-theme-control-round',
},
},
defaultVariants: {

View file

@ -1,5 +1,36 @@
# Dynamic Theme System for @librechat/client
## Versioned theme definitions
New themes should use the versioned `ThemeDefinition` interface. Definitions are data-only, may
provide separate light and dark overrides, and resolve missing values against LibreChat's bundled
defaults before any CSS variables are applied.
```tsx
const compactTheme: ThemeDefinition = {
version: 1,
name: 'compact',
modes: {
light: {
appearance: {
controlRadius: '0.25rem',
roundControlRadius: '9999px',
surfaceRadius: '0.5rem',
largeSurfaceRadius: '0.75rem',
controlHeight: '2rem',
},
},
},
};
<ThemeProvider themeDefinition={compactTheme}>{children}</ThemeProvider>;
```
The initial appearance registry intentionally covers only shared control shape, surface shape,
control height, compact/normal spacing, UI typography, surface elevation, and fast/normal motion.
`themeRGB`, `REACT_APP_THEME_*`, and the existing localStorage keys remain supported through legacy
adapters. Theme application removes only variables owned by the theme module when a theme is reset.
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
@ -42,9 +73,10 @@ The theme system operates in three layers:
### Custom Theme Behavior
- Only applies when `themeRGB` prop is provided
- Prefer the versioned `themeDefinition` prop; the legacy `themeRGB` prop remains supported
- Overrides CSS variables with bare `R G B` channel triplets
- Maintains compatibility with existing CSS
- Resolves missing `themeDefinition` values against the bundled light/dark defaults
- Leaves colors omitted by legacy `themeRGB` unset so consumer CSS continues to cascade
## Basic Usage
@ -109,7 +141,10 @@ itself: `color: rgb(var(--text-primary));`.
Update your `tailwind.config.js`:
```js
const libreChatTailwindPreset = require('@librechat/client/tailwind-preset');
module.exports = {
presets: [libreChatTailwindPreset],
content: [
'./src/**/*.{js,jsx,ts,tsx}',
// Include component library files
@ -130,6 +165,10 @@ module.exports = {
};
```
The published preset supplies the semantic appearance utilities used by theme-aware component
variants, including `h-theme-control`, `rounded-theme-control`, `gap-theme-compact`, and
`duration-theme-fast`. Keep the preset enabled even when defining additional project utilities.
### 5. Use Theme Colors in Components
```tsx
@ -389,7 +428,7 @@ This format allows Tailwind to apply opacity modifiers like `bg-surface-primary/
#### 1. Colors Not Applying
- **Issue**: Custom theme colors aren't showing
- **Solution**: Ensure you're passing the `themeRGB` prop to ThemeProvider
- **Solution**: Pass a valid `themeDefinition`, or use the legacy `themeRGB` prop for color-only overrides
- **Check**: CSS variables in DevTools should show a bare `R G B` triplet
#### 2. Circular Reference Errors
@ -502,7 +541,9 @@ function App() {
}
```
**Important**: Props passed to ThemeProvider will override stored values on initial mount. Only pass props when you explicitly want to override the user's saved preferences.
**Important**: The `themeDefinition`, `themeRGB`, and `themeName` props override stored values and
remain synchronized when they change. Only pass theme props when the parent should control those
values; otherwise use the context setters and allow stored preferences to remain authoritative.
## Contributing

View file

@ -0,0 +1,658 @@
import '@testing-library/jest-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import { ThemeProvider, useTheme } from './ThemeProvider';
const matchMedia = (matches: boolean): MediaQueryList =>
({
matches,
media: '(prefers-color-scheme: dark)',
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
}) as MediaQueryList;
function Controls() {
const { resetTheme, setTheme, setThemeDefinition, setThemeName, setThemeRGB, themeName } =
useTheme();
return (
<>
<output>{themeName}</output>
<button onClick={() => setTheme('dark')}>Dark</button>
<button onClick={() => setThemeName('renamed')}>Rename</button>
<button onClick={() => setThemeName(undefined)}>Clear name</button>
<button onClick={() => setThemeRGB({ 'rgb-accent-primary': '4 5 6' })}>Set legacy</button>
<button
onClick={() => {
setThemeRGB({ 'rgb-accent-primary': '7 8 9' });
setThemeName('batched');
}}
>
Batch legacy
</button>
<button
onClick={() => {
setThemeName('name-first');
setThemeRGB({ 'rgb-accent-primary': '10 11 12' });
}}
>
Batch name first
</button>
<button onClick={() => setThemeDefinition(undefined)}>Clear definition</button>
<button onClick={resetTheme}>Reset</button>
</>
);
}
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('class');
document.documentElement.removeAttribute('style');
document.documentElement.removeAttribute('data-theme');
window.matchMedia = jest.fn(() => matchMedia(false));
});
describe('ThemeProvider', () => {
it('preserves CSS fallbacks for colors omitted by legacy RGB props', async () => {
document.documentElement.style.setProperty('--text-primary', '9 9 9');
render(
<ThemeProvider
initialTheme="light"
themeName="legacy"
themeRGB={{ 'rgb-accent-primary': '1 2 3' }}
>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('legacy');
});
expect(screen.getByText('legacy')).toBeInTheDocument();
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
expect(document.documentElement.style.getPropertyValue('--text-primary')).toBe('9 9 9');
act(() => screen.getByRole('button', { name: 'Dark' }).click());
await waitFor(() => {
expect(document.documentElement).toHaveClass('dark');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
expect(document.documentElement.style.getPropertyValue('--text-primary')).toBe('9 9 9');
});
it('keeps theme definition identity authoritative over legacy naming', async () => {
render(
<ThemeProvider
initialTheme="light"
themeName="legacy-name"
themeDefinition={{
version: 1,
name: 'definition-name',
modes: { light: { colors: { 'rgb-accent-primary': '1 2 3' } } },
}}
>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('definition-name');
});
expect(screen.getByText('definition-name')).toBeInTheDocument();
expect(localStorage.getItem('theme-name')).toBe('definition-name');
expect(localStorage.getItem('theme-source')).toBe('definition');
});
it('keeps valid legacy overrides when another token is malformed', async () => {
render(
<ThemeProvider
initialTheme="light"
themeRGB={{
'rgb-accent-primary': '1 2 3',
'rgb-text-primary': 'invalid',
}}
>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
});
expect(document.documentElement.style.getPropertyValue('--text-primary')).toBe('');
expect(JSON.parse(localStorage.getItem('theme-colors') ?? '{}')).toEqual({
'rgb-accent-primary': '1 2 3',
});
expect(localStorage.getItem('theme-source')).toBe('legacy');
});
it('resets only theme-owned properties', async () => {
document.documentElement.style.setProperty('--text-primary', '9 9 9', 'important');
document.documentElement.style.setProperty('--markdown-font-size', '18px');
document.documentElement.dataset.theme = 'host';
render(
<ThemeProvider initialTheme="light" themeRGB={{ 'rgb-accent-primary': '1 2 3' }}>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
});
act(() => screen.getByRole('button', { name: 'Reset' }).click());
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('');
});
expect(document.documentElement.style.getPropertyValue('--text-primary')).toBe('9 9 9');
expect(document.documentElement.dataset.theme).toBe('host');
expect(document.documentElement.style.getPropertyValue('--markdown-font-size')).toBe('18px');
expect(localStorage.getItem('theme-definition')).toBeNull();
expect(localStorage.getItem('theme-name')).toBeNull();
expect(localStorage.getItem('theme-source')).toBeNull();
});
it('does not remove host theme variables when no custom theme is active', async () => {
document.documentElement.style.setProperty('--text-primary', '9 9 9');
document.documentElement.dataset.theme = 'host';
const { unmount } = render(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement).toHaveClass('light');
});
expect(document.documentElement.style.getPropertyValue('--text-primary')).toBe('9 9 9');
expect(document.documentElement.dataset.theme).toBe('host');
unmount();
expect(document.documentElement.style.getPropertyValue('--text-primary')).toBe('9 9 9');
expect(document.documentElement.dataset.theme).toBe('host');
expect(document.documentElement).not.toHaveClass('light');
});
it('restores host theme values and classes when the provider unmounts', async () => {
document.documentElement.style.setProperty('--accent-primary', '9 9 9');
document.documentElement.dataset.theme = 'host';
document.documentElement.classList.add('dark');
const { unmount } = render(
<ThemeProvider
initialTheme="light"
themeDefinition={{
version: 1,
name: 'embedded',
modes: { light: { colors: { 'rgb-accent-primary': '1 2 3' } } },
}}
>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('embedded');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
expect(document.documentElement).toHaveClass('light');
expect(document.documentElement).not.toHaveClass('dark');
unmount();
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('9 9 9');
expect(document.documentElement.dataset.theme).toBe('host');
expect(document.documentElement).toHaveClass('dark');
expect(document.documentElement).not.toHaveClass('light');
});
it('persists legacy prop overrides for later mounts', async () => {
const { unmount } = render(
<ThemeProvider
initialTheme="light"
themeName="legacy"
themeRGB={{ 'rgb-accent-primary': '1 2 3' }}
>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toMatchObject({
name: 'legacy',
});
});
expect(JSON.parse(localStorage.getItem('theme-colors') ?? '{}')).toEqual({
'rgb-accent-primary': '1 2 3',
});
expect(localStorage.getItem('color-theme')).toBe('light');
expect(localStorage.getItem('theme-source')).toBe('legacy');
unmount();
document.documentElement.removeAttribute('style');
document.documentElement.removeAttribute('data-theme');
render(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('legacy');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
});
it('does not let stale legacy storage downgrade an authoritative definition', async () => {
localStorage.setItem(
'theme-definition',
JSON.stringify({
version: 1,
name: 'native',
modes: { light: { colors: { 'rgb-accent-primary': '1 2 3' } } },
}),
);
localStorage.setItem('theme-colors', JSON.stringify({ 'rgb-accent-primary': '7 8 9' }));
render(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('native');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
expect(document.documentElement.style.getPropertyValue('--text-primary')).not.toBe('');
});
it('applies a name-only prop over a stored definition', async () => {
localStorage.setItem(
'theme-definition',
JSON.stringify({
version: 1,
name: 'stored',
modes: { light: { colors: { 'rgb-accent-primary': '1 2 3' } } },
}),
);
render(
<ThemeProvider initialTheme="light" themeName="brand">
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('brand');
});
expect(screen.getByText('brand')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toMatchObject({
name: 'brand',
});
expect(localStorage.getItem('theme-name')).toBe('brand');
expect(localStorage.getItem('theme-source')).toBe('definition');
});
it('keeps a name-only prop over migrated storage partial after remount', async () => {
document.documentElement.style.setProperty('--text-primary', '9 9 9');
localStorage.setItem('theme-colors', JSON.stringify({ 'rgb-accent-primary': '1 2 3' }));
const { unmount } = render(
<ThemeProvider initialTheme="light" themeName="brand">
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('brand');
});
expect(localStorage.getItem('theme-source')).toBe('legacy');
unmount();
render(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('brand');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
expect(document.documentElement.style.getPropertyValue('--text-primary')).toBe('9 9 9');
});
it('preserves legacy provenance when a migrated stored theme is renamed', async () => {
document.documentElement.style.setProperty('--text-primary', '9 9 9');
localStorage.setItem('theme-colors', JSON.stringify({ 'rgb-accent-primary': '1 2 3' }));
const { unmount } = render(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
act(() => screen.getByRole('button', { name: 'Rename' }).click());
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('renamed');
});
expect(localStorage.getItem('theme-source')).toBe('legacy');
unmount();
render(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('renamed');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
expect(document.documentElement.style.getPropertyValue('--text-primary')).toBe('9 9 9');
});
it('keeps the active definition name synchronized across reloads', async () => {
render(
<ThemeProvider
initialTheme="light"
themeName="legacy"
themeRGB={{ 'rgb-accent-primary': '1 2 3' }}
>
<Controls />
</ThemeProvider>,
);
act(() => screen.getByRole('button', { name: 'Rename' }).click());
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('renamed');
});
expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toMatchObject({
name: 'renamed',
});
expect(localStorage.getItem('theme-name')).toBe('renamed');
});
it('synchronizes legacy theme props after mount and restores the host when removed', async () => {
document.documentElement.style.setProperty('--accent-primary', '9 9 9');
const firstColors = { 'rgb-accent-primary': '1 2 3' };
const secondColors = { 'rgb-accent-primary': '4 5 6' };
const { rerender } = render(
<ThemeProvider initialTheme="light" themeName="first" themeRGB={firstColors}>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('first');
});
rerender(
<ThemeProvider initialTheme="dark" themeName="second" themeRGB={secondColors}>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('second');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('4 5 6');
expect(document.documentElement).toHaveClass('dark');
expect(JSON.parse(localStorage.getItem('theme-colors') ?? '{}')).toEqual(secondColors);
rerender(
<ThemeProvider initialTheme="dark">
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('9 9 9');
});
expect(document.documentElement).not.toHaveAttribute('data-theme');
expect(localStorage.getItem('theme-definition')).toBeNull();
});
it('retains a controlled theme name when only controlled colors are removed', async () => {
const colors = { 'rgb-accent-primary': '1 2 3' };
const { rerender } = render(
<ThemeProvider initialTheme="light" themeName="brand" themeRGB={colors}>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('brand');
});
rerender(
<ThemeProvider initialTheme="light" themeName="brand">
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(screen.getByText('brand')).toBeInTheDocument();
});
expect(document.documentElement).not.toHaveAttribute('data-theme');
expect(localStorage.getItem('theme-definition')).toBeNull();
expect(localStorage.getItem('theme-name')).toBe('brand');
});
it('reapplies an unchanged legacy name when switching away from a definition', async () => {
const definition = {
version: 1 as const,
name: 'native',
modes: { light: { colors: { 'rgb-accent-primary': '1 2 3' } } },
};
const legacyColors = { 'rgb-accent-primary': '4 5 6' };
const { rerender } = render(
<ThemeProvider initialTheme="light" themeName="brand" themeDefinition={definition}>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('native');
});
rerender(
<ThemeProvider initialTheme="light" themeName="brand" themeRGB={legacyColors}>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('brand');
});
expect(screen.getByText('brand')).toBeInTheDocument();
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('4 5 6');
expect(localStorage.getItem('theme-name')).toBe('brand');
expect(localStorage.getItem('theme-source')).toBe('legacy');
});
it('synchronizes valid definition props while ignoring invalid replacements', async () => {
const { rerender } = render(
<ThemeProvider
initialTheme="light"
themeDefinition={{
version: 1,
name: 'first',
modes: { light: { colors: { 'rgb-accent-primary': '1 2 3' } } },
}}
>
<Controls />
</ThemeProvider>,
);
rerender(
<ThemeProvider
initialTheme="light"
themeDefinition={{
version: 1,
name: 'second',
modes: { light: { colors: { 'rgb-accent-primary': '4 5 6' } } },
}}
>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('second');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('4 5 6');
rerender(
<ThemeProvider
initialTheme="light"
themeDefinition={{ version: 1, name: 'invalid', modes: { light: [] as never } }}
>
<Controls />
</ThemeProvider>,
);
expect(document.documentElement.dataset.theme).toBe('second');
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('4 5 6');
expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toMatchObject({
name: 'second',
});
});
it('coordinates batched legacy color and name updates', async () => {
render(
<ThemeProvider
initialTheme="light"
themeName="legacy"
themeRGB={{ 'rgb-accent-primary': '1 2 3' }}
>
<Controls />
</ThemeProvider>,
);
act(() => screen.getByRole('button', { name: 'Batch legacy' }).click());
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('batched');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('7 8 9');
expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toMatchObject({
name: 'batched',
modes: {
light: { colors: { 'rgb-accent-primary': '7 8 9' } },
},
});
expect(JSON.parse(localStorage.getItem('theme-colors') ?? '{}')).toEqual({
'rgb-accent-primary': '7 8 9',
});
});
it('coordinates batched legacy name and color updates when no definition is active', async () => {
render(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
act(() => screen.getByRole('button', { name: 'Batch name first' }).click());
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('name-first');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('10 11 12');
expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toMatchObject({
name: 'name-first',
modes: {
light: { colors: { 'rgb-accent-primary': '10 11 12' } },
},
});
});
it('keeps a valid stored theme when an invalid prop definition is added or removed', async () => {
const storedDefinition = {
version: 1 as const,
name: 'stored',
modes: { light: { colors: { 'rgb-accent-primary': '9 8 7' } } },
};
localStorage.setItem('theme-definition', JSON.stringify(storedDefinition));
localStorage.setItem('theme-name', 'stored');
const { rerender } = render(
<ThemeProvider
initialTheme="light"
themeDefinition={{
version: 1,
name: 'invalid',
modes: { light: { colors: { 'rgb-accent-primary': 'not-rgb' } } },
}}
>
<Controls />
</ThemeProvider>,
);
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('stored');
});
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('9 8 7');
expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toEqual(storedDefinition);
expect(localStorage.getItem('theme-name')).toBe('stored');
rerender(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
expect(document.documentElement.dataset.theme).toBe('stored');
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('9 8 7');
expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toEqual(storedDefinition);
});
it('uses a stable identity when a legacy consumer clears an active theme name', async () => {
render(
<ThemeProvider
initialTheme="light"
themeName="legacy"
themeRGB={{ 'rgb-accent-primary': '1 2 3' }}
>
<Controls />
</ThemeProvider>,
);
act(() => screen.getByRole('button', { name: 'Clear name' }).click());
await waitFor(() => {
expect(document.documentElement.dataset.theme).toBe('custom');
});
expect(screen.getByText('custom')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toMatchObject({
name: 'custom',
});
expect(localStorage.getItem('theme-name')).toBe('custom');
expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
});
it('removes legacy colors when the authoritative definition is cleared', async () => {
render(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
act(() => screen.getByRole('button', { name: 'Set legacy' }).click());
await waitFor(() => {
expect(localStorage.getItem('theme-colors')).not.toBeNull();
});
act(() => screen.getByRole('button', { name: 'Clear definition' }).click());
expect(localStorage.getItem('theme-definition')).toBeNull();
expect(localStorage.getItem('theme-colors')).toBeNull();
expect(localStorage.getItem('theme-source')).toBeNull();
});
});

View file

@ -1,35 +1,63 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useCallback,
useState,
useRef,
useState,
} from 'react';
import { JSX } from 'react/jsx-runtime';
import applyTheme from '../utils/applyTheme';
import { IThemeRGB } from '../types';
import type { IThemeRGB, ThemeDefinition, ThemeMode } from '../types';
import applyTheme, { applyResolvedTheme, themeOwnedProperties } from '../utils/applyTheme';
import { fromLegacyTheme, resolveTheme, validateThemeDefinition } from '../registry';
const THEME_KEY = 'color-theme';
const THEME_COLORS_KEY = 'theme-colors';
const THEME_NAME_KEY = 'theme-name';
const THEME_DEFINITION_KEY = 'theme-definition';
const THEME_SOURCE_KEY = 'theme-source';
const themeModes = ['light', 'dark', 'system'] as const;
type AppearanceMode = (typeof themeModes)[number];
type InitialThemeState = {
definition?: ThemeDefinition;
legacyColors?: IThemeRGB;
};
type ThemeDOMSnapshot = {
properties: Map<string, { value: string; priority: string }>;
dataTheme: string | null;
};
type ThemeClassSnapshot = {
dark: boolean;
light: boolean;
};
type ThemePropSnapshot = Pick<
ThemeProviderProps,
'initialTheme' | 'themeDefinition' | 'themeName' | 'themeRGB'
>;
type ThemeContextType = {
theme: string; // 'light' | 'dark' | 'system'
theme: AppearanceMode;
setTheme: (theme: string) => void;
themeRGB?: IThemeRGB;
setThemeRGB: (colors?: IThemeRGB) => void;
themeDefinition?: ThemeDefinition;
setThemeDefinition: (definition?: ThemeDefinition) => void;
themeName?: string;
setThemeName: (name?: string) => void;
resetTheme: () => void;
};
// Export ThemeContext so it can be imported from hooks
export const ThemeContext: React.Context<ThemeContextType> = createContext<ThemeContextType>({
theme: 'system',
setTheme: () => undefined,
setThemeRGB: () => undefined,
setThemeDefinition: () => undefined,
setThemeName: () => undefined,
resetTheme: () => undefined,
});
@ -37,13 +65,11 @@ export const ThemeContext: React.Context<ThemeContextType> = createContext<Theme
export interface ThemeProviderProps {
children: React.ReactNode;
themeRGB?: IThemeRGB;
themeDefinition?: ThemeDefinition;
themeName?: string;
initialTheme?: string;
}
/**
* Check if theme is dark
*/
export const isDark = (theme: string): boolean => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
@ -51,216 +77,504 @@ export const isDark = (theme: string): boolean => {
return theme === 'dark';
};
/**
* Validate that a parsed value looks like an IThemeRGB object
*/
const isAppearanceMode = (value: string): value is AppearanceMode =>
themeModes.includes(value as AppearanceMode);
const isValidThemeColors = (value: unknown): value is IThemeRGB => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
for (const key of Object.keys(value)) {
const val = (value as Record<string, unknown>)[key];
if (val !== undefined && typeof val !== 'string') {
try {
return validateThemeDefinition(fromLegacyTheme(value as IThemeRGB)).length === 0;
} catch {
return false;
}
};
const isValidThemeDefinition = (value: unknown): value is ThemeDefinition => {
try {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
}
return true;
};
/**
* Get initial theme from localStorage or default to 'system'
*/
const getInitialTheme = (): string => {
if (typeof window === 'undefined') return 'system';
try {
const stored = localStorage.getItem(THEME_KEY);
if (stored && ['light', 'dark', 'system'].includes(stored)) {
return stored;
const definition = value as ThemeDefinition;
if (
definition.version !== 1 ||
typeof definition.name !== 'string' ||
typeof definition.modes !== 'object' ||
definition.modes === null
) {
return false;
}
return validateThemeDefinition(definition).length === 0;
} catch {
// localStorage not available
return false;
}
return 'system';
};
/**
* Get initial theme colors from localStorage
*/
const getInitialThemeColors = (): IThemeRGB | undefined => {
if (typeof window === 'undefined') return undefined;
const readStorage = (key: string): string | null => {
if (typeof window === 'undefined') {
return null;
}
try {
const stored = localStorage.getItem(THEME_COLORS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return localStorage.getItem(key);
} catch {
return null;
}
};
const writeStorage = (key: string, value?: string): void => {
if (typeof window === 'undefined') {
return;
}
try {
if (value === undefined) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, value);
} catch {
// Storage is an optional persistence adapter.
}
};
const getInitialTheme = (): AppearanceMode => {
const stored = readStorage(THEME_KEY);
return stored && isAppearanceMode(stored) ? stored : 'system';
};
const getStoredThemeState = (): InitialThemeState => {
let legacyColors: IThemeRGB | undefined;
const storedSource = readStorage(THEME_SOURCE_KEY);
const storedColors = readStorage(THEME_COLORS_KEY);
if (storedColors) {
try {
const parsed: unknown = JSON.parse(storedColors);
if (isValidThemeColors(parsed)) {
return parsed;
legacyColors = fromLegacyTheme(parsed).modes.light?.colors;
}
} catch {
// Invalid legacy data is ignored.
}
} catch {
// localStorage not available or invalid JSON
}
return undefined;
const storedDefinition = readStorage(THEME_DEFINITION_KEY);
if (storedDefinition) {
try {
const parsed: unknown = JSON.parse(storedDefinition);
if (isValidThemeDefinition(parsed)) {
return {
definition: parsed,
legacyColors: storedSource === 'legacy' ? parsed.modes.light?.colors : undefined,
};
}
} catch {
// Fall through to the legacy storage adapter.
}
}
if (!legacyColors) {
return {};
}
return {
definition: fromLegacyTheme(legacyColors, readStorage(THEME_NAME_KEY) ?? 'custom'),
legacyColors,
};
};
/**
* Get initial theme name from localStorage
*/
const getInitialThemeName = (): string | undefined => {
if (typeof window === 'undefined') return undefined;
try {
return localStorage.getItem(THEME_NAME_KEY) || undefined;
} catch {
// localStorage not available
const getInitialThemeName = (): string | undefined => readStorage(THEME_NAME_KEY) ?? undefined;
const captureThemeDOM = (root: HTMLElement): ThemeDOMSnapshot => ({
properties: new Map(
themeOwnedProperties.map((property) => [
property,
{
value: root.style.getPropertyValue(property),
priority: root.style.getPropertyPriority(property),
},
]),
),
dataTheme: root.getAttribute('data-theme'),
});
const restoreThemeDOM = (snapshot: ThemeDOMSnapshot, root: HTMLElement): void => {
snapshot.properties.forEach(({ value, priority }, property) => {
if (!value) {
root.style.removeProperty(property);
return;
}
root.style.setProperty(property, value, priority);
});
if (snapshot.dataTheme === null) {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', snapshot.dataTheme);
}
return undefined;
};
/**
* ThemeProvider component that handles both dark/light mode switching
* and dynamic color themes via CSS variables with localStorage persistence
*/
export function ThemeProvider({
children,
themeRGB: propThemeRGB,
themeDefinition: propThemeDefinition,
themeName: propThemeName,
initialTheme,
}: ThemeProviderProps): JSX.Element {
const [theme, setThemeState] = useState<string>(getInitialTheme);
const [themeRGB, setThemeRGBState] = useState<IThemeRGB | undefined>(getInitialThemeColors);
const [themeName, setThemeNameState] = useState<string | undefined>(getInitialThemeName);
const initialThemeState = useRef<InitialThemeState | undefined>(undefined);
if (!initialThemeState.current) {
if (propThemeDefinition && isValidThemeDefinition(propThemeDefinition)) {
initialThemeState.current = { definition: propThemeDefinition };
} else if (!propThemeDefinition && propThemeRGB) {
const definition = fromLegacyTheme(propThemeRGB, propThemeName);
initialThemeState.current = {
definition,
legacyColors: definition.modes.light?.colors,
};
} else {
const storedThemeState = getStoredThemeState();
initialThemeState.current =
propThemeName !== undefined && storedThemeState.definition
? {
...storedThemeState,
definition: {
...storedThemeState.definition,
name: propThemeName.trim() || 'custom',
},
}
: storedThemeState;
}
}
// Track if props have been initialized
const initialized = useRef(false);
const [theme, setThemeState] = useState<AppearanceMode>(() =>
initialTheme && isAppearanceMode(initialTheme) ? initialTheme : getInitialTheme(),
);
const [themeDefinition, setThemeDefinitionState] = useState<ThemeDefinition | undefined>(
initialThemeState.current.definition,
);
const [legacyThemeRGB, setLegacyThemeRGB] = useState<IThemeRGB | undefined>(
initialThemeState.current.legacyColors,
);
const legacyThemeRGBRef = useRef(legacyThemeRGB);
legacyThemeRGBRef.current = legacyThemeRGB;
const themeDefinitionRef = useRef(themeDefinition);
themeDefinitionRef.current = themeDefinition;
const [themeName, setThemeNameState] = useState<string | undefined>(
themeDefinition?.name ?? propThemeName ?? getInitialThemeName,
);
const themeNameRef = useRef(themeName);
themeNameRef.current = themeName;
const persistedInitialProps = useRef(false);
const previousThemeProps = useRef<ThemePropSnapshot>({
initialTheme,
themeDefinition: propThemeDefinition,
themeName: propThemeName,
themeRGB: propThemeRGB,
});
const controlledThemeActive = useRef(
Boolean(
(propThemeDefinition && isValidThemeDefinition(propThemeDefinition)) ||
(!propThemeDefinition && propThemeRGB),
),
);
const synchronizedThemeProps = useRef(false);
const themeDOMSnapshot = useRef<ThemeDOMSnapshot | undefined>(undefined);
const themeClassSnapshot = useRef<ThemeClassSnapshot | undefined>(undefined);
const restoreAppliedTheme = useCallback((root = window.document.documentElement) => {
if (!themeDOMSnapshot.current) {
return;
}
restoreThemeDOM(themeDOMSnapshot.current, root);
themeDOMSnapshot.current = undefined;
}, []);
const prepareThemeDOM = useCallback((root: HTMLElement) => {
if (!themeDOMSnapshot.current) {
themeDOMSnapshot.current = captureThemeDOM(root);
return;
}
restoreThemeDOM(themeDOMSnapshot.current, root);
}, []);
useEffect(() => {
if (persistedInitialProps.current) {
return;
}
persistedInitialProps.current = true;
if (initialTheme && isAppearanceMode(initialTheme)) {
writeStorage(THEME_KEY, initialTheme);
}
const validPropDefinition =
propThemeDefinition && isValidThemeDefinition(propThemeDefinition)
? propThemeDefinition
: undefined;
if (propThemeDefinition && !validPropDefinition) {
return;
}
const legacyDefinition =
!propThemeDefinition && propThemeRGB
? fromLegacyTheme(propThemeRGB, propThemeName)
: undefined;
const definition = validPropDefinition ?? legacyDefinition;
if (!definition) {
if (propThemeName !== undefined && themeDefinition) {
writeStorage(THEME_DEFINITION_KEY, JSON.stringify(themeDefinition));
writeStorage(THEME_NAME_KEY, themeDefinition.name);
writeStorage(THEME_SOURCE_KEY, legacyThemeRGB ? 'legacy' : 'definition');
} else if (propThemeName && !themeDefinition) {
writeStorage(THEME_NAME_KEY, propThemeName);
}
return;
}
writeStorage(THEME_DEFINITION_KEY, JSON.stringify(definition));
writeStorage(THEME_NAME_KEY, definition.name);
writeStorage(THEME_SOURCE_KEY, legacyDefinition ? 'legacy' : 'definition');
writeStorage(
THEME_COLORS_KEY,
!propThemeDefinition && legacyDefinition
? JSON.stringify(legacyDefinition.modes.light?.colors ?? {})
: undefined,
);
}, [
initialTheme,
legacyThemeRGB,
propThemeDefinition,
propThemeName,
propThemeRGB,
themeDefinition,
]);
const setTheme = useCallback((newTheme: string) => {
setThemeState(newTheme);
if (typeof window === 'undefined') return;
try {
localStorage.setItem(THEME_KEY, newTheme);
} catch {
// localStorage not available
if (!isAppearanceMode(newTheme)) {
return;
}
setThemeState(newTheme);
writeStorage(THEME_KEY, newTheme);
}, []);
const setThemeDefinition = useCallback((definition?: ThemeDefinition) => {
const errors = definition ? validateThemeDefinition(definition) : [];
if (errors.length > 0) {
throw new TypeError(errors.join('\n'));
}
themeDefinitionRef.current = definition;
setThemeDefinitionState(definition);
legacyThemeRGBRef.current = undefined;
setLegacyThemeRGB(undefined);
writeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined);
writeStorage(THEME_COLORS_KEY);
writeStorage(THEME_SOURCE_KEY, definition ? 'definition' : undefined);
setThemeNameState(definition?.name);
themeNameRef.current = definition?.name;
writeStorage(THEME_NAME_KEY, definition?.name);
}, []);
const setThemeRGB = useCallback((colors?: IThemeRGB) => {
setThemeRGBState(colors);
if (typeof window === 'undefined') return;
try {
if (colors) {
localStorage.setItem(THEME_COLORS_KEY, JSON.stringify(colors));
} else {
localStorage.removeItem(THEME_COLORS_KEY);
}
} catch {
// localStorage not available
}
const definition = colors
? fromLegacyTheme(colors, themeDefinitionRef.current?.name ?? themeNameRef.current)
: undefined;
const legacyColors = definition?.modes.light?.colors;
themeDefinitionRef.current = definition;
setThemeDefinitionState(definition);
legacyThemeRGBRef.current = legacyColors;
setLegacyThemeRGB(legacyColors);
setThemeNameState(definition?.name);
themeNameRef.current = definition?.name;
writeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined);
writeStorage(THEME_NAME_KEY, definition?.name);
writeStorage(THEME_COLORS_KEY, legacyColors ? JSON.stringify(legacyColors) : undefined);
writeStorage(THEME_SOURCE_KEY, definition ? 'legacy' : undefined);
}, []);
const setThemeName = useCallback((name?: string) => {
setThemeNameState(name);
if (typeof window === 'undefined') return;
try {
if (name) {
localStorage.setItem(THEME_NAME_KEY, name);
} else {
localStorage.removeItem(THEME_NAME_KEY);
}
} catch {
// localStorage not available
const currentDefinition = themeDefinitionRef.current;
const nextName = name?.trim() || (currentDefinition ? 'custom' : undefined);
setThemeNameState(nextName);
themeNameRef.current = nextName;
writeStorage(THEME_NAME_KEY, nextName);
if (!nextName || !currentDefinition) {
return;
}
const renamedDefinition = { ...currentDefinition, name: nextName };
themeDefinitionRef.current = renamedDefinition;
setThemeDefinitionState(renamedDefinition);
writeStorage(THEME_DEFINITION_KEY, JSON.stringify(renamedDefinition));
writeStorage(THEME_SOURCE_KEY, legacyThemeRGBRef.current ? 'legacy' : 'definition');
}, []);
// Initialize from props only once on mount
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
if (!synchronizedThemeProps.current) {
synchronizedThemeProps.current = true;
return;
}
// Set initial theme if provided
if (initialTheme) {
const previous = previousThemeProps.current;
const definitionChanged = propThemeDefinition !== previous.themeDefinition;
const legacyColorsChanged = propThemeRGB !== previous.themeRGB;
const switchedToLegacyColors =
definitionChanged && !propThemeDefinition && propThemeRGB !== undefined;
let clearedControlledDefinition = false;
if (definitionChanged || legacyColorsChanged) {
if (propThemeDefinition) {
if (isValidThemeDefinition(propThemeDefinition)) {
setThemeDefinition(propThemeDefinition);
controlledThemeActive.current = true;
}
} else if (propThemeRGB) {
setThemeRGB(propThemeRGB);
controlledThemeActive.current = true;
} else if (controlledThemeActive.current) {
setThemeDefinition(undefined);
controlledThemeActive.current = false;
clearedControlledDefinition = true;
}
}
if (
!propThemeDefinition &&
(propThemeName !== previous.themeName ||
switchedToLegacyColors ||
clearedControlledDefinition)
) {
setThemeName(propThemeName);
}
if (initialTheme !== previous.initialTheme && initialTheme && isAppearanceMode(initialTheme)) {
setTheme(initialTheme);
}
// Set initial theme colors if provided
if (propThemeRGB) {
setThemeRGB(propThemeRGB);
}
previousThemeProps.current = {
initialTheme,
themeDefinition: propThemeDefinition,
themeName: propThemeName,
themeRGB: propThemeRGB,
};
}, [
initialTheme,
propThemeDefinition,
propThemeName,
propThemeRGB,
setTheme,
setThemeDefinition,
setThemeName,
setThemeRGB,
]);
// Set initial theme name if provided
if (propThemeName) {
setThemeName(propThemeName);
}
}, [initialTheme, propThemeRGB, propThemeName, setTheme, setThemeRGB, setThemeName]);
const applyThemeMode = useCallback(
(currentTheme: AppearanceMode) => {
const root = window.document.documentElement;
const mode: ThemeMode = isDark(currentTheme) ? 'dark' : 'light';
// Apply class-based dark mode
const applyThemeMode = useCallback((currentTheme: string) => {
const root = window.document.documentElement;
const darkMode = isDark(currentTheme);
if (!themeClassSnapshot.current) {
themeClassSnapshot.current = {
dark: root.classList.contains('dark'),
light: root.classList.contains('light'),
};
}
root.classList.remove(darkMode ? 'light' : 'dark');
root.classList.add(darkMode ? 'dark' : 'light');
}, []);
root.classList.toggle('dark', mode === 'dark');
root.classList.toggle('light', mode === 'light');
if (!themeDefinition) {
restoreAppliedTheme(root);
return;
}
prepareThemeDOM(root);
if (legacyThemeRGB) {
applyTheme(legacyThemeRGB, root);
root.dataset.theme = themeDefinition.name;
return;
}
try {
applyResolvedTheme(resolveTheme(themeDefinition, mode), root);
} catch (error) {
restoreAppliedTheme(root);
console.error('Unable to apply theme definition', error);
}
},
[legacyThemeRGB, prepareThemeDOM, restoreAppliedTheme, themeDefinition],
);
// Apply theme mode whenever theme changes
useEffect(() => {
applyThemeMode(theme);
}, [theme, applyThemeMode]);
}, [applyThemeMode, theme]);
// Listen for system theme changes when theme is 'system'
useEffect(() => {
if (theme !== 'system') return;
if (theme !== 'system') {
return;
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
applyThemeMode('system');
};
const handleChange = () => applyThemeMode('system');
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme, applyThemeMode]);
}, [applyThemeMode, theme]);
// Apply dynamic color theme
useEffect(() => {
if (themeRGB) {
applyTheme(themeRGB);
}
}, [themeRGB]);
useEffect(
() => () => {
const root = window.document.documentElement;
restoreAppliedTheme(root);
if (themeClassSnapshot.current) {
root.classList.toggle('dark', themeClassSnapshot.current.dark);
root.classList.toggle('light', themeClassSnapshot.current.light);
themeClassSnapshot.current = undefined;
}
},
[restoreAppliedTheme],
);
// Reset theme function
const resetTheme = useCallback(() => {
setTheme('system');
setThemeRGB(undefined);
setThemeName(undefined);
// Remove any custom CSS variables
const root = document.documentElement;
const customProps = Array.from(root.style).filter((prop) => prop.startsWith('--'));
customProps.forEach((prop) => root.style.removeProperty(prop));
}, [setTheme, setThemeRGB, setThemeName]);
setThemeDefinition(undefined);
writeStorage(THEME_COLORS_KEY);
restoreAppliedTheme();
}, [restoreAppliedTheme, setTheme, setThemeDefinition]);
const themeRGB = legacyThemeRGB ?? themeDefinition?.modes.light?.colors;
const value = useMemo(
() => ({
theme,
setTheme,
themeRGB,
setThemeRGB,
themeDefinition,
setThemeDefinition,
themeName,
setThemeName,
resetTheme,
}),
[theme, setTheme, themeRGB, setThemeRGB, themeName, setThemeName, resetTheme],
[
resetTheme,
setTheme,
setThemeDefinition,
setThemeName,
setThemeRGB,
theme,
themeDefinition,
themeName,
themeRGB,
],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
/**
* Hook to access the current theme context
*/
export function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
return useContext(ThemeContext);
}
export default ThemeProvider;

View file

@ -5,7 +5,23 @@ export * from './types';
export { ThemeProvider, ThemeContext, useTheme, isDark } from './context/ThemeProvider';
// Export utility functions
export { default as applyTheme } from './utils/applyTheme';
export {
default as applyTheme,
applyResolvedTheme,
clearAppliedTheme,
themeOwnedProperties,
} from './utils/applyTheme';
export {
THEME_VERSION,
defaultAppearance,
fromLegacyTheme,
libreChatTheme,
resolveTheme,
themeAppearanceProperties,
themeColorTokens,
validateThemeDefinition,
} from './registry';
// Export theme atoms for persistence
export { themeModeAtom, themeColorsAtom, themeNameAtom } from './atoms/themeAtoms';

View file

@ -0,0 +1,154 @@
import type { ThemeDefinition } from './types';
import {
defaultAppearance,
fromLegacyTheme,
resolveTheme,
themeColorTokens,
validateThemeDefinition,
} from './registry';
import { defaultTheme } from './themes/default';
import { darkTheme } from './themes/dark';
const compactTheme: ThemeDefinition = {
version: 1,
name: 'compact-reference',
modes: {
light: {
colors: { 'rgb-accent-primary': '1 2 3' },
appearance: {
controlRadius: '0.25rem',
roundControlRadius: '0.25rem',
surfaceRadius: '0.5rem',
largeSurfaceRadius: '0.5rem',
controlHeight: '2rem',
spaceCompact: '0.25rem',
spaceNormal: '0.5rem',
motionFast: '80ms',
motionNormal: '120ms',
},
},
},
};
describe('theme registry', () => {
it('keeps bundled light and dark themes complete against the canonical registry', () => {
expect(Object.keys(defaultTheme).sort()).toEqual([...themeColorTokens].sort());
expect(Object.keys(darkTheme).sort()).toEqual([...themeColorTokens].sort());
});
it('resolves partial definitions against mode-specific LibreChat defaults', () => {
const light = resolveTheme(compactTheme, 'light');
const dark = resolveTheme(compactTheme, 'dark');
expect(light.colors['rgb-accent-primary']).toBe('1 2 3');
expect(light.colors['rgb-text-primary']).toBe(defaultTheme['rgb-text-primary']);
expect(light.appearance.controlRadius).toBe('0.25rem');
expect(light.appearance.fontFamily).toBe(defaultAppearance.fontFamily);
expect(dark.colors['rgb-text-primary']).toBe(darkTheme['rgb-text-primary']);
expect(dark.appearance).toEqual(defaultAppearance);
});
it('reports invalid and unknown values before a definition reaches the DOM', () => {
const invalidTheme = {
version: 1,
name: 'invalid',
modes: {
light: {
colors: {
'rgb-text-primary': '999 0 0',
'rgb-unknown': '1 2 3',
},
appearance: {
controlRadius: 'url(theme.css)',
unknownSpacing: '1rem',
},
},
},
} as ThemeDefinition;
expect(validateThemeDefinition(invalidTheme)).toEqual([
'Invalid RGB value for rgb-text-primary: 999 0 0',
'Unknown color token: rgb-unknown',
'Invalid appearance value for controlRadius: url(theme.css)',
'Unknown appearance token: unknownSpacing',
]);
expect(() => resolveTheme(invalidTheme, 'light')).toThrow(TypeError);
});
it('sanitizes malformed legacy colors without weakening definition validation', () => {
const legacyTheme = fromLegacyTheme(
{
'rgb-accent-primary': '1 2 3',
'rgb-text-primary': 'invalid',
},
' ',
);
expect(legacyTheme.name).toBe('custom');
expect(legacyTheme.modes.light?.colors).toEqual({
'rgb-accent-primary': '1 2 3',
});
const invalidTheme = {
version: 1,
name: 'invalid',
modes: {
light: {
colors: { 'rgb-text-primary': null as never },
appearance: { fontFamily: 42 as never },
},
},
} as ThemeDefinition;
expect(validateThemeDefinition(invalidTheme)).toEqual([
'Invalid RGB value for rgb-text-primary: null',
'Invalid appearance value for fontFamily: 42',
]);
});
it.each([
[
'mode collection arrays',
{ version: 1, name: 'invalid', modes: [] },
'Theme modes must be an object',
],
[
'mode arrays',
{ version: 1, name: 'invalid', modes: { light: [] } },
'Theme mode light must be an object',
],
[
'null modes',
{ version: 1, name: 'invalid', modes: { light: null } },
'Theme mode light must be an object',
],
[
'color arrays',
{ version: 1, name: 'invalid', modes: { light: { colors: [] } } },
'Theme colors for light must be an object',
],
[
'appearance arrays',
{ version: 1, name: 'invalid', modes: { light: { appearance: [] } } },
'Theme appearance for light must be an object',
],
[
'unknown modes',
{ version: 1, name: 'invalid', modes: { sepia: {} } },
'Unknown theme mode: sepia',
],
[
'unknown top-level fields',
{ version: 1, name: 'invalid', modes: {}, css: ':root {}' },
'Unknown theme field: css',
],
[
'unknown mode fields',
{ version: 1, name: 'invalid', modes: { light: { appearence: {} } } },
'Unknown light theme field: appearence',
],
])('rejects malformed runtime %s', (_label, definition, expectedError) => {
expect(validateThemeDefinition(definition as ThemeDefinition)).toContain(expectedError);
expect(() => resolveTheme(definition as ThemeDefinition, 'light')).toThrow(TypeError);
});
});

View file

@ -0,0 +1,218 @@
import type {
IThemeAppearance,
IThemeRGB,
ResolvedThemeDefinition,
ThemeDefinition,
ThemeMode,
} from './types';
import { defaultTheme } from './themes/default';
import { darkTheme } from './themes/dark';
export const THEME_VERSION = 1 as const;
export const themeColorTokens: readonly (keyof IThemeRGB)[] = Object.freeze(
Object.keys(defaultTheme) as Array<keyof IThemeRGB>,
);
export const themeAppearanceProperties: Readonly<
Record<keyof IThemeAppearance, `--theme-${string}`>
> = Object.freeze({
controlRadius: '--theme-control-radius',
roundControlRadius: '--theme-round-control-radius',
surfaceRadius: '--theme-surface-radius',
largeSurfaceRadius: '--theme-large-surface-radius',
controlHeight: '--theme-control-height',
spaceCompact: '--theme-space-compact',
spaceNormal: '--theme-space-normal',
fontFamily: '--theme-font-family',
elevationSurface: '--theme-elevation-surface',
motionFast: '--theme-motion-fast',
motionNormal: '--theme-motion-normal',
});
export const defaultAppearance: IThemeAppearance = Object.freeze({
controlRadius: '0.75rem',
roundControlRadius: '9999px',
surfaceRadius: '1rem',
largeSurfaceRadius: '1.5rem',
controlHeight: '2.25rem',
spaceCompact: '0.375rem',
spaceNormal: '0.75rem',
fontFamily: 'Inter, sans-serif',
elevationSurface: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
motionFast: '150ms',
motionNormal: '200ms',
});
export const libreChatTheme: ThemeDefinition = Object.freeze({
version: THEME_VERSION,
name: 'librechat',
modes: {
light: { colors: defaultTheme },
dark: { colors: darkTheme },
},
});
const rgbPattern = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/;
const cssLengthPattern = /^(0|\d*\.?\d+(px|rem|em))$/;
const cssDurationPattern = /^\d*\.?\d+(ms|s)$/;
const isRGB = (value: unknown): value is string => {
if (typeof value !== 'string') {
return false;
}
const match = value.match(rgbPattern);
return match !== null && match.slice(1).every((channel) => Number(channel) <= 255);
};
const isLength = (value: unknown): value is string =>
typeof value === 'string' && cssLengthPattern.test(value);
const isDuration = (value: unknown): value is string =>
typeof value === 'string' && cssDurationPattern.test(value);
const isPlainRecord = (value: unknown): value is Record<string, unknown> => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
try {
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype.constructor?.name === 'Object';
} catch {
return false;
}
};
const appearanceValidators: Record<keyof IThemeAppearance, (value: unknown) => boolean> = {
controlRadius: isLength,
roundControlRadius: isLength,
surfaceRadius: isLength,
largeSurfaceRadius: isLength,
controlHeight: isLength,
spaceCompact: isLength,
spaceNormal: isLength,
fontFamily: (value) =>
typeof value === 'string' && value.trim().length > 0 && !/[;{}]/.test(value),
elevationSurface: (value) =>
typeof value === 'string' && value.trim().length > 0 && !/[;{}]|url\s*\(/i.test(value),
motionFast: isDuration,
motionNormal: isDuration,
};
export function validateThemeDefinition(theme: ThemeDefinition): string[] {
const errors: string[] = [];
if (!isPlainRecord(theme)) {
return ['Theme definition must be an object'];
}
Object.keys(theme).forEach((key) => {
if (key !== 'version' && key !== 'name' && key !== 'modes') {
errors.push(`Unknown theme field: ${key}`);
}
});
if (theme.version !== THEME_VERSION) {
errors.push(`Unsupported theme version: ${theme.version}`);
}
if (typeof theme.name !== 'string' || !theme.name.trim()) {
errors.push('Theme name is required');
}
if (!isPlainRecord(theme.modes)) {
errors.push('Theme modes must be an object');
return errors;
}
Object.keys(theme.modes).forEach((mode) => {
if (mode !== 'light' && mode !== 'dark') {
errors.push(`Unknown theme mode: ${mode}`);
}
});
(['light', 'dark'] as const).forEach((mode) => {
const definition = theme.modes[mode];
if (definition === undefined) {
return;
}
if (!isPlainRecord(definition)) {
errors.push(`Theme mode ${mode} must be an object`);
return;
}
Object.keys(definition).forEach((key) => {
if (key !== 'colors' && key !== 'appearance') {
errors.push(`Unknown ${mode} theme field: ${key}`);
}
});
if (definition.colors !== undefined && !isPlainRecord(definition.colors)) {
errors.push(`Theme colors for ${mode} must be an object`);
} else {
Object.entries(definition.colors ?? {}).forEach(([key, value]) => {
if (!themeColorTokens.includes(key as keyof IThemeRGB)) {
errors.push(`Unknown color token: ${key}`);
return;
}
if (value !== undefined && !isRGB(value)) {
errors.push(`Invalid RGB value for ${key}: ${value}`);
}
});
}
if (definition.appearance !== undefined && !isPlainRecord(definition.appearance)) {
errors.push(`Theme appearance for ${mode} must be an object`);
} else {
Object.entries(definition.appearance ?? {}).forEach(([key, value]) => {
const appearanceKey = key as keyof IThemeAppearance;
const validator = appearanceValidators[appearanceKey];
if (!validator) {
errors.push(`Unknown appearance token: ${key}`);
return;
}
if (value !== undefined && !validator(value)) {
errors.push(`Invalid appearance value for ${key}: ${value}`);
}
});
}
});
return errors;
}
export function resolveTheme(theme: ThemeDefinition, mode: ThemeMode): ResolvedThemeDefinition {
const errors = validateThemeDefinition(theme);
if (errors.length > 0) {
throw new TypeError(errors.join('\n'));
}
const baseColors = mode === 'dark' ? darkTheme : defaultTheme;
const definition = theme.modes[mode];
return {
version: THEME_VERSION,
name: theme.name,
mode,
colors: { ...baseColors, ...definition?.colors } as Required<IThemeRGB>,
appearance: { ...defaultAppearance, ...definition?.appearance },
};
}
export function fromLegacyTheme(colors: IThemeRGB, name = 'custom'): ThemeDefinition {
const legacyName = name.trim() || 'custom';
const sanitizedColors = themeColorTokens.reduce<IThemeRGB>((result, token) => {
const value = colors[token];
if (isRGB(value)) {
result[token] = value;
}
return result;
}, {});
return {
version: THEME_VERSION,
name: legacyName,
modes: {
light: { colors: sanitizedColors },
dark: { colors: sanitizedColors },
},
};
}

View file

@ -0,0 +1,68 @@
const fs = require('fs');
const path = require('path');
const resolveConfig = require('tailwindcss/resolveConfig');
const tailwindPreset = require('../../tailwind.preset.cjs');
const packageConfig = require('../../tailwind.config.js');
const packageJson = require('../../package.json');
const { defaultAppearance, themeAppearanceProperties } = require('./registry');
describe('LibreChat Tailwind preset', () => {
it('publishes the appearance roles without removing Tailwind defaults', () => {
const resolved = resolveConfig(packageConfig);
expect(packageConfig.presets).toContain(tailwindPreset);
expect(resolved.theme.fontFamily.sans).toBeDefined();
expect(resolved.theme.fontFamily['theme-ui']).toEqual([
`var(--theme-font-family, ${defaultAppearance.fontFamily})`,
]);
expect(resolved.theme.height['theme-control']).toBe(
`var(--theme-control-height, ${defaultAppearance.controlHeight})`,
);
expect(resolved.theme.spacing['theme-compact']).toBe(
`var(--theme-space-compact, ${defaultAppearance.spaceCompact})`,
);
expect(resolved.theme.spacing['theme-normal']).toBe(
`var(--theme-space-normal, ${defaultAppearance.spaceNormal})`,
);
expect(resolved.theme.spacing['theme-control']).toBe(
`var(--theme-control-height, ${defaultAppearance.controlHeight})`,
);
expect(resolved.theme.borderRadius['theme-control']).toBe(
`var(--theme-control-radius, ${defaultAppearance.controlRadius})`,
);
expect(resolved.theme.borderRadius['theme-control-round']).toBe(
`var(--theme-round-control-radius, ${defaultAppearance.roundControlRadius})`,
);
expect(resolved.theme.borderRadius['theme-surface']).toBe(
`var(--theme-surface-radius, ${defaultAppearance.surfaceRadius})`,
);
expect(resolved.theme.borderRadius['theme-surface-lg']).toBe(
`var(--theme-large-surface-radius, ${defaultAppearance.largeSurfaceRadius})`,
);
expect(resolved.theme.boxShadow['theme-surface']).toBe(
`var(--theme-elevation-surface, ${defaultAppearance.elevationSurface})`,
);
expect(resolved.theme.transitionDuration['theme-fast']).toBe(
`var(--theme-motion-fast, ${defaultAppearance.motionFast})`,
);
expect(resolved.theme.transitionDuration['theme-normal']).toBe(
`var(--theme-motion-normal, ${defaultAppearance.motionNormal})`,
);
});
it('exposes the preset in the published package', () => {
expect(packageJson.files).toContain('tailwind.preset.cjs');
expect(packageJson.exports['./tailwind-preset']).toBe('./tailwind.preset.cjs');
});
it('keeps application CSS defaults aligned with the appearance registry', () => {
const applicationStyles = fs.readFileSync(
path.resolve(__dirname, '../../../../client/src/style.css'),
'utf8',
);
Object.entries(themeAppearanceProperties).forEach(([key, property]) => {
expect(applicationStyles).toContain(`${property}: ${defaultAppearance[key]};`);
});
});
});

View file

@ -249,3 +249,39 @@ export interface Theme {
name: string;
colors: IThemeRGB;
}
export type ThemeMode = 'light' | 'dark';
export interface IThemeAppearance {
controlRadius: string;
roundControlRadius: string;
surfaceRadius: string;
largeSurfaceRadius: string;
controlHeight: string;
spaceCompact: string;
spaceNormal: string;
fontFamily: string;
elevationSurface: string;
motionFast: string;
motionNormal: string;
}
export interface ThemeModeDefinition {
colors?: IThemeRGB;
appearance?: Partial<IThemeAppearance>;
}
/** Versioned, data-only theme input. Missing values resolve against LibreChat defaults. */
export interface ThemeDefinition {
version: 1;
name: string;
modes: Partial<Record<ThemeMode, ThemeModeDefinition>>;
}
export interface ResolvedThemeDefinition {
version: 1;
name: string;
mode: ThemeMode;
colors: Required<IThemeRGB>;
appearance: IThemeAppearance;
}

View file

@ -1,5 +1,7 @@
import type { ThemeDefinition } from '../types';
import applyTheme, { applyResolvedTheme, clearAppliedTheme } from './applyTheme';
import { defaultTheme } from '../themes/default';
import applyTheme from './applyTheme';
import { resolveTheme } from '../registry';
const semanticProperties = [
'--link',
@ -21,6 +23,7 @@ const semanticProperties = [
afterEach(() => {
semanticProperties.forEach((property) => document.documentElement.style.removeProperty(property));
clearAppliedTheme();
});
describe('applyTheme', () => {
@ -77,4 +80,44 @@ describe('applyTheme', () => {
);
expect(document.documentElement.style.getPropertyValue('--surface-overlay')).toBe('89 89 89');
});
it('applies a resolved appearance atomically', () => {
const referenceTheme: ThemeDefinition = {
version: 1,
name: 'compact-reference',
modes: {
light: {
appearance: {
controlRadius: '0.25rem',
roundControlRadius: '0.25rem',
surfaceRadius: '0.5rem',
largeSurfaceRadius: '0.5rem',
motionFast: '80ms',
},
},
},
};
applyResolvedTheme(resolveTheme(referenceTheme, 'light'));
const root = document.documentElement;
expect(root.dataset.theme).toBe('compact-reference');
expect(root.style.getPropertyValue('--theme-control-radius')).toBe('0.25rem');
expect(root.style.getPropertyValue('--theme-surface-radius')).toBe('0.5rem');
expect(root.style.getPropertyValue('--theme-motion-fast')).toBe('80ms');
});
it('clears only properties owned by the theme module', () => {
const root = document.documentElement;
root.style.setProperty('--text-primary', '1 2 3');
root.style.setProperty('--theme-control-radius', '0.25rem');
root.style.setProperty('--markdown-font-size', '18px');
clearAppliedTheme(root);
expect(root.style.getPropertyValue('--text-primary')).toBe('');
expect(root.style.getPropertyValue('--theme-control-radius')).toBe('');
expect(root.style.getPropertyValue('--markdown-font-size')).toBe('18px');
root.style.removeProperty('--markdown-font-size');
});
});

View file

@ -1,132 +1,69 @@
import { IThemeRGB, IThemeVariables } from '../types';
import type { IThemeAppearance, IThemeRGB, ResolvedThemeDefinition } from '../types';
import { themeAppearanceProperties, themeColorTokens } from '../registry';
const colorProperty = (token: keyof IThemeRGB): `--${string}` => `--${token.slice(4)}`;
export const themeOwnedProperties: readonly string[] = Object.freeze([
...themeColorTokens.map(colorProperty),
...Object.values(themeAppearanceProperties),
]);
const rgbPattern = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/;
/**
* Validates RGB string format (e.g., "255 255 255")
*/
function validateRGB(rgb: string): boolean {
if (!rgb) return true;
const rgbRegex = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/;
const match = rgb.match(rgbRegex);
if (!match) return false;
// Check that each value is between 0-255
const [, r, g, b] = match;
return [r, g, b].every((val) => {
const num = parseInt(val, 10);
return num >= 0 && num <= 255;
});
const match = rgb.match(rgbPattern);
return match !== null && match.slice(1).every((channel) => Number(channel) <= 255);
}
/**
* Maps theme RGB values to CSS variables
*/
function mapTheme(rgb: IThemeRGB): Partial<IThemeVariables> {
const variables: Partial<IThemeVariables> = {};
// Map each RGB value to its corresponding CSS variable
const mappings: Record<keyof IThemeRGB, keyof IThemeVariables> = {
'rgb-text-primary': '--text-primary',
'rgb-text-secondary': '--text-secondary',
'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',
'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',
'rgb-header-button-hover': '--header-button-hover',
'rgb-surface-active': '--surface-active',
'rgb-surface-active-alt': '--surface-active-alt',
'rgb-surface-hover': '--surface-hover',
'rgb-surface-hover-alt': '--surface-hover-alt',
'rgb-surface-primary': '--surface-primary',
'rgb-surface-primary-alt': '--surface-primary-alt',
'rgb-surface-primary-contrast': '--surface-primary-contrast',
'rgb-surface-secondary': '--surface-secondary',
'rgb-surface-secondary-alt': '--surface-secondary-alt',
'rgb-surface-tertiary': '--surface-tertiary',
'rgb-surface-tertiary-alt': '--surface-tertiary-alt',
'rgb-surface-dialog': '--surface-dialog',
'rgb-surface-overlay': '--surface-overlay',
'rgb-surface-submit': '--surface-submit',
'rgb-surface-submit-hover': '--surface-submit-hover',
'rgb-surface-destructive': '--surface-destructive',
'rgb-surface-destructive-hover': '--surface-destructive-hover',
'rgb-surface-chat': '--surface-chat',
'rgb-surface-inverted': '--surface-inverted',
'rgb-surface-inverted-hover': '--surface-inverted-hover',
'rgb-text-inverted': '--text-inverted',
'rgb-surface-fixed': '--surface-fixed',
'rgb-surface-fixed-hover': '--surface-fixed-hover',
'rgb-text-fixed': '--text-fixed',
'rgb-border-light': '--border-light',
'rgb-border-medium': '--border-medium',
'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-success-strong': '--status-success-strong',
'rgb-status-info': '--status-info',
'rgb-status-info-subtle': '--status-info-subtle',
'rgb-status-info-border': '--status-info-border',
'rgb-status-info-strong': '--status-info-strong',
'rgb-status-warning': '--status-warning',
'rgb-status-warning-subtle': '--status-warning-subtle',
'rgb-status-warning-border': '--status-warning-border',
'rgb-status-warning-strong': '--status-warning-strong',
'rgb-status-error': '--status-error',
'rgb-status-error-subtle': '--status-error-subtle',
'rgb-status-error-border': '--status-error-border',
'rgb-status-error-strong': '--status-error-strong',
'rgb-status-neutral': '--status-neutral',
'rgb-status-neutral-subtle': '--status-neutral-subtle',
'rgb-status-neutral-border': '--status-neutral-border',
'rgb-text-on-status': '--text-on-status',
'rgb-brand-purple': '--brand-purple',
'rgb-presentation': '--presentation',
};
Object.entries(mappings).forEach(([rgbKey, cssVar]) => {
const value = rgb[rgbKey as keyof IThemeRGB];
if (value) {
variables[cssVar] = value;
function mapColors(colors: IThemeRGB): Array<[string, string]> {
return themeColorTokens.reduce<Array<[string, string]>>((variables, token) => {
const value = colors[token];
if (value !== undefined) {
variables.push([colorProperty(token), value]);
}
});
return variables;
}, []);
}
return variables;
function mapAppearance(appearance: IThemeAppearance): Array<[string, string]> {
return Object.entries(themeAppearanceProperties).map(([key, property]) => [
property,
appearance[key as keyof IThemeAppearance],
]);
}
export function clearAppliedTheme(root: HTMLElement = document.documentElement): void {
themeOwnedProperties.forEach((property) => root.style.removeProperty(property));
root.removeAttribute('data-theme');
}
export function applyResolvedTheme(
theme: ResolvedThemeDefinition,
root: HTMLElement = document.documentElement,
): void {
const variables = [...mapColors(theme.colors), ...mapAppearance(theme.appearance)];
variables.forEach(([property, value]) => root.style.setProperty(property, value));
root.dataset.theme = theme.name;
}
/**
* Applies theme to the document root
* Sets CSS variables as rgb() values for compatibility with existing CSS
* Backward-compatible adapter for the original partial RGB theme interface.
* New theme implementations should resolve a ThemeDefinition and use applyResolvedTheme.
*/
export default function applyTheme(themeRGB?: IThemeRGB): void {
if (!themeRGB) return;
export default function applyTheme(
themeRGB?: IThemeRGB,
root: HTMLElement = document.documentElement,
): void {
if (!themeRGB) {
return;
}
const themeObject = mapTheme(themeRGB);
const root = document.documentElement;
Object.entries(themeObject).forEach(([cssVar, value]) => {
if (!value) return;
const validation = validateRGB(value);
if (!validation) {
console.error(`Invalid RGB value for ${cssVar}: ${value}`);
mapColors(themeRGB).forEach(([property, value]) => {
if (!validateRGB(value)) {
console.error(`Invalid RGB value for ${property}: ${value}`);
return;
}
// Store the bare `R G B` channel triplet. The Tailwind color map wraps these
// as `rgb(var(--x) / <alpha-value>)`, so storing raw channels is what enables
// opacity modifiers and matches the style.css defaults.
root.style.setProperty(cssVar, value);
root.style.setProperty(property, value);
});
}

View file

@ -1,9 +1,11 @@
const { createTailwindColors } = require('./src/theme/utils/createTailwindColors.js');
const libreChatTailwindPreset = require('./tailwind.preset.cjs');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
darkMode: ['class'],
presets: [libreChatTailwindPreset],
theme: {
extend: {
colors: createTailwindColors(),

View file

@ -0,0 +1,32 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
theme: {
extend: {
fontFamily: {
'theme-ui': ['var(--theme-font-family, Inter, sans-serif)'],
},
height: {
'theme-control': 'var(--theme-control-height, 2.25rem)',
},
spacing: {
'theme-compact': 'var(--theme-space-compact, 0.375rem)',
'theme-normal': 'var(--theme-space-normal, 0.75rem)',
'theme-control': 'var(--theme-control-height, 2.25rem)',
},
borderRadius: {
'theme-control': 'var(--theme-control-radius, 0.75rem)',
'theme-control-round': 'var(--theme-round-control-radius, 9999px)',
'theme-surface': 'var(--theme-surface-radius, 1rem)',
'theme-surface-lg': 'var(--theme-large-surface-radius, 1.5rem)',
},
boxShadow: {
'theme-surface':
'var(--theme-elevation-surface, 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1))',
},
transitionDuration: {
'theme-fast': 'var(--theme-motion-fast, 150ms)',
'theme-normal': 'var(--theme-motion-normal, 200ms)',
},
},
},
};