From bd5c1ad05c0f0266ddd1ef87af515485938900ab Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 6 Aug 2026 22:13:02 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A9=20refactor:=20Shared=20UI=20Design?= =?UTF-8?q?=20System=20Tokens=20(#14670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: harden shared design system tokens * fix: address design system review findings --- client/src/style.css | 12 +++ client/src/utils/getThemeFromEnv.js | 6 ++ client/src/utils/getThemeFromEnv.spec.js | 12 +++ .../client/src/components/AlertDialog.tsx | 2 +- .../src/components/AnimatedSearchInput.tsx | 14 +-- packages/client/src/components/Badge.spec.tsx | 38 +++++++++ packages/client/src/components/Badge.tsx | 80 +++++++++++------ packages/client/src/components/Button.tsx | 6 +- packages/client/src/components/Chip.spec.tsx | 32 +++++++ packages/client/src/components/Chip.tsx | 85 +++++++++++++++++++ packages/client/src/components/Dialog.tsx | 2 +- .../client/src/components/IconButton.spec.tsx | 22 +++++ packages/client/src/components/IconButton.tsx | 64 ++++++++++++++ packages/client/src/components/Tag.spec.tsx | 26 ++++++ packages/client/src/components/Tag.tsx | 84 +++++++++++------- packages/client/src/components/Toast.tsx | 16 ++-- packages/client/src/components/index.ts | 2 + packages/client/src/theme/README.md | 10 ++- .../client/src/theme/semanticTokens.spec.ts | 28 ++++++ packages/client/src/theme/themes/dark.ts | 6 ++ packages/client/src/theme/themes/default.ts | 6 ++ packages/client/src/theme/types/index.ts | 18 ++++ .../client/src/theme/utils/applyTheme.spec.ts | 10 +++ packages/client/src/theme/utils/applyTheme.ts | 6 ++ .../src/theme/utils/createTailwindColors.js | 6 ++ .../theme/utils/createTailwindColors.spec.js | 10 +++ 26 files changed, 525 insertions(+), 78 deletions(-) create mode 100644 packages/client/src/components/Badge.spec.tsx create mode 100644 packages/client/src/components/Chip.spec.tsx create mode 100644 packages/client/src/components/Chip.tsx create mode 100644 packages/client/src/components/IconButton.spec.tsx create mode 100644 packages/client/src/components/IconButton.tsx create mode 100644 packages/client/src/components/Tag.spec.tsx create mode 100644 packages/client/src/theme/semanticTokens.spec.ts diff --git a/client/src/style.css b/client/src/style.css index 4fe79e0795..10b74256d6 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -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); diff --git a/client/src/utils/getThemeFromEnv.js b/client/src/utils/getThemeFromEnv.js index 39f828d3cf..5760bac946 100644 --- a/client/src/utils/getThemeFromEnv.js +++ b/client/src/utils/getThemeFromEnv.js @@ -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', diff --git a/client/src/utils/getThemeFromEnv.spec.js b/client/src/utils/getThemeFromEnv.spec.js index 0455e9325e..11713f01aa 100644 --- a/client/src/utils/getThemeFromEnv.spec.js +++ b/client/src/utils/getThemeFromEnv.spec.js @@ -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', }); }); diff --git a/packages/client/src/components/AlertDialog.tsx b/packages/client/src/components/AlertDialog.tsx index 8aae1b5c1a..09d72590c3 100644 --- a/packages/client/src/components/AlertDialog.tsx +++ b/packages/client/src/components/AlertDialog.tsx @@ -26,7 +26,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className = '', ...props }, ref) => ( @@ -45,7 +45,7 @@ const AnimatedSearchInput = ({ {/* Gradient overlay */}
{/* 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'} `} >
-
-
+
+
@@ -66,15 +66,15 @@ const AnimatedSearchInput = ({ >
); diff --git a/packages/client/src/components/Badge.spec.tsx b/packages/client/src/components/Badge.spec.tsx new file mode 100644 index 0000000000..97774a7592 --- /dev/null +++ b/packages/client/src/components/Badge.spec.tsx @@ -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( + , + ); + + 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( + , + ); + + const badge = screen.getByTestId('editable-badge'); + expect(badge).toHaveAttribute('aria-label', 'Editable tool'); + + fireEvent.mouseDown(badge); + expect(onMouseDown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/client/src/components/Badge.tsx b/packages/client/src/components/Badge.tsx index 8a645fc1a3..4bcdbb5f15 100644 --- a/packages/client/src/components/Badge.tsx +++ b/packages/client/src/components/Badge.tsx @@ -40,7 +40,7 @@ export default function Badge({ const isMoveable = isEditing && isAvailable; const isDisabled = id === '1' && isInChat; - const handleClick: React.MouseEventHandler = (e) => { + const handleClick: React.MouseEventHandler = (e) => { if (isDisabled) { e.preventDefault(); e.stopPropagation(); @@ -64,29 +64,24 @@ export default function Badge({ return 0.97; }; - return ( - )} - > + const { type: buttonType, onClick: customOnClick, ...buttonProps } = props; + const handleRootClick: React.MouseEventHandler = customOnClick + ? (event) => customOnClick(event as React.MouseEvent) + : 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 && ( : } )} + + ); + + if (isEditing) { + return ( + )} + 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} + + ); + } + + return ( + )} + > + {badgeContent} ); } diff --git a/packages/client/src/components/Button.tsx b/packages/client/src/components/Button.tsx index 3444789ac4..3e33741497 100644 --- a/packages/client/src/components/Button.tsx +++ b/packages/client/src/components/Button.tsx @@ -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', diff --git a/packages/client/src/components/Chip.spec.tsx b/packages/client/src/components/Chip.spec.tsx new file mode 100644 index 0000000000..c029c01273 --- /dev/null +++ b/packages/client/src/components/Chip.spec.tsx @@ -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(Search result); + + 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( +
+ + Filter + +
, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove filter' })); + + expect(onRemove).toHaveBeenCalledTimes(1); + expect(onParentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/src/components/Chip.tsx b/packages/client/src/components/Chip.tsx new file mode 100644 index 0000000000..dc83b7aa46 --- /dev/null +++ b/packages/client/src/components/Chip.tsx @@ -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, 'children'>, + ChipVariantProps { + children: React.ReactNode; + onRemove?: (event: React.MouseEvent) => void; + removeLabel?: string; + leading?: React.ReactNode; + trailing?: React.ReactNode; +} + +const Chip: React.ForwardRefExoticComponent> = + React.forwardRef( + ( + { + children, + className, + leading, + onRemove, + removeLabel = 'Remove', + size, + tone, + trailing, + ...props + }, + ref, + ) => ( + + {leading} + {children} + {trailing} + {onRemove && ( + { + event.stopPropagation(); + onRemove(event); + }} + > + + )} + + ), + ); + +Chip.displayName = 'Chip'; + +export { Chip, chipVariants }; diff --git a/packages/client/src/components/Dialog.tsx b/packages/client/src/components/Dialog.tsx index 832234227f..38fe8ea404 100644 --- a/packages/client/src/components/Dialog.tsx +++ b/packages/client/src/components/Dialog.tsx @@ -29,7 +29,7 @@ const DialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( { + it('provides an accessible name and a safe default button type', () => { + render(menu); + + expect(screen.getByRole('button', { name: 'Open menu' })).toHaveAttribute('type', 'button'); + }); + + it('allows semantic variants and consumer class names', () => { + render( + + delete + , + ); + + const button = screen.getByRole('button', { name: 'Delete' }); + expect(button).toHaveClass('bg-surface-destructive', 'size-8', 'custom-class'); + }); +}); diff --git a/packages/client/src/components/IconButton.tsx b/packages/client/src/components/IconButton.tsx new file mode 100644 index 0000000000..cb3ab83935 --- /dev/null +++ b/packages/client/src/components/IconButton.tsx @@ -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, 'aria-label'>, + IconButtonVariantProps { + label: string; +} + +const IconButton: React.ForwardRefExoticComponent< + IconButtonProps & React.RefAttributes +> = React.forwardRef( + ({ className, label, type = 'button', variant, size, shape, ...props }, ref) => ( + - )} -
- ), + ); + }, ); 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) => void; } & React.RefAttributes > diff --git a/packages/client/src/components/Toast.tsx b/packages/client/src/components/Toast.tsx index 7c47ef5d2f..8f160637b8 100644 --- a/packages/client/src/components/Toast.tsx +++ b/packages/client/src/components/Toast.tsx @@ -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 { >
diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index 0080307858..be45a915a8 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -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'; diff --git a/packages/client/src/theme/README.md b/packages/client/src/theme/README.md index 1fcdc57428..d9a2df3419 100644 --- a/packages/client/src/theme/README.md +++ b/packages/client/src/theme/README.md @@ -137,7 +137,9 @@ function MyComponent() { return (

Hello World

- +
); } @@ -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 diff --git a/packages/client/src/theme/semanticTokens.spec.ts b/packages/client/src/theme/semanticTokens.spec.ts new file mode 100644 index 0000000000..883b668942 --- /dev/null +++ b/packages/client/src/theme/semanticTokens.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/client/src/theme/themes/dark.ts b/packages/client/src/theme/themes/dark.ts index 6d00ae5c5b..ec7b4b73ef 100644 --- a/packages/client/src/theme/themes/dark.ts +++ b/packages/client/src/theme/themes/dark.ts @@ -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 diff --git a/packages/client/src/theme/themes/default.ts b/packages/client/src/theme/themes/default.ts index 2d131ad51f..3e2a4c75eb 100644 --- a/packages/client/src/theme/themes/default.ts +++ b/packages/client/src/theme/themes/default.ts @@ -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 diff --git a/packages/client/src/theme/types/index.ts b/packages/client/src/theme/types/index.ts index 2076793d94..15370055ab 100644 --- a/packages/client/src/theme/types/index.ts +++ b/packages/client/src/theme/types/index.ts @@ -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; diff --git a/packages/client/src/theme/utils/applyTheme.spec.ts b/packages/client/src/theme/utils/applyTheme.spec.ts index d5b139cecb..0b4c50ad82 100644 --- a/packages/client/src/theme/utils/applyTheme.spec.ts +++ b/packages/client/src/theme/utils/applyTheme.spec.ts @@ -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'); }); }); diff --git a/packages/client/src/theme/utils/applyTheme.ts b/packages/client/src/theme/utils/applyTheme.ts index d1e73945f9..409bdd3ba9 100644 --- a/packages/client/src/theme/utils/applyTheme.ts +++ b/packages/client/src/theme/utils/applyTheme.ts @@ -53,6 +53,7 @@ function mapTheme(rgb: IThemeRGB): Partial { '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 { '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', }; diff --git a/packages/client/src/theme/utils/createTailwindColors.js b/packages/client/src/theme/utils/createTailwindColors.js index 6bc3dc1b4d..7892e152ed 100644 --- a/packages/client/src/theme/utils/createTailwindColors.js +++ b/packages/client/src/theme/utils/createTailwindColors.js @@ -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'), }; diff --git a/packages/client/src/theme/utils/createTailwindColors.spec.js b/packages/client/src/theme/utils/createTailwindColors.spec.js index 0acac8c1b4..a626edf59d 100644 --- a/packages/client/src/theme/utils/createTailwindColors.spec.js +++ b/packages/client/src/theme/utils/createTailwindColors.spec.js @@ -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) / )'); + expect(colors['status-success-strong']).toBe( + 'rgb(var(--status-success-strong) / )', + ); + expect(colors['text-on-status']).toBe('rgb(var(--text-on-status) / )'); + }); + it('combines intrinsic border alpha with Tailwind opacity modifiers', () => { const colors = createTailwindColors();