mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-24 11:12:49 +00:00
🧩 refactor: Shared UI Design System Tokens (#14670)
* feat: harden shared design system tokens * fix: address design system review findings
This commit is contained in:
parent
dfe9ed0a94
commit
bd5c1ad05c
26 changed files with 525 additions and 78 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
38
packages/client/src/components/Badge.spec.tsx
Normal file
38
packages/client/src/components/Badge.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
32
packages/client/src/components/Chip.spec.tsx
Normal file
32
packages/client/src/components/Chip.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
85
packages/client/src/components/Chip.tsx
Normal file
85
packages/client/src/components/Chip.tsx
Normal 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 };
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
22
packages/client/src/components/IconButton.spec.tsx
Normal file
22
packages/client/src/components/IconButton.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
64
packages/client/src/components/IconButton.tsx
Normal file
64
packages/client/src/components/IconButton.tsx
Normal 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 };
|
||||
26
packages/client/src/components/Tag.spec.tsx
Normal file
26
packages/client/src/components/Tag.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
28
packages/client/src/theme/semanticTokens.spec.ts
Normal file
28
packages/client/src/theme/semanticTokens.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue