diff --git a/packages/ui/src/foundations/colors.ts b/packages/ui/src/foundations/colors.ts index 097972c1b6d..b5c31bc369d 100644 --- a/packages/ui/src/foundations/colors.ts +++ b/packages/ui/src/foundations/colors.ts @@ -1,6 +1,7 @@ import { colors as colorUtils } from '../utils/colors'; import { colorOptionToThemedAlphaScale, colorOptionToThemedLightnessScale } from '../utils/colors/scales'; import { clerkCssVar } from '../utils/cssVariables'; +import { lightDark } from '../utils/lightDark'; const whiteAlpha = Object.freeze({ whiteAlpha25: 'hsla(0, 0%, 100%, 0.02)', @@ -34,14 +35,14 @@ type AlphaScale = NonNullable; const primaryScale = colorOptionToThemedLightnessScale( - clerkCssVar('color-primary', '#2F3037'), + clerkCssVar('color-primary', lightDark('#2F3037', '#ffffff')), 'primary', ) as LightnessScale<'primary'>; const successScale = colorOptionToThemedLightnessScale( @@ -62,7 +63,7 @@ const neutralAlphaScale = colorOptionToThemedAlphaScale( 'neutralAlpha', ) as AlphaScale<'neutralAlpha'>; const primaryAlphaScale = colorOptionToThemedAlphaScale( - clerkCssVar('color-primary', '#2F3037'), + clerkCssVar('color-primary', lightDark('#2F3037', '#ffffff')), 'primaryAlpha', ) as AlphaScale<'primaryAlpha'>; const successAlphaScale = colorOptionToThemedAlphaScale( @@ -79,7 +80,7 @@ const borderAlphaScale = colorOptionToThemedAlphaScale( 'borderAlpha', ) as AlphaScale<'borderAlpha'>; -const colorForeground = clerkCssVar('color-foreground', '#212126'); +const colorForeground = clerkCssVar('color-foreground', lightDark('#212126', 'white')); const colorMutedForeground = clerkCssVar( 'color-muted-foreground', colorUtils.makeTransparent(colorForeground, 0.35) || '#747686', @@ -92,8 +93,8 @@ const colors = Object.freeze({ 'color-modal-backdrop', colorUtils.makeTransparent(defaultColorNeutral, 0.27) || neutralAlphaScale.neutralAlpha700, ), - colorBackground: clerkCssVar('color-background', 'white'), - colorInput: clerkCssVar('color-input', 'white'), + colorBackground: clerkCssVar('color-background', lightDark('#ffffff', '#212126')), + colorInput: clerkCssVar('color-input', lightDark('white', '#26262B')), colorForeground, colorMutedForeground, colorMuted: undefined, @@ -101,8 +102,8 @@ const colors = Object.freeze({ 'color-ring', colorUtils.makeTransparent(defaultColorNeutral, 0.85) || neutralAlphaScale.neutralAlpha200, ), - colorInputForeground: clerkCssVar('color-input-foreground', '#131316'), - colorPrimaryForeground: clerkCssVar('color-primary-foreground', 'white'), + colorInputForeground: clerkCssVar('color-input-foreground', lightDark('#131316', 'white')), + colorPrimaryForeground: clerkCssVar('color-primary-foreground', lightDark('white', 'black')), colorShimmer: clerkCssVar('color-shimmer', 'rgba(255, 255, 255, 0.36)'), transparent: 'transparent', white: 'white', diff --git a/packages/ui/src/themes/index.ts b/packages/ui/src/themes/index.ts index 63442746a30..b98c9f9bde1 100644 --- a/packages/ui/src/themes/index.ts +++ b/packages/ui/src/themes/index.ts @@ -2,4 +2,3 @@ export * from './dark'; export * from './shadesOfPurple'; export * from './neobrutalism'; export * from './shadcn'; -export * from './lightDark'; diff --git a/packages/ui/src/themes/lightDark.ts b/packages/ui/src/themes/lightDark.ts deleted file mode 100644 index 32671626272..00000000000 --- a/packages/ui/src/themes/lightDark.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createTheme } from './createTheme'; - -export const lightDark = createTheme({ - name: 'lightDark', - variables: { - colorBackground: 'light-dark(#ffffff, #212126)', - colorNeutral: 'light-dark(#000000, #ffffff)', - colorPrimary: 'light-dark(#2F3037, #ffffff)', - colorPrimaryForeground: 'light-dark(white, black)', - colorForeground: 'light-dark(#212126, white)', - colorInputForeground: 'light-dark(#131316, white)', - colorInput: 'light-dark(white, #26262B)', - }, - elements: { - activeDeviceIcon: { - '--cl-chassis-bottom': 'light-dark(#444444, #d2d2d2)', - '--cl-chassis-back': 'light-dark(#343434, #e6e6e6)', - '--cl-chassis-screen': 'light-dark(#575757, #e6e6e6)', - '--cl-screen': 'light-dark(#000000, #111111)', - }, - }, -}); diff --git a/packages/ui/src/utils/__tests__/cssSupports.test.ts b/packages/ui/src/utils/__tests__/cssSupports.test.ts index 8c745cc15b2..d5a973d620d 100644 --- a/packages/ui/src/utils/__tests__/cssSupports.test.ts +++ b/packages/ui/src/utils/__tests__/cssSupports.test.ts @@ -1,29 +1,54 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { clearCache, cssSupports } from '../cssSupports'; +import { lightDark } from '../lightDark'; -// Mock CSS.supports -const originalCSSSupports = CSS.supports; +// Store original CSS if it exists +const originalCSS = globalThis.CSS; beforeAll(() => { - CSS.supports = vi.fn(feature => { - if (feature === 'color: hsl(from white h s l)') { - return true; - } - if (feature === 'color: color-mix(in srgb, white, black)') { + // Create mock CSS global for Node.js environment + globalThis.CSS = { + supports: vi.fn((feature: string) => { + if (feature === 'color: hsl(from white h s l)') { + return true; + } + if (feature === 'color: color-mix(in srgb, white, black)') { + return false; + } + if (feature === 'color: light-dark(white, black)') { + return true; + } return false; - } - return false; - }); + }), + } as unknown as typeof CSS; }); afterAll(() => { - CSS.supports = originalCSSSupports; + // Restore original CSS or remove if it didn't exist + if (originalCSS) { + globalThis.CSS = originalCSS; + } else { + // @ts-expect-error - cleaning up mock + delete globalThis.CSS; + } }); beforeEach(() => { clearCache(); - vi.mocked(CSS.supports).mockClear(); + // Reset mock to default behavior + vi.mocked(globalThis.CSS.supports).mockImplementation((feature: string) => { + if (feature === 'color: hsl(from white h s l)') { + return true; + } + if (feature === 'color: color-mix(in srgb, white, black)') { + return false; + } + if (feature === 'color: light-dark(white, black)') { + return true; + } + return false; + }); }); describe('cssSupports', () => { @@ -39,10 +64,68 @@ describe('cssSupports', () => { expect(cssSupports.modernColor()).toBe(true); }); + test('lightDark should return true when supported', () => { + expect(cssSupports.lightDark()).toBe(true); + }); + test('caching works correctly', () => { + const initialCallCount = vi.mocked(globalThis.CSS.supports).mock.calls.length; cssSupports.relativeColorSyntax(); - expect(CSS.supports).toHaveBeenCalledTimes(1); + expect(globalThis.CSS.supports).toHaveBeenCalledTimes(initialCallCount + 1); cssSupports.relativeColorSyntax(); - expect(CSS.supports).toHaveBeenCalledTimes(1); // Should not call again due to caching + expect(globalThis.CSS.supports).toHaveBeenCalledTimes(initialCallCount + 1); // Should not call again due to caching + }); + + test('lightDark caching works correctly', () => { + const initialCallCount = vi.mocked(globalThis.CSS.supports).mock.calls.length; + cssSupports.lightDark(); + expect(globalThis.CSS.supports).toHaveBeenCalledTimes(initialCallCount + 1); + cssSupports.lightDark(); + expect(globalThis.CSS.supports).toHaveBeenCalledTimes(initialCallCount + 1); // Should not call again due to caching + }); +}); + +describe('lightDark utility', () => { + test('returns light-dark() when both lightDark and modernColor are supported', () => { + // In this test setup: lightDark=true, relativeColorSyntax=true (so modernColor=true) + const result = lightDark('#ffffff', '#000000'); + expect(result).toBe('light-dark(#ffffff, #000000)'); + }); + + test('returns light value when lightDark is not supported', () => { + // Override mock to return false for lightDark + vi.mocked(globalThis.CSS.supports).mockImplementation((feature: string) => { + if (feature === 'color: light-dark(white, black)') { + return false; + } + if (feature === 'color: hsl(from white h s l)') { + return true; + } + return false; + }); + clearCache(); + + const result = lightDark('#ffffff', '#000000'); + expect(result).toBe('#ffffff'); + }); + + test('returns light value when modernColor is not supported', () => { + // Override mock to return true for lightDark but false for modern color features + vi.mocked(globalThis.CSS.supports).mockImplementation((feature: string) => { + if (feature === 'color: light-dark(white, black)') { + return true; + } + // Both relativeColorSyntax and colorMix return false + return false; + }); + clearCache(); + + const result = lightDark('#ffffff', '#000000'); + expect(result).toBe('#ffffff'); + }); + + test('works with named colors', () => { + const result = lightDark('white', 'black'); + expect(result).toBe('light-dark(white, black)'); }); }); diff --git a/packages/ui/src/utils/cssSupports.ts b/packages/ui/src/utils/cssSupports.ts index cbc8fce494e..c4e96a9ea7f 100644 --- a/packages/ui/src/utils/cssSupports.ts +++ b/packages/ui/src/utils/cssSupports.ts @@ -1,11 +1,13 @@ const CSS_FEATURE_TESTS: Record = { relativeColorSyntax: 'color: hsl(from white h s l)', colorMix: 'color: color-mix(in srgb, white, black)', + lightDark: 'color: light-dark(white, black)', } as const; let SUPPORTS_RELATIVE_COLOR: boolean | undefined; let SUPPORTS_COLOR_MIX: boolean | undefined; let SUPPORTS_MODERN_COLOR: boolean | undefined; +let SUPPORTS_LIGHT_DARK: boolean | undefined; export const cssSupports = { relativeColorSyntax: () => { @@ -47,10 +49,26 @@ export const cssSupports = { return SUPPORTS_MODERN_COLOR; }, + /** + * Returns true if the light-dark() CSS function is supported + */ + lightDark: () => { + if (SUPPORTS_LIGHT_DARK !== undefined) { + return SUPPORTS_LIGHT_DARK; + } + try { + SUPPORTS_LIGHT_DARK = CSS.supports(CSS_FEATURE_TESTS.lightDark); + } catch { + SUPPORTS_LIGHT_DARK = false; + } + + return SUPPORTS_LIGHT_DARK; + }, }; export const clearCache = () => { SUPPORTS_RELATIVE_COLOR = undefined; SUPPORTS_COLOR_MIX = undefined; SUPPORTS_MODERN_COLOR = undefined; + SUPPORTS_LIGHT_DARK = undefined; }; diff --git a/packages/ui/src/utils/lightDark.ts b/packages/ui/src/utils/lightDark.ts new file mode 100644 index 00000000000..f811cdb40c8 --- /dev/null +++ b/packages/ui/src/utils/lightDark.ts @@ -0,0 +1,19 @@ +import { cssSupports } from './cssSupports'; + +/** + * Returns a light-dark() CSS function string when supported, + * otherwise returns the light value as a fallback. + * + * This ensures compatibility with the legacy color system which + * cannot parse light-dark() syntax. + * + * @param light - The color value for light mode + * @param dark - The color value for dark mode + * @returns CSS light-dark() function or the light value + */ +export function lightDark(light: string, dark: string): string { + if (cssSupports.lightDark() && cssSupports.modernColor()) { + return `light-dark(${light}, ${dark})`; + } + return light; +}