From c3a429ddcdf6d164a1fc61b8eec9cb3bfd761d74 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 10 Aug 2026 13:41:03 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20feat:=20Add=20Versioned=20Theme?= =?UTF-8?q?=20Foundation=20(#14709)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🎨 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 --- .github/workflows/playwright-bombadil.yml | 2 +- .github/workflows/playwright-mock.yml | 2 +- AGENTS.md | 10 + CLAUDE.md | 27 + CONTEXT.md | 1 + Dockerfile.multi | 1 + client/src/style.css | 11 + client/src/utils/getThemeFromEnv.js | 97 +-- client/tailwind.config.cjs | 2 + packages/client/package.json | 4 +- .../client/src/components/Button.spec.tsx | 45 ++ packages/client/src/components/Button.tsx | 64 +- packages/client/src/components/Chip.spec.tsx | 17 + packages/client/src/components/Chip.tsx | 15 +- .../client/src/components/IconButton.spec.tsx | 13 + packages/client/src/components/IconButton.tsx | 8 +- packages/client/src/theme/README.md | 49 +- .../src/theme/context/ThemeProvider.spec.tsx | 658 ++++++++++++++++++ .../src/theme/context/ThemeProvider.tsx | 594 ++++++++++++---- packages/client/src/theme/index.ts | 18 +- packages/client/src/theme/registry.spec.ts | 154 ++++ packages/client/src/theme/registry.ts | 218 ++++++ packages/client/src/theme/tailwind.spec.js | 68 ++ packages/client/src/theme/types/index.ts | 36 + .../client/src/theme/utils/applyTheme.spec.ts | 45 +- packages/client/src/theme/utils/applyTheme.ts | 171 ++--- packages/client/tailwind.config.js | 2 + packages/client/tailwind.preset.cjs | 32 + 28 files changed, 1980 insertions(+), 384 deletions(-) create mode 100644 packages/client/src/components/Button.spec.tsx create mode 100644 packages/client/src/theme/context/ThemeProvider.spec.tsx create mode 100644 packages/client/src/theme/registry.spec.ts create mode 100644 packages/client/src/theme/registry.ts create mode 100644 packages/client/src/theme/tailwind.spec.js create mode 100644 packages/client/tailwind.preset.cjs diff --git a/.github/workflows/playwright-bombadil.yml b/.github/workflows/playwright-bombadil.yml index 040a8c4848..16df9583f5 100644 --- a/.github/workflows/playwright-bombadil.yml +++ b/.github/workflows/playwright-bombadil.yml @@ -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' diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml index bfe6653766..f02fc3b6de 100644 --- a/.github/workflows/playwright-mock.yml +++ b/.github/workflows/playwright-mock.yml @@ -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' diff --git a/AGENTS.md b/AGENTS.md index 48e86847e2..1cc4a69d52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 511d579866..20cda2ee2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/CONTEXT.md b/CONTEXT.md index 47127ccd61..59e4f83bb3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/Dockerfile.multi b/Dockerfile.multi index f2fe8de220..bfa7a22091 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -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 diff --git a/client/src/style.css b/client/src/style.css index c774fb27ec..58c217e67e 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -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 { diff --git a/client/src/utils/getThemeFromEnv.js b/client/src/utils/getThemeFromEnv.js index 5760bac946..5be2941f2b 100644 --- a/client/src/utils/getThemeFromEnv.js +++ b/client/src/utils/getThemeFromEnv.js @@ -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_`. - * 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} [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; diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index 7a19a0aeac..e8f326f8c6 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -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'], diff --git a/packages/client/package.json b/packages/client/package.json index e4265a4a26..13ec70bf6d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -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", diff --git a/packages/client/src/components/Button.spec.tsx b/packages/client/src/components/Button.spec.tsx new file mode 100644 index 0000000000..c1b4b8ce34 --- /dev/null +++ b/packages/client/src/components/Button.spec.tsx @@ -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( + , + ); + + 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(); + const button = screen.getByRole('button', { name: 'Subtle' }); + + expect(button).toHaveClass('rounded-xl'); + expect(button).not.toHaveClass('rounded-lg'); + + rerender( + , + ); + + 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'); + }); +}); diff --git a/packages/client/src/components/Button.tsx b/packages/client/src/components/Button.tsx index 3e33741497..a2f12caa9c 100644 --- a/packages/client/src/components/Button.tsx +++ b/packages/client/src/components/Button.tsx @@ -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, VariantProps { @@ -61,12 +81,12 @@ export interface ButtonProps const Button: React.ForwardRefExoticComponent< ButtonProps & React.RefAttributes > = React.forwardRef( - ({ 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 ( diff --git a/packages/client/src/components/Chip.spec.tsx b/packages/client/src/components/Chip.spec.tsx index c029c01273..a6dba04f36 100644 --- a/packages/client/src/components/Chip.spec.tsx +++ b/packages/client/src/components/Chip.spec.tsx @@ -29,4 +29,21 @@ describe('Chip', () => { expect(onRemove).toHaveBeenCalledTimes(1); expect(onParentClick).not.toHaveBeenCalled(); }); + + it('exposes theme-owned shape and density recipes', () => { + render( + + Themeable + , + ); + + 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', + ); + }); }); diff --git a/packages/client/src/components/Chip.tsx b/packages/client/src/components/Chip.tsx index dc83b7aa46..9fb17736ce 100644 --- a/packages/client/src/components/Chip.tsx +++ b/packages/client/src/components/Chip.tsx @@ -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 ( - + {leading} {children} {trailing} @@ -67,6 +75,7 @@ const Chip: React.ForwardRefExoticComponent { event.stopPropagation(); diff --git a/packages/client/src/components/IconButton.spec.tsx b/packages/client/src/components/IconButton.spec.tsx index a7b48a9fd5..866785a168 100644 --- a/packages/client/src/components/IconButton.spec.tsx +++ b/packages/client/src/components/IconButton.spec.tsx @@ -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( + + menu + , + ); + + expect(screen.getByRole('button', { name: 'Theme control' })).toHaveClass( + 'size-theme-control', + 'rounded-theme-control-round', + ); + }); }); diff --git a/packages/client/src/components/IconButton.tsx b/packages/client/src/components/IconButton.tsx index cb3ab83935..2f25485795 100644 --- a/packages/client/src/components/IconButton.tsx +++ b/packages/client/src/components/IconButton.tsx @@ -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: { diff --git a/packages/client/src/theme/README.md b/packages/client/src/theme/README.md index d9a2df3419..64f171e0d8 100644 --- a/packages/client/src/theme/README.md +++ b/packages/client/src/theme/README.md @@ -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', + }, + }, + }, +}; + +{children}; +``` + +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 diff --git a/packages/client/src/theme/context/ThemeProvider.spec.tsx b/packages/client/src/theme/context/ThemeProvider.spec.tsx new file mode 100644 index 0000000000..10b0660302 --- /dev/null +++ b/packages/client/src/theme/context/ThemeProvider.spec.tsx @@ -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 ( + <> + {themeName} + + + + + + + + + + ); +} + +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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe('brand'); + }); + expect(localStorage.getItem('theme-source')).toBe('legacy'); + + unmount(); + render( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe('first'); + }); + rerender( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe('brand'); + }); + rerender( + + + , + ); + + 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( + + + , + ); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe('native'); + }); + rerender( + + + , + ); + + 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( + + + , + ); + + rerender( + + + , + ); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe('second'); + }); + expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('4 5 6'); + + rerender( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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(); + }); +}); diff --git a/packages/client/src/theme/context/ThemeProvider.tsx b/packages/client/src/theme/context/ThemeProvider.tsx index 8872fd6210..1e6a748e70 100644 --- a/packages/client/src/theme/context/ThemeProvider.tsx +++ b/packages/client/src/theme/context/ThemeProvider.tsx @@ -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; + 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 = createContext({ theme: 'system', setTheme: () => undefined, setThemeRGB: () => undefined, + setThemeDefinition: () => undefined, setThemeName: () => undefined, resetTheme: () => undefined, }); @@ -37,13 +65,11 @@ export const ThemeContext: React.Context = createContext { 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)[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(getInitialTheme); - const [themeRGB, setThemeRGBState] = useState(getInitialThemeColors); - const [themeName, setThemeNameState] = useState(getInitialThemeName); + const initialThemeState = useRef(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(() => + initialTheme && isAppearanceMode(initialTheme) ? initialTheme : getInitialTheme(), + ); + const [themeDefinition, setThemeDefinitionState] = useState( + initialThemeState.current.definition, + ); + const [legacyThemeRGB, setLegacyThemeRGB] = useState( + initialThemeState.current.legacyColors, + ); + const legacyThemeRGBRef = useRef(legacyThemeRGB); + legacyThemeRGBRef.current = legacyThemeRGB; + const themeDefinitionRef = useRef(themeDefinition); + themeDefinitionRef.current = themeDefinition; + const [themeName, setThemeNameState] = useState( + themeDefinition?.name ?? propThemeName ?? getInitialThemeName, + ); + const themeNameRef = useRef(themeName); + themeNameRef.current = themeName; + const persistedInitialProps = useRef(false); + const previousThemeProps = useRef({ + initialTheme, + themeDefinition: propThemeDefinition, + themeName: propThemeName, + themeRGB: propThemeRGB, + }); + const controlledThemeActive = useRef( + Boolean( + (propThemeDefinition && isValidThemeDefinition(propThemeDefinition)) || + (!propThemeDefinition && propThemeRGB), + ), + ); + const synchronizedThemeProps = useRef(false); + const themeDOMSnapshot = useRef(undefined); + const themeClassSnapshot = useRef(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 {children}; } -/** - * 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; diff --git a/packages/client/src/theme/index.ts b/packages/client/src/theme/index.ts index 18d02ed1e1..b7177e09b4 100644 --- a/packages/client/src/theme/index.ts +++ b/packages/client/src/theme/index.ts @@ -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'; diff --git a/packages/client/src/theme/registry.spec.ts b/packages/client/src/theme/registry.spec.ts new file mode 100644 index 0000000000..55d25eefa7 --- /dev/null +++ b/packages/client/src/theme/registry.spec.ts @@ -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); + }); +}); diff --git a/packages/client/src/theme/registry.ts b/packages/client/src/theme/registry.ts new file mode 100644 index 0000000000..3c5245bf84 --- /dev/null +++ b/packages/client/src/theme/registry.ts @@ -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, +); + +export const themeAppearanceProperties: Readonly< + Record +> = 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 => { + 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 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, + appearance: { ...defaultAppearance, ...definition?.appearance }, + }; +} + +export function fromLegacyTheme(colors: IThemeRGB, name = 'custom'): ThemeDefinition { + const legacyName = name.trim() || 'custom'; + const sanitizedColors = themeColorTokens.reduce((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 }, + }, + }; +} diff --git a/packages/client/src/theme/tailwind.spec.js b/packages/client/src/theme/tailwind.spec.js new file mode 100644 index 0000000000..695972ad0e --- /dev/null +++ b/packages/client/src/theme/tailwind.spec.js @@ -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]};`); + }); + }); +}); diff --git a/packages/client/src/theme/types/index.ts b/packages/client/src/theme/types/index.ts index 15370055ab..02d9b82516 100644 --- a/packages/client/src/theme/types/index.ts +++ b/packages/client/src/theme/types/index.ts @@ -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; +} + +/** Versioned, data-only theme input. Missing values resolve against LibreChat defaults. */ +export interface ThemeDefinition { + version: 1; + name: string; + modes: Partial>; +} + +export interface ResolvedThemeDefinition { + version: 1; + name: string; + mode: ThemeMode; + colors: Required; + appearance: IThemeAppearance; +} diff --git a/packages/client/src/theme/utils/applyTheme.spec.ts b/packages/client/src/theme/utils/applyTheme.spec.ts index a10d1f0710..7676edba7c 100644 --- a/packages/client/src/theme/utils/applyTheme.spec.ts +++ b/packages/client/src/theme/utils/applyTheme.spec.ts @@ -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'); + }); }); diff --git a/packages/client/src/theme/utils/applyTheme.ts b/packages/client/src/theme/utils/applyTheme.ts index 409bdd3ba9..58422e43a2 100644 --- a/packages/client/src/theme/utils/applyTheme.ts +++ b/packages/client/src/theme/utils/applyTheme.ts @@ -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 { - const variables: Partial = {}; - - // Map each RGB value to its corresponding CSS variable - const mappings: Record = { - '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>((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) / )`, 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); }); } diff --git a/packages/client/tailwind.config.js b/packages/client/tailwind.config.js index df7ee31c61..c17c09e12a 100644 --- a/packages/client/tailwind.config.js +++ b/packages/client/tailwind.config.js @@ -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(), diff --git a/packages/client/tailwind.preset.cjs b/packages/client/tailwind.preset.cjs new file mode 100644 index 0000000000..a25b41b8b0 --- /dev/null +++ b/packages/client/tailwind.preset.cjs @@ -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)', + }, + }, + }, +};