🧩 refactor: Shared UI Design System Tokens (#14670)

* feat: harden shared design system tokens

* fix: address design system review findings
This commit is contained in:
Danny Avila 2026-08-06 22:13:02 -04:00 • committed by GitHub
parent dfe9ed0a94
commit bd5c1ad05c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 525 additions and 78 deletions

View file

@ -174,6 +174,7 @@ html {
--surface-tertiary: var(--gray-100);
--surface-tertiary-alt: var(--white);
--surface-dialog: var(--white);
--surface-overlay: var(--gray-500);
--surface-submit: var(--green-700);
--surface-submit-hover: var(--green-800);
--surface-destructive: var(--red-700);
@ -192,18 +193,23 @@ html {
--status-success: var(--green-600);
--status-success-subtle: var(--green-50);
--status-success-border: var(--green-300);
--status-success-strong: 2 133 94;
--status-info: var(--blue-600);
--status-info-subtle: var(--blue-50);
--status-info-border: var(--blue-300);
--status-info-strong: var(--gray-500);
--status-warning: var(--amber-600);
--status-warning-subtle: var(--amber-50);
--status-warning-border: var(--amber-300);
--status-warning-strong: 199 82 9;
--status-error: var(--red-600);
--status-error-subtle: var(--red-50);
--status-error-border: var(--red-300);
--status-error-strong: 224 47 31;
--status-neutral: var(--gray-600);
--status-neutral-subtle: var(--gray-100);
--status-neutral-border: var(--gray-300);
--text-on-status: var(--white);
--surface-inverted: var(--gray-850);
--surface-inverted-hover: var(--gray-700);
@ -243,6 +249,7 @@ html {
--surface-tertiary: var(--gray-700);
--surface-tertiary-alt: var(--gray-700);
--surface-dialog: var(--gray-850);
--surface-overlay: 0 0 0;
--surface-submit: var(--green-700);
--surface-submit-hover: var(--green-800);
--surface-destructive: var(--red-800);
@ -257,18 +264,23 @@ html {
--status-success: var(--green-300);
--status-success-subtle: var(--green-950);
--status-success-border: var(--green-800);
--status-success-strong: var(--green-800);
--status-info: var(--blue-300);
--status-info-subtle: var(--blue-950);
--status-info-border: var(--blue-800);
--status-info-strong: var(--gray-600);
--status-warning: var(--amber-300);
--status-warning-subtle: var(--amber-950);
--status-warning-border: var(--amber-800);
--status-warning-strong: var(--amber-800);
--status-error: var(--red-300);
--status-error-subtle: var(--red-950);
--status-error-border: var(--red-800);
--status-error-strong: var(--red-800);
--status-neutral: var(--gray-300);
--status-neutral-subtle: var(--gray-800);
--status-neutral-border: var(--gray-700);
--text-on-status: var(--white);
--surface-inverted: var(--white);
--surface-inverted-hover: var(--gray-100);

View file

@ -36,6 +36,7 @@ const THEME_TOKENS = [
'surface-tertiary',
'surface-tertiary-alt',
'surface-dialog',
'surface-overlay',
'surface-submit',
'surface-submit-hover',
'surface-destructive',
@ -58,18 +59,23 @@ const THEME_TOKENS = [
'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',

View file

@ -31,6 +31,12 @@ describe('getThemeFromEnv', () => {
REACT_APP_THEME_TEXT_INVERTED: '19 20 21',
REACT_APP_THEME_SURFACE_FIXED_HOVER: '22 23 24',
REACT_APP_THEME_TEXT_FIXED: '25 26 27',
REACT_APP_THEME_SURFACE_OVERLAY: '28 29 30',
REACT_APP_THEME_STATUS_SUCCESS_STRONG: '31 32 33',
REACT_APP_THEME_STATUS_INFO_STRONG: '34 35 36',
REACT_APP_THEME_STATUS_WARNING_STRONG: '37 38 39',
REACT_APP_THEME_STATUS_ERROR_STRONG: '40 41 42',
REACT_APP_THEME_TEXT_ON_STATUS: '43 44 45',
}),
).toEqual({
'rgb-status-error': '1 2 3',
@ -42,6 +48,12 @@ describe('getThemeFromEnv', () => {
'rgb-text-inverted': '19 20 21',
'rgb-surface-fixed-hover': '22 23 24',
'rgb-text-fixed': '25 26 27',
'rgb-surface-overlay': '28 29 30',
'rgb-status-success-strong': '31 32 33',
'rgb-status-info-strong': '34 35 36',
'rgb-status-warning-strong': '37 38 39',
'rgb-status-error-strong': '40 41 42',
'rgb-text-on-status': '43 44 45',
});
});

View file

@ -26,7 +26,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className = '', ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-gray-500/90 transition-opacity animate-in fade-in dark:bg-gray-800/90',
'fixed inset-0 z-50 bg-surface-overlay/90 transition-opacity animate-in fade-in',
className,
)}
{...props}

View file

@ -28,7 +28,7 @@ const AnimatedSearchInput = ({
<Search
className={cn(
`h-4 w-4 transition-all duration-500 ease-in-out`,
isSearching && hasValue ? 'text-blue-400' : 'text-text-secondary',
isSearching && hasValue ? 'text-accent-primary' : 'text-text-secondary',
)}
/>
</div>
@ -45,7 +45,7 @@ const AnimatedSearchInput = ({
{/* Gradient overlay */}
<div
className={`pointer-events-none absolute inset-0 z-20 rounded-lg bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-blue-500/20 transition-all duration-500 ease-in-out ${isSearching && hasValue ? 'opacity-100 blur-sm' : 'opacity-0 blur-none'} `}
className={`pointer-events-none absolute inset-0 z-20 rounded-lg bg-gradient-to-r from-accent-primary/20 via-accent-primary/10 to-accent-primary/20 transition-all duration-500 ease-in-out ${isSearching && hasValue ? 'opacity-100 blur-sm' : 'opacity-0 blur-none'} `}
/>
{/* Animated loading indicator */}
@ -53,8 +53,8 @@ const AnimatedSearchInput = ({
className={`absolute right-3 top-1/2 z-20 -translate-y-1/2 transition-all duration-500 ease-in-out ${isSearching && hasValue ? 'scale-100 opacity-100' : 'scale-0 opacity-0'} `}
>
<div className="relative h-2 w-2">
<div className="absolute inset-0 animate-ping rounded-full bg-blue-500/60" />
<div className="absolute inset-0 rounded-full bg-blue-500" />
<div className="absolute inset-0 animate-ping rounded-full bg-accent-primary/60" />
<div className="absolute inset-0 rounded-full bg-accent-primary" />
</div>
</div>
</div>
@ -66,15 +66,15 @@ const AnimatedSearchInput = ({
>
<div className="absolute inset-0">
<div
className={`bg-gradient-radial absolute inset-0 from-blue-500/10 to-transparent transition-opacity duration-700 ease-in-out ${isSearching && hasValue ? 'animate-pulse-slow opacity-100' : 'opacity-0'} `}
className={`bg-gradient-radial absolute inset-0 from-accent-primary/10 to-transparent transition-opacity duration-700 ease-in-out ${isSearching && hasValue ? 'animate-pulse-slow opacity-100' : 'opacity-0'} `}
/>
<div
className={`absolute inset-0 bg-gradient-to-r from-purple-500/5 via-blue-500/5 to-purple-500/5 blur-xl transition-all duration-700 ease-in-out ${isSearching && hasValue ? 'animate-gradient-x opacity-100' : 'opacity-0'} `}
className={`absolute inset-0 bg-gradient-to-r from-accent-primary/5 via-accent-primary/10 to-accent-primary/5 blur-xl transition-all duration-700 ease-in-out ${isSearching && hasValue ? 'animate-gradient-x opacity-100' : 'opacity-0'} `}
/>
</div>
</div>
<div
className={`absolute inset-0 -z-20 scale-100 bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-blue-500/10 opacity-0 blur-xl transition-all duration-500 ease-in-out peer-focus:scale-105 peer-focus:opacity-100`}
className={`absolute inset-0 -z-20 scale-100 bg-gradient-to-r from-accent-primary/10 via-accent-primary/10 to-accent-primary/10 opacity-0 blur-xl transition-all duration-500 ease-in-out peer-focus:scale-105 peer-focus:opacity-100`}
/>
</div>
);

View file

@ -0,0 +1,38 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import Badge from './Badge';
describe('Badge', () => {
it('keeps the editing action button outside the badge button', () => {
const onBadgeAction = jest.fn();
const { container } = render(
<Badge label="Tool" isAvailable={true} isEditing onBadgeAction={onBadgeAction} />,
);
expect(container.querySelector('button button')).toBeNull();
expect(container.firstElementChild?.tagName).toBe('DIV');
fireEvent.click(screen.getByRole('button', { name: 'Remove Tool' }));
expect(onBadgeAction).toHaveBeenCalledTimes(1);
});
it('forwards editing-mode attributes and event handlers to the wrapper', () => {
const onMouseDown = jest.fn();
render(
<Badge
label="Tool"
isAvailable={true}
isEditing
aria-label="Editable tool"
data-testid="editable-badge"
onMouseDown={onMouseDown}
/>,
);
const badge = screen.getByTestId('editable-badge');
expect(badge).toHaveAttribute('aria-label', 'Editable tool');
fireEvent.mouseDown(badge);
expect(onMouseDown).toHaveBeenCalledTimes(1);
});
});

View file

@ -40,7 +40,7 @@ export default function Badge({
const isMoveable = isEditing && isAvailable;
const isDisabled = id === '1' && isInChat;
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
const handleClick: React.MouseEventHandler<HTMLElement> = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
@ -64,29 +64,24 @@ export default function Badge({
return 0.97;
};
return (
<motion.button
onClick={handleClick}
className={cn(
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
'border border-border-medium text-sm font-medium transition-shadow',
'@container-[600px]:w-full size-9 p-2',
isActive
? 'bg-surface-active shadow-md'
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:scale-95 active:shadow-inner',
isMoveable && 'cursor-move',
isDisabled && 'cursor-not-allowed opacity-50 hover:shadow-sm',
className,
)}
animate={{
scale: isDragging ? 1.1 : 1,
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
}}
whileTap={{ scale: getWhileTapScale() }}
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
{...(props as React.ComponentProps<typeof motion.button>)}
>
const { type: buttonType, onClick: customOnClick, ...buttonProps } = props;
const handleRootClick: React.MouseEventHandler<HTMLElement> = customOnClick
? (event) => customOnClick(event as React.MouseEvent<HTMLButtonElement>)
: handleClick;
const badgeClassName = cn(
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
'border border-border-medium text-sm font-medium transition-shadow',
'@container-[600px]:w-full size-9 p-2',
isActive
? 'bg-surface-active shadow-md'
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:scale-95 active:shadow-inner',
isMoveable && 'cursor-move',
isDisabled && 'cursor-not-allowed opacity-50 hover:shadow-sm',
className,
);
const badgeContent = (
<>
{Icon && (
<Icon
className={cn(
@ -100,7 +95,9 @@ export default function Badge({
{isEditing && !isDragging && (
<motion.button
type="button"
className="@container-[600px]:h-5 @container-[600px]:w-5 absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary shadow-sm"
aria-label={isAvailable ? `Remove ${label}` : `Restore ${label}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
@ -114,6 +111,41 @@ export default function Badge({
{isAvailable ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</motion.button>
)}
</>
);
if (isEditing) {
return (
<motion.div
{...(buttonProps as React.ComponentProps<typeof motion.div>)}
onClick={handleRootClick}
className={badgeClassName}
animate={{
scale: isDragging ? 1.1 : 1,
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
}}
whileTap={{ scale: getWhileTapScale() }}
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
>
{badgeContent}
</motion.div>
);
}
return (
<motion.button
type={buttonType ?? 'button'}
onClick={handleRootClick}
className={badgeClassName}
animate={{
scale: isDragging ? 1.1 : 1,
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
}}
whileTap={{ scale: getWhileTapScale() }}
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
{...(buttonProps as React.ComponentProps<typeof motion.button>)}
>
{badgeContent}
</motion.button>
);
}

View file

@ -27,7 +27,8 @@ const buttonVariants: (
variants: {
variant: {
default: 'bg-surface-inverted text-text-inverted hover:bg-surface-inverted-hover',
destructive: 'bg-surface-destructive text-white hover:bg-surface-destructive-hover',
destructive:
'bg-surface-destructive text-text-on-status hover:bg-surface-destructive-hover',
outline:
'text-text-primary border border-border-light bg-transparent hover:bg-surface-hover hover:text-text-primary',
subtle:
@ -35,8 +36,7 @@ const buttonVariants: (
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',
// hardcoded text color because of WCAG contrast issues (text-white)
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
submit: 'bg-surface-submit text-text-on-status hover:bg-surface-submit-hover',
},
size: {
default: 'h-10 px-4 py-2',

View file

@ -0,0 +1,32 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { Chip } from './Chip';
describe('Chip', () => {
it('renders semantic tone classes', () => {
render(<Chip tone="info">Search result</Chip>);
expect(screen.getByText('Search result').parentElement).toHaveClass(
'bg-status-info-subtle',
'text-status-info',
);
});
it('stops propagation and calls the remove handler', () => {
const onRemove = jest.fn();
const onParentClick = jest.fn();
render(
<div onClick={onParentClick}>
<Chip onRemove={onRemove} removeLabel="Remove filter">
Filter
</Chip>
</div>,
);
fireEvent.click(screen.getByRole('button', { name: 'Remove filter' }));
expect(onRemove).toHaveBeenCalledTimes(1);
expect(onParentClick).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,85 @@
import * as React from 'react';
import { X } from 'lucide-react';
import { cva } from 'class-variance-authority';
import type { ClassProp } from 'class-variance-authority/types';
import { IconButton } from './IconButton';
import { cn } from '~/utils';
type ChipVariantProps = {
tone?: 'neutral' | 'info' | 'success' | 'warning' | 'error' | null;
size?: 'sm' | 'md' | 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',
{
variants: {
tone: {
neutral: 'border-status-neutral-border bg-status-neutral-subtle text-status-neutral',
info: 'border-status-info-border bg-status-info-subtle text-status-info',
success: 'border-status-success-border bg-status-success-subtle text-status-success',
warning: 'border-status-warning-border bg-status-warning-subtle text-status-warning',
error: 'border-status-error-border bg-status-error-subtle text-status-error',
},
size: {
sm: 'min-h-6 px-2 py-0.5',
md: 'min-h-8 px-2.5 py-1',
},
},
defaultVariants: {
tone: 'neutral',
size: 'sm',
},
},
);
export interface ChipProps
extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'children'>,
ChipVariantProps {
children: React.ReactNode;
onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
removeLabel?: string;
leading?: React.ReactNode;
trailing?: React.ReactNode;
}
const Chip: React.ForwardRefExoticComponent<ChipProps & React.RefAttributes<HTMLSpanElement>> =
React.forwardRef<HTMLSpanElement, ChipProps>(
(
{
children,
className,
leading,
onRemove,
removeLabel = 'Remove',
size,
tone,
trailing,
...props
},
ref,
) => (
<span ref={ref} className={cn(chipVariants({ tone, size, className }))} {...props}>
{leading}
<span className="min-w-0 truncate">{children}</span>
{trailing}
{onRemove && (
<IconButton
label={removeLabel}
size="xs"
className="-mr-1 text-current hover:bg-surface-hover/50"
onClick={(event) => {
event.stopPropagation();
onRemove(event);
}}
>
<X className="size-3" aria-hidden="true" />
</IconButton>
)}
</span>
),
);
Chip.displayName = 'Chip';
export { Chip, chipVariants };

View file

@ -29,7 +29,7 @@ const DialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[999] bg-gray-600/65 transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in dark:bg-black/80',
'fixed inset-0 z-[999] bg-surface-overlay/65 transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in',
className ?? '',
)}
{...props}

View file

@ -0,0 +1,22 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { IconButton } from './IconButton';
describe('IconButton', () => {
it('provides an accessible name and a safe default button type', () => {
render(<IconButton label="Open menu">menu</IconButton>);
expect(screen.getByRole('button', { name: 'Open menu' })).toHaveAttribute('type', 'button');
});
it('allows semantic variants and consumer class names', () => {
render(
<IconButton label="Delete" variant="destructive" size="sm" className="custom-class">
delete
</IconButton>,
);
const button = screen.getByRole('button', { name: 'Delete' });
expect(button).toHaveClass('bg-surface-destructive', 'size-8', 'custom-class');
});
});

View file

@ -0,0 +1,64 @@
import * as React from 'react';
import { cva } from 'class-variance-authority';
import type { ClassProp } from 'class-variance-authority/types';
import { cn } from '~/utils';
type IconButtonVariantProps = {
variant?: 'default' | 'secondary' | 'ghost' | 'destructive' | null;
size?: 'xs' | 'sm' | 'md' | 'lg' | null;
shape?: 'round' | 'square' | 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',
{
variants: {
variant: {
default: 'bg-surface-secondary hover:bg-surface-hover',
secondary: 'border border-border-light bg-surface-secondary hover:bg-surface-hover',
ghost: 'bg-transparent hover:bg-surface-hover',
destructive:
'bg-surface-destructive text-text-on-status hover:bg-surface-destructive-hover',
},
size: {
xs: 'size-6',
sm: 'size-8',
md: 'size-9',
lg: 'size-10',
},
shape: {
round: 'rounded-full',
square: 'rounded-lg',
},
},
defaultVariants: {
variant: 'ghost',
size: 'md',
shape: 'round',
},
},
);
export interface IconButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'>,
IconButtonVariantProps {
label: string;
}
const IconButton: React.ForwardRefExoticComponent<
IconButtonProps & React.RefAttributes<HTMLButtonElement>
> = React.forwardRef<HTMLButtonElement, IconButtonProps>(
({ className, label, type = 'button', variant, size, shape, ...props }, ref) => (
<button
ref={ref}
type={type}
aria-label={label}
className={cn(iconButtonVariants({ variant, size, shape, className }))}
{...props}
/>
),
);
IconButton.displayName = 'IconButton';
export { IconButton, iconButtonVariants };

View file

@ -0,0 +1,26 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { Tag } from './Tag';
describe('Tag', () => {
it('uses semantic status tokens while preserving the success default', () => {
render(<Tag label="Enabled" onRemove={jest.fn()} />);
expect(screen.getByText('Enabled').parentElement).toHaveClass(
'bg-status-success-subtle',
'text-status-success',
);
});
it('supports alternate tones and removal callbacks', () => {
const onRemove = jest.fn();
render(<Tag label="Warning" variant="warning" onRemove={onRemove} />);
expect(screen.getByText('Warning').parentElement).toHaveClass(
'bg-status-warning-subtle',
'text-status-warning',
);
fireEvent.click(screen.getByRole('button', { name: 'Remove Warning' }));
expect(onRemove).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,48 +1,73 @@
import * as React from 'react';
import { X } from 'lucide-react';
import { IconButton } from './IconButton';
import { cn } from '~/utils';
type TagVariant = 'neutral' | 'info' | 'success' | 'warning' | 'error';
type TagProps = React.ComponentPropsWithoutRef<'div'> & {
label: string;
labelClassName?: string;
CancelButton?: React.ReactNode;
LabelNode?: React.ReactNode;
variant?: TagVariant;
onRemove?: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
const TagPrimitiveRoot = React.forwardRef<HTMLDivElement, TagProps>(
(
{ CancelButton, LabelNode, label, onRemove, className = '', labelClassName = '', ...props },
{
CancelButton,
LabelNode,
label,
onRemove,
className = '',
labelClassName = '',
variant = 'success',
...props
},
ref,
) => (
<div
ref={ref}
{...props}
className={cn(
'flex max-h-8 items-center overflow-y-hidden rounded-3xl border-2 border-green-600 bg-green-600/20 text-xs text-green-600 dark:text-white',
className,
)}
>
<div className={cn('ml-1 whitespace-pre-wrap px-2 py-1', labelClassName)}>
{LabelNode ? <>{LabelNode} </> : null}
{label}
) => {
const variantClassName = {
neutral: 'border-status-neutral-border bg-status-neutral-subtle text-status-neutral',
info: 'border-status-info-border bg-status-info-subtle text-status-info',
success: 'border-status-success-border bg-status-success-subtle text-status-success',
warning: 'border-status-warning-border bg-status-warning-subtle text-status-warning',
error: 'border-status-error-border bg-status-error-subtle text-status-error',
}[variant];
return (
<div
ref={ref}
{...props}
className={cn(
'flex max-h-8 items-center overflow-y-hidden rounded-3xl border-2 text-xs',
variantClassName,
className,
)}
>
<div className={cn('ml-1 whitespace-pre-wrap px-2 py-1', labelClassName)}>
{LabelNode ? <>{LabelNode} </> : null}
{label}
</div>
{CancelButton
? CancelButton
: onRemove && (
<IconButton
label={`Remove ${label}`}
size="xs"
className="mr-0.5 text-current hover:bg-surface-hover/50"
onClick={(e) => {
e.stopPropagation();
onRemove(e);
}}
>
<X className="size-3" aria-hidden="true" />
</IconButton>
)}
</div>
{CancelButton
? CancelButton
: onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(e);
}}
className="rounded-full bg-green-600/50"
aria-label={`Remove ${label}`}
>
<X className="m-[1.5px] p-1" aria-hidden="true" />
</button>
)}
</div>
),
);
},
);
TagPrimitiveRoot.displayName = 'Tag';
@ -54,6 +79,7 @@ export const Tag: React.MemoExoticComponent<
labelClassName?: string;
CancelButton?: React.ReactNode;
LabelNode?: React.ReactNode;
variant?: TagVariant;
onRemove?: (e: React.MouseEvent<HTMLButtonElement>) => void;
} & React.RefAttributes<HTMLDivElement>
>

View file

@ -6,16 +6,10 @@ import { useToast } from '~/hooks';
export function Toast(): JSX.Element {
const { toast, onOpenChange } = useToast();
const severityClassName = {
/* Going up by 100 units in terms of darkness (eg bg-green-500 to bg-green-600) for
* bg colors produces colors that are too visually dissimilar to LibreChat's standard color palette.
* These colors were derived by adjusting the values in the HSV color space using CCA
* until the 4.5:1 contrast ratio threshold was met against white text while maintaining
* a relatively recognizable color scheme for toasts without compromising accessibility.
* */
[NotificationSeverity.INFO]: 'border-gray-500 bg-gray-500',
[NotificationSeverity.SUCCESS]: 'border-[#02855E] bg-[#02855E]',
[NotificationSeverity.WARNING]: 'border-[#C75209] bg-[#C75209]',
[NotificationSeverity.ERROR]: 'border-[#E02F1F] bg-[#E02F1F]',
[NotificationSeverity.INFO]: 'border-status-info-strong bg-status-info-strong',
[NotificationSeverity.SUCCESS]: 'border-status-success-strong bg-status-success-strong',
[NotificationSeverity.WARNING]: 'border-status-warning-strong bg-status-warning-strong',
[NotificationSeverity.ERROR]: 'border-status-error-strong bg-status-error-strong',
};
return (
@ -30,7 +24,7 @@ export function Toast(): JSX.Element {
>
<div className="w-full p-1 text-center md:w-auto md:text-justify">
<div
className={`alert-root pointer-events-auto inline-flex flex-row gap-2 rounded-md border px-3 py-2 font-bold text-white ${
className={`alert-root pointer-events-auto inline-flex flex-row gap-2 rounded-md border px-3 py-2 font-bold text-text-on-status ${
severityClassName[toast.severity]
}`}
>

View file

@ -4,6 +4,7 @@ export * from './Alert';
export * from './AlertDialog';
export * from './Breadcrumb';
export * from './Button';
export * from './Chip';
export * from './Checkbox';
export * from './Dialog';
export * from './DropdownMenu';
@ -30,6 +31,7 @@ export * from './Tooltip';
export * from './Pagination';
export * from './Progress';
export * from './InputOTP';
export * from './IconButton';
export * from './MultiSearch';
export * from './Resizable';
export * from './Select';

View file

@ -137,7 +137,9 @@ function MyComponent() {
return (
<div className="border border-border-light bg-surface-primary text-text-primary">
<h1 className="text-text-secondary">Hello World</h1>
<button className="bg-surface-submit text-white hover:bg-surface-submit-hover">Submit</button>
<button className="bg-surface-submit text-text-on-status hover:bg-surface-submit-hover">
Submit
</button>
</div>
);
}
@ -153,6 +155,7 @@ function MyComponent() {
- `text-text-tertiary` - Tertiary text color
- `text-text-warning` - Warning text color
- `text-text-destructive` - Destructive/error text color
- `text-text-on-status` - Text color for strong status surfaces
### Surface Colors
@ -162,6 +165,7 @@ function MyComponent() {
- `bg-surface-submit` - Submit button background
- `bg-surface-destructive` - Destructive action background
- `bg-surface-dialog` - Dialog/modal background
- `bg-surface-overlay` - Dialog/modal scrim, adapted per theme
- `bg-surface-chat` - Chat interface background
### Border Colors
@ -174,13 +178,15 @@ function MyComponent() {
### Status Colors
Each status family has a foreground, a `-subtle` background, and a `-border`:
Each status family has a foreground, a `-subtle` background, a `-border`, and a
`-strong` surface for high-contrast notifications:
- `text-status-success` / `bg-status-success-subtle` / `border-status-success-border`
- `text-status-info` / `bg-status-info-subtle` / `border-status-info-border`
- `text-status-warning` / `bg-status-warning-subtle` / `border-status-warning-border`
- `text-status-error` / `bg-status-error-subtle` / `border-status-error-border`
- `text-status-neutral` / `bg-status-neutral-subtle` / `border-status-neutral-border`
- `bg-status-success-strong` / `bg-status-info-strong` / `bg-status-warning-strong` / `bg-status-error-strong`
### Other Colors

View file

@ -0,0 +1,28 @@
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
const sharedComponents = [
'AnimatedSearchInput.tsx',
'AlertDialog.tsx',
'Button.tsx',
'Chip.tsx',
'Dialog.tsx',
'IconButton.tsx',
'Tag.tsx',
'Toast.tsx',
];
describe('shared component color guardrail', () => {
it('keeps shared primitives free of direct palette utilities and hex colors', () => {
const directPalette =
/(?:bg|text|border|ring|from|via|to)-(?:gray|red|green|blue|purple|amber|yellow|orange|pink|indigo|violet|teal|cyan|slate|zinc|neutral|stone)-\d/;
const hexColor = /#[0-9a-f]{3,8}\b/i;
sharedComponents.forEach((component) => {
const source = readFileSync(join(__dirname, '..', 'components', component), 'utf8');
expect(source).not.toMatch(directPalette);
expect(source).not.toMatch(hexColor);
});
});
});

View file

@ -41,6 +41,7 @@ export const darkTheme: IThemeRGB = {
'rgb-surface-tertiary': '47 47 47', // #2f2f2f (gray-700)
'rgb-surface-tertiary-alt': '47 47 47', // #2f2f2f (gray-700)
'rgb-surface-dialog': '23 23 23', // #171717 (gray-850)
'rgb-surface-overlay': '0 0 0', // #000 (black)
'rgb-surface-submit': '4 120 87', // #047857 (green-700)
'rgb-surface-submit-hover': '6 95 70', // #065f46 (green-800)
'rgb-surface-destructive': '153 27 27', // #991b1b (red-800)
@ -65,18 +66,23 @@ export const darkTheme: IThemeRGB = {
'rgb-status-success': '110 231 183', // #6ee7b7 (green-300)
'rgb-status-success-subtle': '2 44 34', // #022c22 (green-950)
'rgb-status-success-border': '6 95 70', // #065f46 (green-800)
'rgb-status-success-strong': '6 95 70', // #065f46 (green-800)
'rgb-status-info': '147 197 253', // #93c5fd (blue-300)
'rgb-status-info-subtle': '23 37 84', // #172554 (blue-950)
'rgb-status-info-border': '30 64 175', // #1e40af (blue-800)
'rgb-status-info-strong': '66 66 66', // #424242 (gray-600)
'rgb-status-warning': '252 211 77', // #fcd34d (amber-300)
'rgb-status-warning-subtle': '69 26 3', // #451a03 (amber-950)
'rgb-status-warning-border': '146 64 14', // #92400e (amber-800)
'rgb-status-warning-strong': '146 64 14', // #92400e (amber-800)
'rgb-status-error': '252 165 165', // #fca5a5 (red-300)
'rgb-status-error-subtle': '69 10 10', // #450a0a (red-950)
'rgb-status-error-border': '153 27 27', // #991b1b (red-800)
'rgb-status-error-strong': '153 27 27', // #991b1b (red-800)
'rgb-status-neutral': '205 205 205', // #cdcdcd (gray-300)
'rgb-status-neutral-subtle': '33 33 33', // #212121 (gray-800)
'rgb-status-neutral-border': '47 47 47', // #2f2f2f (gray-700)
'rgb-text-on-status': '255 255 255', // #fff (white)
// Brand colors
'rgb-brand-purple': '171 104 255', // #ab68ff

View file

@ -41,6 +41,7 @@ export const defaultTheme: IThemeRGB = {
'rgb-surface-tertiary': '236 236 236', // #ececec (gray-100)
'rgb-surface-tertiary-alt': '255 255 255', // #fff (white)
'rgb-surface-dialog': '255 255 255', // #fff (white)
'rgb-surface-overlay': '89 89 89', // #595959 (gray-500)
'rgb-surface-submit': '4 120 87', // #047857 (green-700)
'rgb-surface-submit-hover': '6 95 70', // #065f46 (green-800)
'rgb-surface-destructive': '185 28 28', // #b91c1c (red-700)
@ -65,18 +66,23 @@ export const defaultTheme: IThemeRGB = {
'rgb-status-success': '5 150 105', // #059669 (green-600)
'rgb-status-success-subtle': '236 253 245', // #ecfdf5 (green-50)
'rgb-status-success-border': '110 231 183', // #6ee7b7 (green-300)
'rgb-status-success-strong': '2 133 94', // #02855e
'rgb-status-info': '37 99 235', // #2563eb (blue-600)
'rgb-status-info-subtle': '239 246 255', // #eff6ff (blue-50)
'rgb-status-info-border': '147 197 253', // #93c5fd (blue-300)
'rgb-status-info-strong': '89 89 89', // #595959 (gray-500)
'rgb-status-warning': '217 119 6', // #d97706 (amber-600)
'rgb-status-warning-subtle': '255 251 235', // #fffbeb (amber-50)
'rgb-status-warning-border': '252 211 77', // #fcd34d (amber-300)
'rgb-status-warning-strong': '199 82 9', // #c75209
'rgb-status-error': '220 38 38', // #dc2626 (red-600)
'rgb-status-error-subtle': '254 242 242', // #fef2f2 (red-50)
'rgb-status-error-border': '252 165 165', // #fca5a5 (red-300)
'rgb-status-error-strong': '224 47 31', // #e02f1f
'rgb-status-neutral': '66 66 66', // #424242 (gray-600)
'rgb-status-neutral-subtle': '236 236 236', // #ececec (gray-100)
'rgb-status-neutral-border': '205 205 205', // #cdcdcd (gray-300)
'rgb-text-on-status': '255 255 255', // #fff (white)
// Brand colors
'rgb-brand-purple': '171 104 255', // #ab68ff

View file

@ -39,6 +39,7 @@ export interface IThemeRGB {
'rgb-surface-tertiary'?: string;
'rgb-surface-tertiary-alt'?: string;
'rgb-surface-dialog'?: string;
'rgb-surface-overlay'?: string;
'rgb-surface-submit'?: string;
'rgb-surface-submit-hover'?: string;
'rgb-surface-destructive'?: string;
@ -63,18 +64,23 @@ export interface IThemeRGB {
'rgb-status-success'?: string;
'rgb-status-success-subtle'?: string;
'rgb-status-success-border'?: string;
'rgb-status-success-strong'?: string;
'rgb-status-info'?: string;
'rgb-status-info-subtle'?: string;
'rgb-status-info-border'?: string;
'rgb-status-info-strong'?: string;
'rgb-status-warning'?: string;
'rgb-status-warning-subtle'?: string;
'rgb-status-warning-border'?: string;
'rgb-status-warning-strong'?: string;
'rgb-status-error'?: string;
'rgb-status-error-subtle'?: string;
'rgb-status-error-border'?: string;
'rgb-status-error-strong'?: string;
'rgb-status-neutral'?: string;
'rgb-status-neutral-subtle'?: string;
'rgb-status-neutral-border'?: string;
'rgb-text-on-status'?: string;
// Brand colors
'rgb-brand-purple'?: string;
@ -114,6 +120,7 @@ export interface IThemeVariables {
'--surface-tertiary': string;
'--surface-tertiary-alt': string;
'--surface-dialog': string;
'--surface-overlay': string;
'--surface-submit': string;
'--surface-submit-hover': string;
'--surface-destructive': string;
@ -138,18 +145,23 @@ export interface IThemeVariables {
'--status-success': string;
'--status-success-subtle': string;
'--status-success-border': string;
'--status-success-strong': string;
'--status-info': string;
'--status-info-subtle': string;
'--status-info-border': string;
'--status-info-strong': string;
'--status-warning': string;
'--status-warning-subtle': string;
'--status-warning-border': string;
'--status-warning-strong': string;
'--status-error': string;
'--status-error-subtle': string;
'--status-error-border': string;
'--status-error-strong': string;
'--status-neutral': string;
'--status-neutral-subtle': string;
'--status-neutral-border': string;
'--text-on-status': string;
'--brand-purple': string;
'--presentation': string;
}
@ -185,6 +197,7 @@ export interface IThemeColors {
'surface-tertiary'?: string;
'surface-tertiary-alt'?: string;
'surface-dialog'?: string;
'surface-overlay'?: string;
'surface-submit'?: string;
'surface-submit-hover'?: string;
'surface-destructive'?: string;
@ -205,18 +218,23 @@ export interface IThemeColors {
'status-success'?: string;
'status-success-subtle'?: string;
'status-success-border'?: string;
'status-success-strong'?: string;
'status-info'?: string;
'status-info-subtle'?: string;
'status-info-border'?: string;
'status-info-strong'?: string;
'status-warning'?: string;
'status-warning-subtle'?: string;
'status-warning-border'?: string;
'status-warning-strong'?: string;
'status-error'?: string;
'status-error-subtle'?: string;
'status-error-border'?: string;
'status-error-strong'?: string;
'status-neutral'?: string;
'status-neutral-subtle'?: string;
'status-neutral-border'?: string;
'text-on-status'?: string;
'brand-purple'?: string;
presentation?: string;

View file

@ -12,6 +12,9 @@ const semanticProperties = [
'--status-success',
'--status-success-subtle',
'--status-success-border',
'--status-success-strong',
'--surface-overlay',
'--text-on-status',
'--status-error',
'--status-neutral-border',
];
@ -46,8 +49,11 @@ describe('applyTheme', () => {
'rgb-status-success': '26 27 28',
'rgb-status-success-subtle': '29 30 31',
'rgb-status-success-border': '32 33 34',
'rgb-status-success-strong': '33 34 35',
'rgb-status-error': '35 36 37',
'rgb-status-neutral-border': '38 39 40',
'rgb-surface-overlay': '39 40 41',
'rgb-text-on-status': '42 43 44',
});
const style = document.documentElement.style;
@ -56,13 +62,17 @@ describe('applyTheme', () => {
expect(style.getPropertyValue('--status-success')).toBe('26 27 28');
expect(style.getPropertyValue('--status-success-subtle')).toBe('29 30 31');
expect(style.getPropertyValue('--status-success-border')).toBe('32 33 34');
expect(style.getPropertyValue('--status-success-strong')).toBe('33 34 35');
expect(style.getPropertyValue('--status-error')).toBe('35 36 37');
expect(style.getPropertyValue('--status-neutral-border')).toBe('38 39 40');
expect(style.getPropertyValue('--surface-overlay')).toBe('39 40 41');
expect(style.getPropertyValue('--text-on-status')).toBe('42 43 44');
});
it('ships status tokens in the bundled themes', () => {
applyTheme(defaultTheme);
expect(document.documentElement.style.getPropertyValue('--status-error')).toBe('220 38 38');
expect(document.documentElement.style.getPropertyValue('--surface-overlay')).toBe('89 89 89');
});
});

View file

@ -53,6 +53,7 @@ function mapTheme(rgb: IThemeRGB): Partial<IThemeVariables> {
'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',
@ -73,18 +74,23 @@ function mapTheme(rgb: IThemeRGB): Partial<IThemeVariables> {
'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',
};

View file

@ -96,6 +96,7 @@ function createTailwindColors() {
'surface-tertiary': cssVar('--surface-tertiary'),
'surface-tertiary-alt': cssVar('--surface-tertiary-alt'),
'surface-dialog': cssVar('--surface-dialog'),
'surface-overlay': cssVar('--surface-overlay'),
'surface-submit': cssVar('--surface-submit'),
'surface-submit-hover': cssVar('--surface-submit-hover'),
'surface-destructive': cssVar('--surface-destructive'),
@ -118,18 +119,23 @@ function createTailwindColors() {
'status-success': cssVar('--status-success'),
'status-success-subtle': cssVar('--status-success-subtle'),
'status-success-border': cssVar('--status-success-border'),
'status-success-strong': cssVar('--status-success-strong'),
'status-info': cssVar('--status-info'),
'status-info-subtle': cssVar('--status-info-subtle'),
'status-info-border': cssVar('--status-info-border'),
'status-info-strong': cssVar('--status-info-strong'),
'status-warning': cssVar('--status-warning'),
'status-warning-subtle': cssVar('--status-warning-subtle'),
'status-warning-border': cssVar('--status-warning-border'),
'status-warning-strong': cssVar('--status-warning-strong'),
'status-error': cssVar('--status-error'),
'status-error-subtle': cssVar('--status-error-subtle'),
'status-error-border': cssVar('--status-error-border'),
'status-error-strong': cssVar('--status-error-strong'),
'status-neutral': cssVar('--status-neutral'),
'status-neutral-subtle': cssVar('--status-neutral-subtle'),
'status-neutral-border': cssVar('--status-neutral-border'),
'text-on-status': cssVar('--text-on-status'),
'switch-unchecked': hslVar('--switch-unchecked'),
};

View file

@ -1,6 +1,16 @@
const { createTailwindColors } = require('./createTailwindColors');
describe('createTailwindColors', () => {
it('exposes semantic overlay and strong status colors', () => {
const colors = createTailwindColors();
expect(colors['surface-overlay']).toBe('rgb(var(--surface-overlay) / <alpha-value>)');
expect(colors['status-success-strong']).toBe(
'rgb(var(--status-success-strong) / <alpha-value>)',
);
expect(colors['text-on-status']).toBe('rgb(var(--text-on-status) / <alpha-value>)');
});
it('combines intrinsic border alpha with Tailwind opacity modifiers', () => {
const colors = createTailwindColors();