diff --git a/.changeset/chubby-parts-try.md b/.changeset/chubby-parts-try.md new file mode 100644 index 00000000000..16f486e81ff --- /dev/null +++ b/.changeset/chubby-parts-try.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +TODO diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index f58f41096dd..6244226c05a 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,10 +1,10 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "612kB" }, + { "path": "./dist/clerk.js", "maxSize": "613.2kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "110KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "110.6KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/src/ui/customizables/parseVariables.ts b/packages/clerk-js/src/ui/customizables/parseVariables.ts index 5afe51f7c11..fde1865c4e5 100644 --- a/packages/clerk-js/src/ui/customizables/parseVariables.ts +++ b/packages/clerk-js/src/ui/customizables/parseVariables.ts @@ -2,48 +2,47 @@ import type { Theme } from '@clerk/types'; import { spaceScaleKeys } from '../foundations/sizes'; import type { fontSizes, fontWeights } from '../foundations/typography'; -import { colorOptionToHslaAlphaScale, colorOptionToHslaLightnessScale } from '../utils/colorOptionToHslaScale'; import { colors } from '../utils/colors'; +import { colorOptionToThemedAlphaScale, colorOptionToThemedLightnessScale } from '../utils/colors/scales'; import { fromEntries } from '../utils/fromEntries'; import { removeUndefinedProps } from '../utils/removeUndefinedProps'; export const createColorScales = (theme: Theme) => { const variables = theme.variables || {}; - const primaryScale = colorOptionToHslaLightnessScale(variables.colorPrimary, 'primary'); - const primaryAlphaScale = colorOptionToHslaAlphaScale(primaryScale?.primary500, 'primaryAlpha'); - const dangerScale = colorOptionToHslaLightnessScale(variables.colorDanger, 'danger'); - const dangerAlphaScale = colorOptionToHslaAlphaScale(dangerScale?.danger500, 'dangerAlpha'); - const successScale = colorOptionToHslaLightnessScale(variables.colorSuccess, 'success'); - const successAlphaScale = colorOptionToHslaAlphaScale(successScale?.success500, 'successAlpha'); - const warningScale = colorOptionToHslaLightnessScale(variables.colorWarning, 'warning'); - const warningAlphaScale = colorOptionToHslaAlphaScale(warningScale?.warning500, 'warningAlpha'); + const dangerScale = colorOptionToThemedLightnessScale(variables.colorDanger, 'danger'); + const primaryScale = colorOptionToThemedLightnessScale(variables.colorPrimary, 'primary'); + const successScale = colorOptionToThemedLightnessScale(variables.colorSuccess, 'success'); + const warningScale = colorOptionToThemedLightnessScale(variables.colorWarning, 'warning'); + + const dangerAlphaScale = colorOptionToThemedAlphaScale(dangerScale?.danger500, 'dangerAlpha'); + const neutralAlphaScale = colorOptionToThemedAlphaScale(variables.colorNeutral, 'neutralAlpha'); + const primaryAlphaScale = colorOptionToThemedAlphaScale(primaryScale?.primary500, 'primaryAlpha'); + const successAlphaScale = colorOptionToThemedAlphaScale(successScale?.success500, 'successAlpha'); + const warningAlphaScale = colorOptionToThemedAlphaScale(warningScale?.warning500, 'warningAlpha'); return removeUndefinedProps({ - ...primaryScale, - ...primaryAlphaScale, ...dangerScale, - ...dangerAlphaScale, + ...primaryScale, ...successScale, - ...successAlphaScale, ...warningScale, + ...dangerAlphaScale, + ...neutralAlphaScale, + ...primaryAlphaScale, + ...successAlphaScale, ...warningAlphaScale, - ...colorOptionToHslaAlphaScale(variables.colorNeutral, 'neutralAlpha'), primaryHover: colors.adjustForLightness(primaryScale?.primary500), - colorTextOnPrimaryBackground: toHSLA(variables.colorTextOnPrimaryBackground), - colorText: toHSLA(variables.colorText), - colorTextSecondary: toHSLA(variables.colorTextSecondary) || colors.makeTransparent(variables.colorText, 0.35), - colorInputText: toHSLA(variables.colorInputText), - colorBackground: toHSLA(variables.colorBackground), - colorInputBackground: toHSLA(variables.colorInputBackground), - colorShimmer: toHSLA(variables.colorShimmer), + colorTextOnPrimaryBackground: colors.toHslaString(variables.colorTextOnPrimaryBackground), + colorText: colors.toHslaString(variables.colorText), + colorTextSecondary: + colors.toHslaString(variables.colorTextSecondary) || colors.makeTransparent(variables.colorText, 35), + colorInputText: colors.toHslaString(variables.colorInputText), + colorBackground: colors.toHslaString(variables.colorBackground), + colorInputBackground: colors.toHslaString(variables.colorInputBackground), + colorShimmer: colors.toHslaString(variables.colorShimmer), }); }; -export const toHSLA = (str: string | undefined) => { - return str ? colors.toHslaString(str) : undefined; -}; - export const createRadiiUnits = (theme: Theme) => { const { borderRadius } = theme.variables || {}; if (borderRadius === undefined) { diff --git a/packages/clerk-js/src/ui/foundations/colors.ts b/packages/clerk-js/src/ui/foundations/colors.ts index 2b949f0f2d3..4e97cfdb197 100644 --- a/packages/clerk-js/src/ui/foundations/colors.ts +++ b/packages/clerk-js/src/ui/foundations/colors.ts @@ -1,4 +1,4 @@ -import { colorOptionToHslaAlphaScale } from '../utils/colorOptionToHslaScale'; +import { colorOptionToThemedAlphaScale } from '../utils/colors/scales'; export const whiteAlpha = Object.freeze({ whiteAlpha25: 'hsla(0, 0%, 100%, 0.02)', @@ -65,7 +65,7 @@ export const colors = Object.freeze({ primary900: '#1B171C', primaryHover: '#3B3C45', //primary 500 adjusted for lightness // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...colorOptionToHslaAlphaScale('#2F3037', 'primaryAlpha')!, + ...colorOptionToThemedAlphaScale('#2F3037', 'primaryAlpha')!, danger50: '#FEF2F2', danger100: '#FEE5E5', danger200: '#FECACA', @@ -78,7 +78,7 @@ export const colors = Object.freeze({ danger900: '#7F1D1D', danger950: '#450A0A', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...colorOptionToHslaAlphaScale('#EF4444', 'dangerAlpha')!, + ...colorOptionToThemedAlphaScale('#EF4444', 'dangerAlpha')!, warning50: '#FFF6ED', warning100: '#FFEBD5', warning200: '#FED1AA', @@ -91,7 +91,7 @@ export const colors = Object.freeze({ warning900: '#7C2912', warning950: '#431207', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...colorOptionToHslaAlphaScale('#F36B16', 'warningAlpha')!, + ...colorOptionToThemedAlphaScale('#F36B16', 'warningAlpha')!, success50: '#F0FDF2', success100: '#DCFCE2', success200: '#BBF7C6', @@ -104,5 +104,5 @@ export const colors = Object.freeze({ success900: '#145323', success950: '#052E0F', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...colorOptionToHslaAlphaScale('#22C543', 'successAlpha')!, + ...colorOptionToThemedAlphaScale('#22C543', 'successAlpha')!, } as const); diff --git a/packages/clerk-js/src/ui/utils/__tests__/colors.test.ts b/packages/clerk-js/src/ui/utils/__tests__/colors.test.ts deleted file mode 100644 index 7762f9dc9cd..00000000000 --- a/packages/clerk-js/src/ui/utils/__tests__/colors.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { HslaColor } from '@clerk/types'; - -import { colors } from '../colors'; - -describe('colors.toHslaColor(color)', function () { - const hsla = { h: 195, s: 100, l: 50, a: 1 }; - const cases: Array<[string, any]> = [ - // ['', undefined], - // ['00bfff', hsla], - ['transparent', { h: 0, s: 0, l: 0, a: 0 }], - ['#00bfff', hsla], - ['rgb(0, 191, 255)', hsla], - ['rgba(0, 191, 255, 0.3)', { ...hsla, a: 0.3 }], - ['hsl(195, 100%, 50%)', hsla], - ['hsla(195, 100%, 50%, 1)', hsla], - ]; - - it.each(cases)('colors.toHslaColor(%s) => %s', (a, expected) => { - expect(colors.toHslaColor(a)).toEqual(expected); - }); -}); - -describe('colors.toHslaColor(color)', function () { - const cases: Array<[HslaColor, any]> = [ - [colors.toHslaColor('transparent'), `hsla(0, 0%, 0%, 0)`], - [colors.toHslaColor('#00bfff'), 'hsla(195, 100%, 50%, 1)'], - [colors.toHslaColor('rgb(0, 191, 255)'), 'hsla(195, 100%, 50%, 1)'], - [colors.toHslaColor('rgba(0, 191, 255, 0.3)'), 'hsla(195, 100%, 50%, 0.3)'], - [colors.toHslaColor('hsl(195, 100%, 50%)'), 'hsla(195, 100%, 50%, 1)'], - [colors.toHslaColor('hsla(195, 100%, 50%, 1)'), 'hsla(195, 100%, 50%, 1)'], - ]; - - it.each(cases)('colors.toHslaColor(%s) => %s', (a, expected) => { - expect(colors.toHslaString(a)).toEqual(expected); - }); -}); diff --git a/packages/clerk-js/src/ui/utils/__tests__/cssSupports.test.ts b/packages/clerk-js/src/ui/utils/__tests__/cssSupports.test.ts new file mode 100644 index 00000000000..cb4af74c05f --- /dev/null +++ b/packages/clerk-js/src/ui/utils/__tests__/cssSupports.test.ts @@ -0,0 +1,42 @@ +import { clearCache, cssSupports } from '../cssSupports'; + +// Mock CSS.supports +const originalCSSSupports = CSS.supports; + +beforeAll(() => { + CSS.supports = jest.fn(feature => { + if (feature === 'color: hsl(from white h s l)') return true; + if (feature === 'color: color-mix(in srgb, white, black)') return false; + return false; + }); +}); + +afterAll(() => { + CSS.supports = originalCSSSupports; +}); + +beforeEach(() => { + clearCache(); + (CSS.supports as jest.Mock).mockClear(); +}); + +describe('cssSupports', () => { + test('relativeColorSyntax should return true when supported', () => { + expect(cssSupports.relativeColorSyntax()).toBe(true); + }); + + test('colorMix should return false when not supported', () => { + expect(cssSupports.colorMix()).toBe(false); + }); + + test('modernColor should return true when at least one feature is supported', () => { + expect(cssSupports.modernColor()).toBe(true); + }); + + test('caching works correctly', () => { + cssSupports.relativeColorSyntax(); + expect(CSS.supports).toHaveBeenCalledTimes(1); + cssSupports.relativeColorSyntax(); + expect(CSS.supports).toHaveBeenCalledTimes(1); // Should not call again due to caching + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colorOptionToHslaScale.ts b/packages/clerk-js/src/ui/utils/colorOptionToHslaScale.ts deleted file mode 100644 index 7676ccbb389..00000000000 --- a/packages/clerk-js/src/ui/utils/colorOptionToHslaScale.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { ColorScale, CssColorOrAlphaScale, CssColorOrScale, HslaColor, HslaColorString } from '@clerk/types'; - -import { colors } from './colors'; -import { fromEntries } from './fromEntries'; - -type InternalColorScale = ColorScale & Partial>; - -const LIGHT_SHADES = ['25', '50', '100', '150', '200', '300', '400'].reverse(); -const DARK_SHADES = ['600', '700', '750', '800', '850', '900', '950']; - -const ALL_SHADES = [...[...LIGHT_SHADES].reverse(), '500', ...DARK_SHADES] as const; - -const TARGET_L_50_SHADE = 97; -const TARGET_L_900_SHADE = 12; - -function createEmptyColorScale(): InternalColorScale { - return { - '25': undefined, - '50': undefined, - '100': undefined, - '150': undefined, - '200': undefined, - '300': undefined, - '400': undefined, - '500': undefined, - '600': undefined, - '700': undefined, - '750': undefined, - '800': undefined, - '850': undefined, - '900': undefined, - '950': undefined, - }; -} - -type WithPrefix, Prefix extends string> = { - [k in keyof T as `${Prefix}${k & string}`]: T[k]; -}; - -export const colorOptionToHslaAlphaScale = ( - colorOption: CssColorOrAlphaScale | undefined, - prefix: Prefix, -): WithPrefix, Prefix> | undefined => { - return getUserProvidedScaleOrGenerateHslaColorsScale(colorOption, prefix, generateFilledAlphaScaleFromBaseHslaColor); -}; - -export const colorOptionToHslaLightnessScale = ( - colorOption: CssColorOrScale | undefined, - prefix: Prefix, -): WithPrefix, Prefix> | undefined => { - return fillUserProvidedScaleWithGeneratedHslaColors(colorOption, prefix, generateFilledScaleFromBaseHslaColor); -}; - -const getUserProvidedScaleOrGenerateHslaColorsScale = ( - colorOption: CssColorOrAlphaScale | undefined, - prefix: Prefix, - generator: (base: HslaColor) => InternalColorScale, -): WithPrefix, Prefix> | undefined => { - if (!colorOption) { - return undefined; - } - - if (typeof colorOption === 'object' && !ALL_SHADES.every(key => key in colorOption)) { - throw new Error('You need to provide all the following shades: ' + ALL_SHADES.join(', ')); - } - - if (typeof colorOption === 'object') { - const scale = Object.keys(colorOption).reduce((acc, key) => { - // @ts-expect-error - acc[key] = colors.toHslaColor(colorOption[key]); - return acc; - }, createEmptyColorScale()); - return prefixAndStringifyHslaScale(scale, prefix); - } - - const hslaColor = colors.toHslaColor(colorOption); - const filledHslaColorScale = generator(hslaColor); - return prefixAndStringifyHslaScale(filledHslaColorScale, prefix); -}; - -const fillUserProvidedScaleWithGeneratedHslaColors = ( - colorOption: CssColorOrScale | undefined, - prefix: Prefix, - generator: (base: HslaColor) => InternalColorScale, -): WithPrefix, Prefix> | undefined => { - if (!colorOption) { - return undefined; - } - - if (typeof colorOption === 'object' && !colorOption['500']) { - throw new Error('You need to provide at least the 500 shade'); - } - - const userDefinedHslaColorScale = userDefinedColorToHslaColorScale(colorOption); - const filledHslaColorScale = generator(userDefinedHslaColorScale['500']); - const merged = mergeFilledIntoUserDefinedScale(filledHslaColorScale, userDefinedHslaColorScale); - return prefixAndStringifyHslaScale(merged, prefix); -}; - -const mergeFilledIntoUserDefinedScale = ( - generated: InternalColorScale, - userDefined: InternalColorScale, -): InternalColorScale => { - // @ts-expect-error - return fromEntries(Object.entries(userDefined).map(([k, v]) => [k, v || generated[k]])); -}; - -const prefixAndStringifyHslaScale = ( - scale: InternalColorScale, - prefix: Prefix, -) => { - const res = {} as WithPrefix, Prefix>; - for (const key in scale) { - // @ts-expect-error - if (scale[key]) { - // @ts-expect-error - res[prefix + key] = colors.toHslaString(scale[key]); - } - } - return res; -}; - -const userDefinedColorToHslaColorScale = (colorOption: CssColorOrScale): InternalColorScale => { - const baseScale = typeof colorOption === 'string' ? { '500': colorOption } : colorOption; - const hslaScale = createEmptyColorScale(); - // @ts-expect-error - const entries = Object.keys(hslaScale).map(k => [k, baseScale[k] ? colors.toHslaColor(baseScale[k]) : undefined]); - return fromEntries(entries) as InternalColorScale; -}; - -/** - * This function generates a color scale using `base` as the 500 shade. - * The lightest shade (50) will always have a lightness of TARGET_L_50_SHADE, - * and the darkest shade (900) will always have a lightness of TARGET_L_900_SHADE. - * It calculates the required inc/decr lightness steps and applies them to base - */ -const generateFilledScaleFromBaseHslaColor = (base: HslaColor): InternalColorScale => { - const newScale = createEmptyColorScale(); - type Key = keyof typeof newScale; - newScale['500'] = base; - - const lightPercentage = (TARGET_L_50_SHADE - base.l) / LIGHT_SHADES.length; - const darkPercentage = (base.l - TARGET_L_900_SHADE) / DARK_SHADES.length; - - LIGHT_SHADES.forEach( - (shade, i) => (newScale[shade as any as Key] = colors.changeHslaLightness(base, (i + 1) * lightPercentage)), - ); - DARK_SHADES.map( - (shade, i) => (newScale[shade as any as Key] = colors.changeHslaLightness(base, (i + 1) * darkPercentage * -1)), - ); - return newScale as InternalColorScale; -}; - -const generateFilledAlphaScaleFromBaseHslaColor = (base: HslaColor): InternalColorScale => { - const newScale = createEmptyColorScale(); - const baseWithoutAlpha = colors.setHslaAlpha(base, 0); - const alphas = [0.02, 0.03, 0.07, 0.11, 0.15, 0.28, 0.41, 0.53, 0.62, 0.73, 0.78, 0.81, 0.84, 0.87, 0.92]; - // @ts-expect-error - Object.keys(newScale).forEach((k, i) => (newScale[k] = colors.setHslaAlpha(baseWithoutAlpha, alphas[i]))); - return newScale as InternalColorScale; -}; diff --git a/packages/clerk-js/src/ui/utils/colors/README.md b/packages/clerk-js/src/ui/utils/colors/README.md new file mode 100644 index 00000000000..027a7659e29 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/README.md @@ -0,0 +1,58 @@ +# Colors System + +This folder contains the color manipulation utilities for Clerk's UI components. The system automatically chooses between **legacy** and **modern** color handling based on browser support. + +## How It Works + +The color system uses a **progressive enhancement** approach: + +1. **Detect browser capabilities** - Check if the browser supports modern CSS color features +2. **Choose the right approach** - Use modern CSS when available, fall back to legacy methods +3. **Provide consistent API** - Same functions work regardless of which approach is used + +## Legacy vs Modern Approach + +### Legacy Color Handling (`legacy.ts`) + +- **When**: Used in older browsers that don't support modern CSS color features +- **How**: Converts colors to HSLA objects and manipulates values in JavaScript +- **Example**: `#ff0000` becomes `{ h: 0, s: 100, l: 50, a: 1 }` +- **Output**: Returns HSLA strings like `hsla(0, 100%, 50%, 1)` + +### Modern Color Handling (`modern.ts`) + +- **When**: Used in browsers that support `color-mix()` or relative color syntax +- **How**: Uses native CSS color functions in order to support CSS variables +- **Example**: `color-mix(in srgb, #ff0000 80%, white 20%)` for lightening +- **Output**: Returns modern CSS color strings + +## Key Features + +- **Automatic detection** - No need to manually choose legacy vs modern +- **Same API** - All functions work the same way regardless of browser +- **Fallback support** - Always works, even in older browsers + +## Main Functions + +```typescript +// Lighten a color by percentage +colors.lighten('#ff0000', 20); // Makes red 20% lighter + +// Make a color transparent +colors.makeTransparent('#ff0000', 50); // Makes red 50% transparent + +// Set specific opacity +colors.setAlpha('#ff0000', 0.5); // Sets red to 50% opacity + +// Adjust for better contrast +colors.adjustForLightness('#333333', 10); // Slightly lightens dark colors +``` + +## Browser Support Detection + +The system checks for these modern CSS features: + +- `color-mix()` function +- Relative color syntax (`hsl(from white h s l)`) + +If either is supported, modern handling is used. Otherwise, legacy handling kicks in. diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/constants.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/constants.spec.ts new file mode 100644 index 00000000000..3f7fc08a5a6 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/constants.spec.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest'; + +import { + ALL_SHADES, + ALPHA_PERCENTAGES, + ALPHA_VALUES, + COLOR_BOUNDS, + COLOR_SCALE, + DARK_SHADES, + LIGHT_SHADES, + LIGHTNESS_CONFIG, + LIGHTNESS_MIX_DATA, + MODERN_CSS_LIMITS, + RELATIVE_SHADE_STEPS, +} from '../constants'; + +describe('Color Constants', () => { + describe('COLOR_SCALE', () => { + it('should contain all expected color shades in order', () => { + expect(COLOR_SCALE).toEqual([25, 50, 100, 150, 200, 300, 400, 500, 600, 700, 750, 800, 850, 900, 950]); + }); + + it('should be readonly at compile time', () => { + // COLOR_SCALE is readonly via 'as const' but not frozen at runtime + // This is sufficient for immutability in TypeScript + expect(Array.isArray(COLOR_SCALE)).toBe(true); + }); + + it('should have correct length', () => { + expect(COLOR_SCALE).toHaveLength(15); + }); + }); + + describe('Shade groupings', () => { + it('should have correct light shades', () => { + expect(LIGHT_SHADES).toEqual(['400', '300', '200', '150', '100', '50', '25']); + }); + + it('should have correct dark shades', () => { + expect(DARK_SHADES).toEqual(['600', '700', '750', '800', '850', '900', '950']); + }); + + it('should have all shades including 500', () => { + expect(ALL_SHADES).toContain('500'); + expect(ALL_SHADES).toHaveLength(15); + }); + + it('should have all shades equal to light + dark + 500', () => { + const expected = [...LIGHT_SHADES, '500', ...DARK_SHADES]; + expect(ALL_SHADES).toEqual(expected); + }); + }); + + describe('LIGHTNESS_CONFIG', () => { + it('should have correct lightness configuration', () => { + expect(LIGHTNESS_CONFIG).toEqual({ + TARGET_LIGHT: 97, + TARGET_DARK: 12, + LIGHT_STEPS: 7, + DARK_STEPS: 7, + }); + }); + + it('should be readonly at compile time', () => { + // LIGHTNESS_CONFIG is readonly via 'as const' but not frozen at runtime + expect(typeof LIGHTNESS_CONFIG).toBe('object'); + }); + }); + + describe('ALPHA_VALUES', () => { + it('should have correct number of alpha values', () => { + expect(ALPHA_VALUES).toHaveLength(COLOR_SCALE.length); + }); + + it('should have all values between 0 and 1', () => { + ALPHA_VALUES.forEach(value => { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + }); + }); + + it('should be in ascending order', () => { + for (let i = 1; i < ALPHA_VALUES.length; i++) { + expect(ALPHA_VALUES[i]).toBeGreaterThan(ALPHA_VALUES[i - 1]); + } + }); + }); + + describe('ALPHA_PERCENTAGES', () => { + it('should have entries for all color shades', () => { + COLOR_SCALE.forEach(shade => { + expect(ALPHA_PERCENTAGES[shade]).toBeDefined(); + expect(typeof ALPHA_PERCENTAGES[shade]).toBe('number'); + }); + }); + + it('should have all percentages between 0 and 100', () => { + Object.values(ALPHA_PERCENTAGES).forEach(percentage => { + expect(percentage).toBeGreaterThanOrEqual(0); + expect(percentage).toBeLessThanOrEqual(100); + }); + }); + + it('should be in ascending order following COLOR_SCALE order', () => { + for (let i = 1; i < COLOR_SCALE.length; i++) { + const currentShade = COLOR_SCALE[i]; + const previousShade = COLOR_SCALE[i - 1]; + expect(ALPHA_PERCENTAGES[currentShade]).toBeGreaterThan(ALPHA_PERCENTAGES[previousShade]); + } + }); + + it('should be readonly at compile time', () => { + // ALPHA_PERCENTAGES is readonly via 'as const' but not frozen at runtime + expect(typeof ALPHA_PERCENTAGES).toBe('object'); + }); + }); + + describe('LIGHTNESS_MIX_DATA', () => { + it('should have entries for all color shades', () => { + COLOR_SCALE.forEach(shade => { + expect(LIGHTNESS_MIX_DATA[shade]).toBeDefined(); + expect(typeof LIGHTNESS_MIX_DATA[shade]).toBe('object'); + }); + }); + + it('should have correct structure for each shade', () => { + Object.entries(LIGHTNESS_MIX_DATA).forEach(([_shade, data]) => { + expect(data).toHaveProperty('mixColor'); + expect(data).toHaveProperty('percentage'); + expect(typeof data.percentage).toBe('number'); + + if (data.mixColor !== null) { + expect(['white', 'black']).toContain(data.mixColor); + } + }); + }); + + it('should have 500 shade with no mix color', () => { + expect(LIGHTNESS_MIX_DATA[500]).toEqual({ + mixColor: null, + percentage: 0, + }); + }); + + it('should have light shades mixing with white', () => { + LIGHT_SHADES.forEach(shade => { + const numShade = parseInt(shade) as keyof typeof LIGHTNESS_MIX_DATA; + expect(LIGHTNESS_MIX_DATA[numShade].mixColor).toBe('white'); + }); + }); + + it('should have dark shades mixing with black', () => { + DARK_SHADES.forEach(shade => { + const numShade = parseInt(shade) as keyof typeof LIGHTNESS_MIX_DATA; + expect(LIGHTNESS_MIX_DATA[numShade].mixColor).toBe('black'); + }); + }); + + it('should be readonly at compile time', () => { + // LIGHTNESS_MIX_DATA is readonly via 'as const' but not frozen at runtime + expect(typeof LIGHTNESS_MIX_DATA).toBe('object'); + }); + }); + + describe('RELATIVE_SHADE_STEPS', () => { + it('should have correct step values', () => { + // Light shades should have steps 1-7 + expect(RELATIVE_SHADE_STEPS[400]).toBe(1); + expect(RELATIVE_SHADE_STEPS[300]).toBe(2); + expect(RELATIVE_SHADE_STEPS[200]).toBe(3); + expect(RELATIVE_SHADE_STEPS[150]).toBe(4); + expect(RELATIVE_SHADE_STEPS[100]).toBe(5); + expect(RELATIVE_SHADE_STEPS[50]).toBe(6); + expect(RELATIVE_SHADE_STEPS[25]).toBe(7); + + // Dark shades should have steps 1-7 + expect(RELATIVE_SHADE_STEPS[600]).toBe(1); + expect(RELATIVE_SHADE_STEPS[700]).toBe(2); + expect(RELATIVE_SHADE_STEPS[750]).toBe(3); + expect(RELATIVE_SHADE_STEPS[800]).toBe(4); + expect(RELATIVE_SHADE_STEPS[850]).toBe(5); + expect(RELATIVE_SHADE_STEPS[900]).toBe(6); + expect(RELATIVE_SHADE_STEPS[950]).toBe(7); + }); + + it('should not have a step for 500 shade', () => { + expect(RELATIVE_SHADE_STEPS[500]).toBeUndefined(); + }); + + it('should be readonly at compile time', () => { + // RELATIVE_SHADE_STEPS is readonly via 'as const' but not frozen at runtime + expect(typeof RELATIVE_SHADE_STEPS).toBe('object'); + }); + }); + + describe('COLOR_BOUNDS', () => { + it('should have correct RGB bounds', () => { + expect(COLOR_BOUNDS.rgb).toEqual({ min: 0, max: 255 }); + }); + + it('should have correct alpha bounds', () => { + expect(COLOR_BOUNDS.alpha).toEqual({ min: 0, max: 1 }); + }); + + it('should have correct hue bounds', () => { + expect(COLOR_BOUNDS.hue).toEqual({ min: 0, max: 360 }); + }); + + it('should have correct percentage bounds', () => { + expect(COLOR_BOUNDS.percentage).toEqual({ min: 0, max: 100 }); + }); + + it('should be readonly at compile time', () => { + // COLOR_BOUNDS is readonly via 'as const' but not frozen at runtime + expect(typeof COLOR_BOUNDS).toBe('object'); + }); + }); + + describe('MODERN_CSS_LIMITS', () => { + it('should have all required limits', () => { + expect(MODERN_CSS_LIMITS).toHaveProperty('MAX_LIGHTNESS_MIX'); + expect(MODERN_CSS_LIMITS).toHaveProperty('MIN_ALPHA_PERCENTAGE'); + expect(MODERN_CSS_LIMITS).toHaveProperty('MAX_LIGHTNESS_ADJUSTMENT'); + expect(MODERN_CSS_LIMITS).toHaveProperty('MIN_LIGHTNESS_FLOOR'); + expect(MODERN_CSS_LIMITS).toHaveProperty('LIGHTNESS_MULTIPLIER'); + expect(MODERN_CSS_LIMITS).toHaveProperty('MIX_MULTIPLIER'); + }); + + it('should have reasonable limit values', () => { + expect(MODERN_CSS_LIMITS.MAX_LIGHTNESS_MIX).toBeGreaterThan(0); + expect(MODERN_CSS_LIMITS.MAX_LIGHTNESS_MIX).toBeLessThanOrEqual(100); + + expect(MODERN_CSS_LIMITS.MIN_ALPHA_PERCENTAGE).toBeGreaterThan(0); + expect(MODERN_CSS_LIMITS.MIN_ALPHA_PERCENTAGE).toBeLessThanOrEqual(100); + + expect(MODERN_CSS_LIMITS.LIGHTNESS_MULTIPLIER).toBeGreaterThan(0); + expect(MODERN_CSS_LIMITS.MIX_MULTIPLIER).toBeGreaterThan(0); + }); + + it('should be readonly at compile time', () => { + // MODERN_CSS_LIMITS is readonly via 'as const' but not frozen at runtime + expect(typeof MODERN_CSS_LIMITS).toBe('object'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/index.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/index.spec.ts new file mode 100644 index 00000000000..415400ddf7c --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/index.spec.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock cssSupports +vi.mock('../../cssSupports', () => ({ + cssSupports: { + modernColor: vi.fn(), + }, +})); + +vi.mock('../legacy', () => ({ + colors: { + toHslaColor: vi.fn(), + toHslaString: vi.fn(), + changeHslaLightness: vi.fn(), + setHslaAlpha: vi.fn(), + lighten: vi.fn(), + makeTransparent: vi.fn(), + makeSolid: vi.fn(), + setAlpha: vi.fn(), + adjustForLightness: vi.fn(), + }, +})); + +vi.mock('../modern', () => ({ + colors: { + lighten: vi.fn(), + makeTransparent: vi.fn(), + makeSolid: vi.fn(), + setAlpha: vi.fn(), + adjustForLightness: vi.fn(), + }, +})); + +import { cssSupports } from '../../cssSupports'; +import { colors, legacyColors, modernColors } from '../index'; + +// Get the mocked functions +const mockModernColorSupport = vi.mocked(cssSupports.modernColor); +const mockLegacyColors = vi.mocked(legacyColors); +const mockModernColors = vi.mocked(modernColors); + +describe('Colors Index', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockModernColorSupport.mockReturnValue(false); + }); + + describe('modernColors and legacyColors exports', () => { + it('should export modernColors', () => { + expect(modernColors).toBeDefined(); + }); + + it('should export legacyColors', () => { + expect(legacyColors).toBeDefined(); + }); + }); + + describe('toHslaColor', () => { + it('should return undefined for undefined input', () => { + expect(colors.toHslaColor(undefined)).toBeUndefined(); + }); + + it('should return color string when modern CSS is supported', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = colors.toHslaColor('red'); + expect(result).toBe('red'); + }); + + it('should call legacy toHslaColor when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.toHslaColor.mockReturnValue({ h: 0, s: 100, l: 50, a: 1 }); + + colors.toHslaColor('red'); + expect(mockLegacyColors.toHslaColor).toHaveBeenCalledWith('red'); + }); + }); + + describe('toHslaString', () => { + it('should return undefined for undefined input', () => { + expect(colors.toHslaString(undefined)).toBeUndefined(); + }); + + it('should return color string when modern CSS is supported and input is string', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = colors.toHslaString('red'); + expect(result).toBe('red'); + }); + + it('should call legacy toHslaString when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.toHslaString.mockReturnValue('hsla(0, 100%, 50%, 1)'); + + const hslaColor = { h: 0, s: 100, l: 50, a: 1 }; + colors.toHslaString(hslaColor); + expect(mockLegacyColors.toHslaString).toHaveBeenCalledWith(hslaColor); + }); + + it('should call legacy toHslaString for string input when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.toHslaString.mockReturnValue('hsla(0, 100%, 50%, 1)'); + + colors.toHslaString('red'); + expect(mockLegacyColors.toHslaString).toHaveBeenCalledWith('red'); + }); + }); + + describe('changeHslaLightness', () => { + it('should always use legacy implementation', () => { + const hslaColor = { h: 0, s: 100, l: 50, a: 1 }; + const lightness = 10; + + colors.changeHslaLightness(hslaColor, lightness); + expect(mockLegacyColors.changeHslaLightness).toHaveBeenCalledWith(hslaColor, lightness); + }); + }); + + describe('setHslaAlpha', () => { + it('should always use legacy implementation', () => { + const hslaColor = { h: 0, s: 100, l: 50, a: 1 }; + const alpha = 0.5; + + colors.setHslaAlpha(hslaColor, alpha); + expect(mockLegacyColors.setHslaAlpha).toHaveBeenCalledWith(hslaColor, alpha); + }); + }); + + describe('lighten', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.lighten.mockReturnValue('lightened-color'); + + const result = colors.lighten('red', 0.1); + expect(mockModernColors.lighten).toHaveBeenCalledWith('red', 0.1); + expect(result).toBe('lightened-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.lighten.mockReturnValue('legacy-lightened-color'); + + const result = colors.lighten('red', 0.1); + expect(mockLegacyColors.lighten).toHaveBeenCalledWith('red', 0.1); + expect(result).toBe('legacy-lightened-color'); + }); + + it('should handle default percentage', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.lighten.mockReturnValue('lightened-color'); + + colors.lighten('red'); + expect(mockModernColors.lighten).toHaveBeenCalledWith('red', 0); + }); + }); + + describe('makeTransparent', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.makeTransparent.mockReturnValue('transparent-color'); + + const result = colors.makeTransparent('red', 0.5); + expect(mockModernColors.makeTransparent).toHaveBeenCalledWith('red', 0.5); + expect(result).toBe('transparent-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.makeTransparent.mockReturnValue('legacy-transparent-color'); + + const result = colors.makeTransparent('red', 0.5); + expect(mockLegacyColors.makeTransparent).toHaveBeenCalledWith('red', 0.5); + expect(result).toBe('legacy-transparent-color'); + }); + }); + + describe('makeSolid', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.makeSolid.mockReturnValue('solid-color'); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(mockModernColors.makeSolid).toHaveBeenCalledWith('rgba(255, 0, 0, 0.5)'); + expect(result).toBe('solid-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.makeSolid.mockReturnValue('legacy-solid-color'); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(mockLegacyColors.makeSolid).toHaveBeenCalledWith('rgba(255, 0, 0, 0.5)'); + expect(result).toBe('legacy-solid-color'); + }); + }); + + describe('setAlpha', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.setAlpha.mockReturnValue('alpha-color'); + + const result = colors.setAlpha('red', 0.5); + expect(mockModernColors.setAlpha).toHaveBeenCalledWith('red', 0.5); + expect(result).toBe('alpha-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.setAlpha.mockReturnValue('legacy-alpha-color'); + + const result = colors.setAlpha('red', 0.5); + expect(mockLegacyColors.setAlpha).toHaveBeenCalledWith('red', 0.5); + expect(result).toBe('legacy-alpha-color'); + }); + }); + + describe('adjustForLightness', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.adjustForLightness.mockReturnValue('adjusted-color'); + + const result = colors.adjustForLightness('red', 5); + expect(mockModernColors.adjustForLightness).toHaveBeenCalledWith('red', 5); + expect(result).toBe('adjusted-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.adjustForLightness.mockReturnValue('legacy-adjusted-color'); + + const result = colors.adjustForLightness('red', 5); + expect(mockLegacyColors.adjustForLightness).toHaveBeenCalledWith('red', 5); + expect(result).toBe('legacy-adjusted-color'); + }); + + it('should handle default lightness value', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.adjustForLightness.mockReturnValue('adjusted-color'); + + colors.adjustForLightness('red'); + expect(mockModernColors.adjustForLightness).toHaveBeenCalledWith('red', 5); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/legacy.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/legacy.spec.ts new file mode 100644 index 00000000000..4ebec389ac0 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/legacy.spec.ts @@ -0,0 +1,440 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { colors } from '../legacy'; + +describe('Legacy Colors', () => { + describe('toHslaColor', () => { + describe('RGB and RGBA inputs', () => { + it('should parse hex colors without alpha', () => { + const result = colors.toHslaColor('#ff0000'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse hex colors with alpha', () => { + const result = colors.toHslaColor('#ff000080'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5019607843137255 }); + }); + + it('should parse 3-digit hex colors', () => { + const result = colors.toHslaColor('#f00'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse 4-digit hex colors with alpha', () => { + const result = colors.toHslaColor('#f008'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5333333333333333 }); + }); + + it('should parse rgb() colors', () => { + const result = colors.toHslaColor('rgb(255, 0, 0)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse rgba() colors', () => { + const result = colors.toHslaColor('rgba(255, 0, 0, 0.5)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should parse rgb() colors with percentages', () => { + const result = colors.toHslaColor('rgb(100%, 0%, 0%)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse rgba() colors with percentage alpha', () => { + const result = colors.toHslaColor('rgba(255, 0, 0, 50%)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should parse different RGB colors correctly', () => { + const blue = colors.toHslaColor('#0000ff'); + expect(blue).toEqual({ h: 240, s: 100, l: 50, a: 1 }); + + const green = colors.toHslaColor('#00ff00'); + expect(green).toEqual({ h: 120, s: 100, l: 50, a: 1 }); + + const yellow = colors.toHslaColor('#ffff00'); + expect(yellow).toEqual({ h: 60, s: 100, l: 50, a: 1 }); + }); + }); + + describe('HSL and HSLA inputs', () => { + it('should parse hsl() colors', () => { + const result = colors.toHslaColor('hsl(0, 100%, 50%)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse hsla() colors', () => { + const result = colors.toHslaColor('hsla(0, 100%, 50%, 0.5)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should parse hsl() colors with deg unit', () => { + const result = colors.toHslaColor('hsl(180deg, 50%, 25%)'); + expect(result).toEqual({ h: 180, s: 50, l: 25, a: 1 }); + }); + + it('should handle hue values over 360', () => { + const result = colors.toHslaColor('hsl(450, 100%, 50%)'); + expect(result).toEqual({ h: 90, s: 100, l: 50, a: 1 }); + }); + + it('should handle negative hue values', () => { + const result = colors.toHslaColor('hsl(-90, 100%, 50%)'); + expect(result).toEqual({ h: 270, s: 100, l: 50, a: 1 }); + }); + }); + + describe('HWB inputs', () => { + it('should parse hwb() colors', () => { + const result = colors.toHslaColor('hwb(0, 0%, 0%)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse hwb() colors with alpha', () => { + const result = colors.toHslaColor('hwb(0, 0%, 0%, 0.5)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should handle hwb colors with high whiteness and blackness', () => { + const result = colors.toHslaColor('hwb(0, 50%, 50%)'); + expect(result.h).toBe(0); + expect(result.a).toBe(1); + }); + }); + + describe('CSS keyword inputs', () => { + it('should parse named colors', () => { + expect(colors.toHslaColor('red')).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + expect(colors.toHslaColor('blue')).toEqual({ h: 240, s: 100, l: 50, a: 1 }); + expect(colors.toHslaColor('green')).toEqual({ h: 120, s: 100, l: 25, a: 1 }); + expect(colors.toHslaColor('white')).toEqual({ h: 0, s: 0, l: 100, a: 1 }); + expect(colors.toHslaColor('black')).toEqual({ h: 0, s: 0, l: 0, a: 1 }); + expect(colors.toHslaColor('transparent')).toEqual({ h: 0, s: 0, l: 0, a: 0 }); + }); + + it('should handle gray and grey equivalents', () => { + const gray = colors.toHslaColor('gray'); + const grey = colors.toHslaColor('grey'); + expect(gray).toEqual(grey); + expect(gray).toEqual({ h: 0, s: 0, l: 50, a: 1 }); + }); + }); + + describe('CSS variable inputs', () => { + // Mock DOM environment for testing CSS variables + const mockWindow = { + getComputedStyle: vi.fn(), + }; + + beforeEach(() => { + // @ts-ignore + global.window = mockWindow; + // @ts-ignore + global.document = { documentElement: {} }; + }); + + afterEach(() => { + vi.clearAllMocks(); + // @ts-ignore + global.window = undefined; + // @ts-ignore + global.document = undefined; + }); + + it('should resolve CSS variables with hex values', () => { + mockWindow.getComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('#ff0000'), + }); + + const result = colors.toHslaColor('var(--brand)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should resolve CSS variables with rgb values', () => { + mockWindow.getComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(255, 0, 0)'), + }); + + const result = colors.toHslaColor('var(--primary-color)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should resolve CSS variables with hsl values', () => { + mockWindow.getComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('hsl(240, 100%, 50%)'), + }); + + const result = colors.toHslaColor('var(--accent)'); + expect(result).toEqual({ h: 240, s: 100, l: 50, a: 1 }); + }); + + it('should use fallback value when CSS variable is not defined', () => { + mockWindow.getComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue(''), + }); + + const result = colors.toHslaColor('var(--undefined-var, #00ff00)'); + expect(result).toEqual({ h: 120, s: 100, l: 50, a: 1 }); + }); + + it('should handle CSS variables with spaces', () => { + mockWindow.getComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('hsl(180, 50%, 50%)'), + }); + + const result = colors.toHslaColor('var( --spaced-var )'); + expect(result).toEqual({ h: 180, s: 50, l: 50, a: 1 }); + }); + + it('should throw error when CSS variable cannot be resolved and no fallback', () => { + mockWindow.getComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue(''), + }); + + expect(() => colors.toHslaColor('var(--undefined-var)')).toThrow(); + }); + + it('should work in server environment without window', () => { + // @ts-ignore + global.window = undefined; + + expect(() => colors.toHslaColor('var(--brand, red)')).not.toThrow(); + const result = colors.toHslaColor('var(--brand, red)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + }); + + describe('error cases', () => { + it('should throw error for invalid color strings', () => { + expect(() => colors.toHslaColor('invalid')).toThrow(); + expect(() => colors.toHslaColor('')).toThrow(); + expect(() => colors.toHslaColor('not-a-color')).toThrow(); + }); + + it('should throw error with helpful message', () => { + expect(() => colors.toHslaColor('invalid')).toThrow(/cannot be used as a color within 'variables'/); + }); + }); + }); + + describe('toHslaString', () => { + it('should convert HslaColor object to string', () => { + const hsla = { h: 0, s: 100, l: 50, a: 1 }; + const result = colors.toHslaString(hsla); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should convert HslaColor object with alpha to string', () => { + const hsla = { h: 120, s: 50, l: 25, a: 0.8 }; + const result = colors.toHslaString(hsla); + expect(result).toBe('hsla(120, 50%, 25%, 0.8)'); + }); + + it('should convert color string to hsla string', () => { + const result = colors.toHslaString('#ff0000'); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should handle undefined alpha', () => { + const hsla = { h: 0, s: 100, l: 50, a: undefined }; + const result = colors.toHslaString(hsla); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + }); + + describe('changeHslaLightness', () => { + it('should increase lightness', () => { + const hsla = { h: 0, s: 100, l: 50, a: 1 }; + const result = colors.changeHslaLightness(hsla, 10); + expect(result).toEqual({ h: 0, s: 100, l: 60, a: 1 }); + }); + + it('should decrease lightness', () => { + const hsla = { h: 0, s: 100, l: 50, a: 1 }; + const result = colors.changeHslaLightness(hsla, -10); + expect(result).toEqual({ h: 0, s: 100, l: 40, a: 1 }); + }); + + it('should preserve other properties', () => { + const hsla = { h: 240, s: 75, l: 30, a: 0.8 }; + const result = colors.changeHslaLightness(hsla, 20); + expect(result).toEqual({ h: 240, s: 75, l: 50, a: 0.8 }); + }); + }); + + describe('setHslaAlpha', () => { + it('should set alpha value', () => { + const hsla = { h: 0, s: 100, l: 50, a: 1 }; + const result = colors.setHslaAlpha(hsla, 0.5); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should preserve other properties', () => { + const hsla = { h: 240, s: 75, l: 30, a: 0.8 }; + const result = colors.setHslaAlpha(hsla, 0.2); + expect(result).toEqual({ h: 240, s: 75, l: 30, a: 0.2 }); + }); + }); + + describe('lighten', () => { + it('should return undefined for undefined color', () => { + expect(colors.lighten(undefined)).toBeUndefined(); + }); + + it('should lighten color by percentage', () => { + const result = colors.lighten('hsl(0, 100%, 50%)', 0.2); + expect(result).toBe('hsla(0, 100%, 60%, 1)'); + }); + + it('should handle zero percentage', () => { + const result = colors.lighten('hsl(0, 100%, 50%)', 0); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should handle different color formats', () => { + const result = colors.lighten('#ff0000', 0.1); + expect(result).toBe('hsla(0, 100%, 55%, 1)'); + }); + }); + + describe('makeSolid', () => { + it('should return undefined for undefined color', () => { + expect(colors.makeSolid(undefined)).toBeUndefined(); + }); + + it('should make transparent color solid', () => { + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should keep solid color solid', () => { + const result = colors.makeSolid('rgb(255, 0, 0)'); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + }); + + describe('makeTransparent', () => { + it('should return undefined for undefined color', () => { + expect(colors.makeTransparent(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(colors.makeTransparent('')).toBeUndefined(); + }); + + it('should make color transparent by percentage', () => { + const result = colors.makeTransparent('rgb(255, 0, 0)', 0.5); + expect(result).toBe('hsla(0, 100%, 50%, 0.5)'); + }); + + it('should handle zero percentage', () => { + const result = colors.makeTransparent('rgb(255, 0, 0)', 0); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should handle already transparent colors', () => { + const result = colors.makeTransparent('rgba(255, 0, 0, 0.8)', 0.5); + expect(result).toBe('hsla(0, 100%, 50%, 0.4)'); + }); + }); + + describe('setAlpha', () => { + it('should set alpha value', () => { + const result = colors.setAlpha('rgb(255, 0, 0)', 0.5); + expect(result).toBe('hsla(0, 100%, 50%, 0.5)'); + }); + + it('should handle empty string', () => { + const result = colors.setAlpha('', 0.5); + expect(result).toBe(''); + }); + + it('should replace existing alpha', () => { + const result = colors.setAlpha('rgba(255, 0, 0, 0.8)', 0.3); + expect(result).toBe('hsla(0, 100%, 50%, 0.3)'); + }); + }); + + describe('adjustForLightness', () => { + it('should return undefined for undefined color', () => { + expect(colors.adjustForLightness(undefined)).toBeUndefined(); + }); + + it('should adjust lightness with default value', () => { + const result = colors.adjustForLightness('hsl(0, 100%, 50%)'); + expect(result).toBe('hsla(0, 100%, 60%, 1)'); + }); + + it('should adjust lightness with custom value', () => { + const result = colors.adjustForLightness('hsl(0, 100%, 50%)', 10); + expect(result).toBe('hsla(0, 100%, 70%, 1)'); + }); + + it('should handle maximum lightness', () => { + const result = colors.adjustForLightness('hsl(0, 100%, 100%)', 5); + expect(result).toBe('hsla(0, 100%, 95%, 1)'); + }); + + it('should cap lightness at 100%', () => { + const result = colors.adjustForLightness('hsl(0, 100%, 90%)', 10); + expect(result).toBe('hsla(0, 100%, 100%, 1)'); + }); + + it('should handle different color formats', () => { + const result = colors.adjustForLightness('#ff0000', 5); + expect(result).toBe('hsla(0, 100%, 60%, 1)'); + }); + }); + + describe('edge cases and clamping', () => { + it('should clamp RGB values to valid range', () => { + const result = colors.toHslaColor('rgb(300, -50, 500)'); + expect(result.h).toBeGreaterThanOrEqual(0); + expect(result.s).toBeGreaterThanOrEqual(0); + expect(result.l).toBeGreaterThanOrEqual(0); + expect(result.a).toBe(1); + }); + + it('should clamp alpha values to valid range', () => { + const result = colors.toHslaColor('rgba(255, 0, 0, 2)'); + expect(result.a).toBe(1); + }); + + it('should throw error for whitespace in color strings', () => { + expect(() => colors.toHslaColor(' rgb(255, 0, 0) ')).toThrow(); + }); + + it('should throw error for uppercase RGB', () => { + expect(() => colors.toHslaColor('RGB(255, 0, 0)')).toThrow(); + }); + }); + + describe('complex color conversions', () => { + it('should handle grayscale colors correctly', () => { + const white = colors.toHslaColor('#ffffff'); + expect(white).toEqual({ h: 0, s: 0, l: 100, a: 1 }); + + const black = colors.toHslaColor('#000000'); + expect(black).toEqual({ h: 0, s: 0, l: 0, a: 1 }); + + const gray = colors.toHslaColor('#808080'); + expect(gray.s).toBe(0); + expect(gray.l).toBe(50); + }); + + it('should handle bright colors correctly', () => { + const cyan = colors.toHslaColor('#00ffff'); + expect(cyan).toEqual({ h: 180, s: 100, l: 50, a: 1 }); + + const magenta = colors.toHslaColor('#ff00ff'); + expect(magenta).toEqual({ h: 300, s: 100, l: 50, a: 1 }); + }); + + it('should handle dark colors correctly', () => { + const darkRed = colors.toHslaColor('#800000'); + expect(darkRed.h).toBe(0); + expect(darkRed.s).toBe(100); + expect(darkRed.l).toBe(25); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/modern.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/modern.spec.ts new file mode 100644 index 00000000000..7b780551637 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/modern.spec.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { cssSupports } from '../../cssSupports'; +import { colors } from '../modern'; + +// Mock cssSupports +vi.mock('../../cssSupports', () => ({ + cssSupports: { + relativeColorSyntax: vi.fn(), + colorMix: vi.fn(), + }, +})); + +// Get the mocked functions +const mockRelativeColorSyntax = vi.mocked(cssSupports.relativeColorSyntax); +const mockColorMix = vi.mocked(cssSupports.colorMix); + +describe('Modern CSS Colors', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRelativeColorSyntax.mockReturnValue(true); + mockColorMix.mockReturnValue(true); + }); + + describe('lighten', () => { + it('should return undefined for undefined color', () => { + expect(colors.lighten(undefined)).toBeUndefined(); + }); + + it('should use relative color syntax when supported', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = colors.lighten('red', 0.1); + expect(result).toMatch(/hsl\(from red h s calc\(l \+ 10%\)\)/); + }); + + it('should fall back to color-mix when relative color syntax not supported', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = colors.lighten('red', 0.1); + expect(result).toMatch(/color-mix\(in srgb, red, white 10%\)/); + }); + + it('should return original color when no modern CSS support', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + const result = colors.lighten('red', 0.1); + expect(result).toBe('red'); + }); + + it('should handle zero percentage', () => { + const result = colors.lighten('blue', 0); + expect(result).toMatch(/hsl\(from blue h s calc\(l \+ 0%\)\)/); + }); + + it('should limit color-mix percentage to maximum', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = colors.lighten('red', 2); // Very high percentage + expect(result).toMatch(/color-mix\(in srgb, red, white 95%\)/); // Should be capped + }); + }); + + describe('makeTransparent', () => { + it('should return undefined for undefined color', () => { + expect(colors.makeTransparent(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(colors.makeTransparent('')).toBeUndefined(); + }); + + it('should use color-mix when supported', () => { + mockColorMix.mockReturnValue(true); + + const result = colors.makeTransparent('red', 0.5); + expect(result).toMatch(/color-mix\(in srgb, transparent, red 50%\)/); + }); + + it('should return original color when color-mix not supported', () => { + mockColorMix.mockReturnValue(false); + + const result = colors.makeTransparent('red', 0.5); + expect(result).toBe('red'); + }); + + it('should handle zero transparency', () => { + const result = colors.makeTransparent('blue', 0); + expect(result).toMatch(/color-mix\(in srgb, transparent, blue 100%\)/); + }); + + it('should enforce minimum alpha percentage', () => { + const result = colors.makeTransparent('red', 0.99); // Very transparent + expect(result).toMatch(/color-mix\(in srgb, transparent, red 5%\)/); // Should be minimum + }); + }); + + describe('makeSolid', () => { + it('should return undefined for undefined color', () => { + expect(colors.makeSolid(undefined)).toBeUndefined(); + }); + + it('should use relative color syntax when supported', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(result).toMatch(/hsl\(from rgba\(255, 0, 0, 0\.5\) h s l \/ 1\)/); + }); + + it('should fall back to color-mix when relative color syntax not supported', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(result).toMatch(/color-mix\(in srgb, rgba\(255, 0, 0, 0\.5\), rgba\(255, 0, 0, 0\.5\) 100%\)/); + }); + + it('should return original color when no modern CSS support', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(result).toBe('rgba(255, 0, 0, 0.5)'); + }); + }); + + describe('setAlpha', () => { + it('should use relative color syntax when supported', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = colors.setAlpha('red', 0.5); + expect(result).toMatch(/hsl\(from red h s l \/ 0\.5\)/); + }); + + it('should fall back to color-mix when relative color syntax not supported', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = colors.setAlpha('red', 0.5); + expect(result).toMatch(/color-mix\(in srgb, transparent, red 50%\)/); + }); + + it('should return original color when no modern CSS support', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + const result = colors.setAlpha('red', 0.5); + expect(result).toBe('red'); + }); + + it('should clamp alpha values to valid range', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const resultLow = colors.setAlpha('red', -0.5); + const resultHigh = colors.setAlpha('red', 1.5); + + expect(resultLow).toMatch(/hsl\(from red h s l \/ 0\)/); + expect(resultHigh).toMatch(/hsl\(from red h s l \/ 1\)/); + }); + + it('should handle boundary alpha values', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const resultZero = colors.setAlpha('red', 0); + const resultOne = colors.setAlpha('red', 1); + + expect(resultZero).toMatch(/hsl\(from red h s l \/ 0\)/); + expect(resultOne).toMatch(/hsl\(from red h s l \/ 1\)/); + }); + }); + + describe('adjustForLightness', () => { + it('should return undefined for undefined color', () => { + expect(colors.adjustForLightness(undefined)).toBeUndefined(); + }); + + it('should use color-mix when supported', () => { + mockColorMix.mockReturnValue(true); + + const result = colors.adjustForLightness('red', 5); + expect(result).toMatch(/color-mix\(in srgb, red, white 20%\)/); + }); + + it('should return original color when no modern CSS support', () => { + mockColorMix.mockReturnValue(false); + mockRelativeColorSyntax.mockReturnValue(false); + + const result = colors.adjustForLightness('red', 5); + expect(result).toBe('red'); + }); + + it('should handle default lightness value', () => { + mockColorMix.mockReturnValue(true); + + const result = colors.adjustForLightness('red'); + expect(result).toMatch(/color-mix\(in srgb, red, white 20%\)/); + }); + + it('should limit color-mix percentage', () => { + mockColorMix.mockReturnValue(true); + + const result = colors.adjustForLightness('red', 20); // High value + expect(result).toMatch(/color-mix\(in srgb, red, white 30%\)/); // Should be limited + }); + }); + + describe('CSS support detection', () => { + it('should handle missing CSS support gracefully', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + expect(colors.lighten('red', 0.1)).toBe('red'); + expect(colors.makeTransparent('red', 0.5)).toBe('red'); + expect(colors.makeSolid('red')).toBe('red'); + expect(colors.setAlpha('red', 0.5)).toBe('red'); + expect(colors.adjustForLightness('red', 5)).toBe('red'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/scales.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/scales.spec.ts new file mode 100644 index 00000000000..30df8264816 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/scales.spec.ts @@ -0,0 +1,360 @@ +import type { ColorScale } from '@clerk/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { cssSupports } from '../../cssSupports'; +import { + colorOptionToThemedAlphaScale, + colorOptionToThemedLightnessScale, + generateAlphaScale, + generateLightnessScale, + legacyScales, + modernScales, +} from '../scales'; + +// Mock cssSupports +vi.mock('../../cssSupports', () => ({ + cssSupports: { + modernColor: vi.fn(), + relativeColorSyntax: vi.fn(), + }, +})); + +// Get the mocked functions +const mockModernColorSupport = vi.mocked(cssSupports.modernColor); +const mockRelativeColorSyntax = vi.mocked(cssSupports.relativeColorSyntax); + +vi.mock('../index', () => ({ + colors: { + toHslaColor: (_color: string) => ({ h: 0, s: 50, l: 50, a: 1 }), + toHslaString: (color: any) => `hsla(${color.h}, ${color.s}%, ${color.l}%, ${color.a})`, + changeHslaLightness: (color: any, change: number) => ({ + ...color, + l: Math.max(0, Math.min(100, color.l + change)), + }), + setHslaAlpha: (color: any, alpha: number) => ({ ...color, a: alpha }), + }, +})); + +describe('Color Scales', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockModernColorSupport.mockReturnValue(false); + mockRelativeColorSyntax.mockReturnValue(false); + }); + + describe('generateAlphaScale', () => { + it('should return empty scale for undefined input', () => { + const result = generateAlphaScale(undefined); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + + it('should return empty scale for null input', () => { + const result = generateAlphaScale(null as any); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + + it('should generate scale from string color', () => { + const result = generateAlphaScale('red'); + expect(result).toBeDefined(); + expect(result['25']).toBeDefined(); + expect(result['500']).toBeDefined(); + expect(result['950']).toBeDefined(); + }); + + it('should use modern CSS when supported', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = generateAlphaScale('blue'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + + const result = generateAlphaScale('blue'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + }); + + it('should handle existing color scale input', () => { + const existingScale: ColorScale = { + '25': '#ff0000', + '50': '#ff0000', + '100': '#ff0000', + '150': '#ff0000', + '200': '#ff0000', + '300': '#ff0000', + '400': '#ff0000', + '500': '#ff0000', + '600': '#ff0000', + '700': '#ff0000', + '750': '#ff0000', + '800': '#ff0000', + '850': '#ff0000', + '900': '#ff0000', + '950': '#ff0000', + }; + + const result = generateAlphaScale(existingScale); + expect(result).toBeDefined(); + expect(result['500']).toBe('#ff0000'); + }); + }); + + describe('generateLightnessScale', () => { + it('should return empty scale for undefined input', () => { + const result = generateLightnessScale(undefined); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + + it('should generate scale from string color', () => { + const result = generateLightnessScale('red'); + expect(result).toBeDefined(); + expect(result['25']).toBeDefined(); + expect(result['500']).toBeDefined(); + expect(result['950']).toBeDefined(); + }); + + it('should use modern CSS when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockRelativeColorSyntax.mockReturnValue(true); + + const result = generateLightnessScale('green'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + + const result = generateLightnessScale('green'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + }); + + it('should handle existing color scale input', () => { + const existingScale: ColorScale = { + '25': '#00ff00', + '50': '#00ff00', + '100': '#00ff00', + '150': '#00ff00', + '200': '#00ff00', + '300': '#00ff00', + '400': '#00ff00', + '500': '#00ff00', + '600': '#00ff00', + '700': '#00ff00', + '750': '#00ff00', + '800': '#00ff00', + '850': '#00ff00', + '900': '#00ff00', + '950': '#00ff00', + }; + + const result = generateLightnessScale(existingScale); + expect(result).toBeDefined(); + expect(result['500']).toBe('#00ff00'); + }); + }); + + describe('modernScales', () => { + it('should have generateAlphaScale function', () => { + expect(typeof modernScales.generateAlphaScale).toBe('function'); + }); + + it('should have generateLightnessScale function', () => { + expect(typeof modernScales.generateLightnessScale).toBe('function'); + }); + + it('should generate modern alpha scale', () => { + const result = modernScales.generateAlphaScale('red'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + expect(result['500']).toContain('color-mix'); + }); + + it('should generate modern lightness scale', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = modernScales.generateLightnessScale('red'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + expect(result['500']).toBe('red'); // 500 should be the original color + }); + }); + + describe('legacyScales', () => { + it('should have generateAlphaScale function', () => { + expect(typeof legacyScales.generateAlphaScale).toBe('function'); + }); + + it('should have generateLightnessScale function', () => { + expect(typeof legacyScales.generateLightnessScale).toBe('function'); + }); + + it('should generate legacy alpha scale', () => { + const result = legacyScales.generateAlphaScale('red'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + expect(result['500']).toContain('hsla'); + }); + + it('should generate legacy lightness scale', () => { + const result = legacyScales.generateLightnessScale('red'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + expect(result['500']).toContain('hsla'); + }); + }); + + describe('scale merging', () => { + it('should merge user-provided colors with generated scale', () => { + const userScale: Partial> = { + '500': '#ff0000', + '700': '#cc0000', + }; + + const result = generateLightnessScale(userScale as any); + expect(result['500']).toBe('#ff0000'); + expect(result['700']).toBe('#cc0000'); + expect(result['25']).toBeDefined(); // Should be generated + expect(result['950']).toBeDefined(); // Should be generated + }); + }); + + describe('input validation', () => { + it('should handle empty string input', () => { + const result = generateLightnessScale(''); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + + it('should handle invalid color scale object', () => { + const invalidScale = { notAShade: 'red' }; + const result = generateLightnessScale(invalidScale as any); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + }); + + describe('applyScalePrefix', () => { + // We need to access the internal applyScalePrefix function for testing + // Since it's now private, we'll test it through the public API + it('should apply prefix through themed functions', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = colorOptionToThemedAlphaScale('red', 'bg-'); + + expect(result).toBeDefined(); + if (result) { + expect(Object.keys(result)).toEqual(expect.arrayContaining([expect.stringMatching(/^bg-\d+$/)])); + } + }); + + it('should skip undefined values in prefixed results', () => { + mockModernColorSupport.mockReturnValue(false); + + // Empty string results in undefined values that should be filtered out + const result = colorOptionToThemedLightnessScale('', 'text-'); + + expect(result).toBeUndefined(); + }); + }); +}); + +describe('Themed Color Scales', () => { + describe('colorOptionToThemedAlphaScale', () => { + it('should return undefined for undefined input', () => { + const result = colorOptionToThemedAlphaScale(undefined, 'bg-'); + expect(result).toBeUndefined(); + }); + + it('should handle string color input', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = colorOptionToThemedAlphaScale('red', 'bg-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('bg-500'); + }); + + it('should handle color scale object', () => { + const colorScale = { + '25': '#fef2f2', + '50': '#fee2e2', + '100': '#fecaca', + '150': '#fca5a5', + '200': '#f87171', + '300': '#ef4444', + '400': '#dc2626', + '500': '#b91c1c', + '600': '#991b1b', + '700': '#7f1d1d', + '750': '#6b1d1d', + '800': '#5a1616', + '850': '#4a1212', + '900': '#3a0e0e', + '950': '#2a0a0a', + }; + + const result = colorOptionToThemedAlphaScale(colorScale, 'bg-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('bg-500'); + }); + + it('should apply correct prefix', () => { + const result = colorOptionToThemedAlphaScale('red', 'text-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('text-500'); + }); + }); + + describe('colorOptionToThemedLightnessScale', () => { + it('should return undefined for undefined input', () => { + const result = colorOptionToThemedLightnessScale(undefined, 'bg-'); + expect(result).toBeUndefined(); + }); + + it('should handle string color input', () => { + mockModernColorSupport.mockReturnValue(true); + mockRelativeColorSyntax.mockReturnValue(true); + + const result = colorOptionToThemedLightnessScale('red', 'bg-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('bg-500'); + }); + + it('should handle partial color scale object', () => { + const partialScale = { + '500': '#ef4444', + '700': '#7f1d1d', + }; + + const result = colorOptionToThemedLightnessScale(partialScale, 'bg-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('bg-500'); + }); + + it('should apply correct prefix', () => { + const result = colorOptionToThemedLightnessScale('blue', 'text-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('text-500'); + }); + + it('should handle empty string input', () => { + const result = colorOptionToThemedLightnessScale('', 'bg-'); + + // Empty strings are falsy, so the function returns undefined + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts new file mode 100644 index 00000000000..cffb0c513f1 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { cssSupports } from '../../cssSupports'; +import { + createAlphaColorMixString, + createColorMixString, + createEmptyColorScale, + createRelativeColorString, + generateAlphaColorMix, + generateColorMixSyntax, + generateRelativeColorSyntax, + getSupportedColorVariant, +} from '../utils'; + +// Mock cssSupports +vi.mock('../../cssSupports', () => ({ + cssSupports: { + relativeColorSyntax: vi.fn(), + colorMix: vi.fn(), + }, +})); + +// Get the mocked functions +const mockRelativeColorSyntax = vi.mocked(cssSupports.relativeColorSyntax); +const mockColorMix = vi.mocked(cssSupports.colorMix); + +describe('Color Utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + }); + + describe('createEmptyColorScale', () => { + it('should create an empty color scale with all shades', () => { + const scale = createEmptyColorScale(); + + expect(scale).toHaveProperty('25', undefined); + expect(scale).toHaveProperty('50', undefined); + expect(scale).toHaveProperty('100', undefined); + expect(scale).toHaveProperty('500', undefined); + expect(scale).toHaveProperty('950', undefined); + }); + + it('should return a new object each time', () => { + const scale1 = createEmptyColorScale(); + const scale2 = createEmptyColorScale(); + + expect(scale1).not.toBe(scale2); + expect(scale1).toEqual(scale2); + }); + + it('should allow modification of returned scale', () => { + const scale = createEmptyColorScale(); + scale['500'] = 'red'; + + expect(scale['500']).toBe('red'); + }); + }); + + describe('color string generators', () => { + describe('createColorMixString', () => { + it('should generate color-mix syntax', () => { + const result = createColorMixString('red', 'blue', 50); + expect(result).toBe('color-mix(in srgb, red, blue 50%)'); + }); + }); + + describe('createRelativeColorString', () => { + it('should generate relative color syntax without alpha', () => { + const result = createRelativeColorString('red', 'h', 's', 'calc(l + 10%)'); + expect(result).toBe('hsl(from red h s calc(l + 10%))'); + }); + + it('should generate relative color syntax with alpha', () => { + const result = createRelativeColorString('red', 'h', 's', 'l', '0.5'); + expect(result).toBe('hsl(from red h s l / 0.5)'); + }); + }); + + describe('createAlphaColorMixString', () => { + it('should generate alpha color-mix syntax', () => { + const result = createAlphaColorMixString('red', 50); + expect(result).toBe('color-mix(in srgb, transparent, red 50%)'); + }); + }); + }); + + describe('generateRelativeColorSyntax', () => { + it('should return original color for 500 shade', () => { + const result = generateRelativeColorSyntax('red', 500); + expect(result).toBe('red'); + }); + + it('should generate correct syntax for light shades', () => { + const result = generateRelativeColorSyntax('red', 400); + expect(result).toMatch(/hsl\(from red h s calc\(l \+ \(1 \* \(\(97 - l\) \/ 7\)\)\)\)/); + }); + + it('should generate correct syntax for dark shades', () => { + const result = generateRelativeColorSyntax('red', 600); + expect(result).toMatch(/hsl\(from red h s calc\(l - \(1 \* \(\(l - 12\) \/ 7\)\)\)\)/); + }); + }); + + describe('generateColorMixSyntax', () => { + it('should return original color for 500 shade', () => { + const result = generateColorMixSyntax('red', 500); + expect(result).toBe('red'); + }); + + it('should generate color-mix with white for light shades', () => { + const result = generateColorMixSyntax('red', 50); + expect(result).toBe('color-mix(in srgb, red, white 80%)'); + }); + + it('should generate color-mix with black for dark shades', () => { + const result = generateColorMixSyntax('red', 800); + expect(result).toBe('color-mix(in srgb, red, black 44%)'); + }); + }); + + describe('generateAlphaColorMix', () => { + it('should generate alpha color-mix for all shades', () => { + const result25 = generateAlphaColorMix('red', 25); + const result500 = generateAlphaColorMix('red', 500); + const result950 = generateAlphaColorMix('red', 950); + + expect(result25).toBe('color-mix(in srgb, transparent, red 2%)'); + expect(result500).toBe('color-mix(in srgb, transparent, red 53%)'); + expect(result950).toBe('color-mix(in srgb, transparent, red 92%)'); + }); + }); + + describe('getSupportedColorVariant', () => { + it('should return original color for 500 shade', () => { + const result = getSupportedColorVariant('red', 500); + expect(result).toBe('red'); + }); + + it('should use relative color syntax when supported', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = getSupportedColorVariant('red', 400); + expect(result).toMatch(/hsl\(from red h s calc\(l \+ \(1 \* \(\(97 - l\) \/ 7\)\)\)\)/); + }); + + it('should fall back to color-mix when relative color syntax not supported', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = getSupportedColorVariant('red', 400); + expect(result).toBe('color-mix(in srgb, red, white 16%)'); + }); + + it('should return original color when no modern CSS support', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + const result = getSupportedColorVariant('red', 400); + expect(result).toBe('red'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/constants.ts b/packages/clerk-js/src/ui/utils/colors/constants.ts new file mode 100644 index 00000000000..3061e100e20 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/constants.ts @@ -0,0 +1,107 @@ +/** + * Shared constants for color utilities + */ + +import type { ColorScale } from '@clerk/types'; + +// Types +export type ColorShade = 25 | 50 | 100 | 150 | 200 | 300 | 400 | 500 | 600 | 700 | 750 | 800 | 850 | 900 | 950; +export type ColorShadeKey = keyof ColorScale; + +// Core color scale definition +export const COLOR_SCALE: readonly ColorShade[] = [ + 25, 50, 100, 150, 200, 300, 400, 500, 600, 700, 750, 800, 850, 900, 950, +] as const; + +// Shade groupings for scale generation +export const LIGHT_SHADES: ColorShadeKey[] = ['400', '300', '200', '150', '100', '50', '25']; +export const DARK_SHADES: ColorShadeKey[] = ['600', '700', '750', '800', '850', '900', '950']; +export const ALL_SHADES: ColorShadeKey[] = [...LIGHT_SHADES, '500', ...DARK_SHADES]; + +// Lightness configuration for scale generation +export const LIGHTNESS_CONFIG = { + TARGET_LIGHT: 97, // Target lightness for 50 shade + TARGET_DARK: 12, // Target lightness for 900 shade + LIGHT_STEPS: 7, // Number of light shades + DARK_STEPS: 7, // Number of dark shades +} as const; + +// Alpha percentages for color-mix generation +export const ALPHA_PERCENTAGES: Record = { + 25: 2, + 50: 3, + 100: 7, + 150: 11, + 200: 15, + 300: 28, + 400: 41, + 500: 53, + 600: 62, + 700: 73, + 750: 78, + 800: 81, + 850: 84, + 900: 87, + 950: 92, +} as const; + +export const ALPHA_VALUES = Object.values(ALPHA_PERCENTAGES) + .map(v => v / 100) + .sort(); + +// Lightness mix data for color-mix generation +export const LIGHTNESS_MIX_DATA: Record = { + 25: { mixColor: 'white', percentage: 85 }, + 50: { mixColor: 'white', percentage: 80 }, + 100: { mixColor: 'white', percentage: 68 }, + 150: { mixColor: 'white', percentage: 55 }, + 200: { mixColor: 'white', percentage: 40 }, + 300: { mixColor: 'white', percentage: 26 }, + 400: { mixColor: 'white', percentage: 16 }, + 500: { mixColor: null, percentage: 0 }, + 600: { mixColor: 'black', percentage: 12 }, + 700: { mixColor: 'black', percentage: 22 }, + 750: { mixColor: 'black', percentage: 30 }, + 800: { mixColor: 'black', percentage: 44 }, + 850: { mixColor: 'black', percentage: 55 }, + 900: { mixColor: 'black', percentage: 65 }, + 950: { mixColor: 'black', percentage: 75 }, +} as const; + +// Relative color syntax step configuration +export const RELATIVE_SHADE_STEPS: Record = { + // Light shades (lighter than 500) + 400: 1, + 300: 2, + 200: 3, + 150: 4, + 100: 5, + 50: 6, + 25: 7, + // Dark shades (darker than 500) + 600: 1, + 700: 2, + 750: 3, + 800: 4, + 850: 5, + 900: 6, + 950: 7, +} as const; + +// Color bounds for validation and clamping +export const COLOR_BOUNDS = { + rgb: { min: 0, max: 255 }, + alpha: { min: 0, max: 1 }, + hue: { min: 0, max: 360 }, + percentage: { min: 0, max: 100 }, +} as const; + +// Modern CSS utility constants +export const MODERN_CSS_LIMITS = { + MAX_LIGHTNESS_MIX: 95, // Maximum percentage for color-mix with white + MIN_ALPHA_PERCENTAGE: 5, // Minimum opacity for transparent color-mix + MAX_LIGHTNESS_ADJUSTMENT: 30, // Maximum lightness adjustment in color-mix + MIN_LIGHTNESS_FLOOR: 95, // Minimum lightness floor for very light colors + LIGHTNESS_MULTIPLIER: 2, // Multiplier for lightness adjustments + MIX_MULTIPLIER: 4, // Multiplier for mix percentage calculations +} as const; diff --git a/packages/clerk-js/src/ui/utils/colors/index.ts b/packages/clerk-js/src/ui/utils/colors/index.ts new file mode 100644 index 00000000000..bf0dbe8c0ab --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/index.ts @@ -0,0 +1,160 @@ +import type { HslaColor } from '@clerk/types'; + +import { cssSupports } from '../cssSupports'; +import { colors as legacyColors } from './legacy'; +import { colors as modernColors } from './modern'; + +export const colors = { + /** + * Changes the lightness value of an HSLA color object + * @param color - The HSLA color object to modify + * @param lightness - The new lightness value (0-100) + * @returns A new HSLA color object with the modified lightness + * @example + * ```typescript + * const darkColor = colors.changeHslaLightness({ h: 200, s: 50, l: 80, a: 1 }, 20); + * ``` + */ + changeHslaLightness: legacyColors.changeHslaLightness, + + /** + * Sets the alpha (opacity) value of an HSLA color object + * @param color - The HSLA color object to modify + * @param alpha - The new alpha value (0-1) + * @returns A new HSLA color object with the modified alpha + * @example + * ```typescript + * const semiTransparent = colors.setHslaAlpha({ h: 200, s: 50, l: 50, a: 1 }, 0.5); + * ``` + */ + setHslaAlpha: legacyColors.setHslaAlpha, + + /** + * Converts a color string to either a string (modern CSS) or HSLA object (legacy) + * Uses modern CSS features when supported, falls back to parsing the string into an HSLA object for older browsers + * @param color - CSS color string (hex, rgb, hsl, `var(--color)`, etc.) or undefined + * @returns Color string in modern browsers, HSLA object in legacy browsers, or undefined if input is undefined + * @example + * ```typescript + * const processedColor = colors.toHslaColor('#ff0000'); // '#ff0000' or { h: 0, s: 100, l: 50, a: 1 } + * const noColor = colors.toHslaColor(undefined); // undefined + * ``` + */ + toHslaColor: (color: string | undefined): string | HslaColor | undefined => { + if (!color) return undefined; + return cssSupports.modernColor() ? color : legacyColors.toHslaColor(color); + }, + + /** + * Converts a color (string or HSLA object) to a CSS string representation + * @param color - CSS color string, HSLA object, or undefined + * @returns CSS color string or undefined if input is undefined + * @example + * ```typescript + * const cssColor = colors.toHslaString('#ff0000'); // '#ff0000' or 'hsla(0, 100%, 50%, 1)' + * const hslaColor = colors.toHslaString({ h: 200, s: 50, l: 50, a: 1 }); // 'hsla(200, 50%, 50%, 1)' + * ``` + */ + toHslaString: (color: string | HslaColor | undefined): string | undefined => { + if (!color) return undefined; + if (cssSupports.modernColor() && typeof color === 'string') return color; + return legacyColors.toHslaString(color); + }, + + /** + * Creates a lighter version of the given color + * Uses modern CSS relative color syntax when supported, falls back to HSLA manipulation + * @param color - CSS color string or undefined + * @param percentage - How much lighter to make the color (0-100, default: 0) + * @returns Lightened color string or undefined if input is undefined + * @example + * ```typescript + * const lightBlue = colors.lighten('#0066cc', 20); // 20% lighter blue + * const noChange = colors.lighten('#0066cc'); // Same color (0% change) + * ``` + */ + lighten: (color: string | undefined, percentage = 0): string | undefined => { + if (cssSupports.modernColor()) { + return modernColors.lighten(color, percentage); + } + return legacyColors.lighten(color, percentage); + }, + + /** + * Creates a transparent version of the given color by reducing its opacity + * Uses modern CSS color-mix function when supported, falls back to HSLA alpha manipulation + * @param color - CSS color string or undefined + * @param percentage - How much transparency to add (0-100, default: 0) + * @returns Color with reduced opacity or undefined if input is undefined + * @example + * ```typescript + * const semiTransparent = colors.makeTransparent('#ff0000', 50); // 50% transparent red + * const opaque = colors.makeTransparent('#ff0000'); // Same color (0% transparency) + * ``` + */ + makeTransparent: (color: string | undefined, percentage = 0): string | undefined => { + if (cssSupports.modernColor()) { + return modernColors.makeTransparent(color, percentage); + } + return legacyColors.makeTransparent(color, percentage); + }, + + /** + * Removes transparency from a color, making it fully opaque + * Uses modern CSS features when supported, falls back to HSLA alpha manipulation + * @param color - CSS color string or undefined + * @returns Fully opaque version of the color or undefined if input is undefined + * @example + * ```typescript + * const solid = colors.makeSolid('rgba(255, 0, 0, 0.5)'); // Fully opaque red + * const alreadySolid = colors.makeSolid('#ff0000'); // Same color (already opaque) + * ``` + */ + makeSolid: (color: string | undefined): string | undefined => { + if (cssSupports.modernColor()) { + return modernColors.makeSolid(color); + } + return legacyColors.makeSolid(color); + }, + + /** + * Sets the alpha (opacity) value of a color + * Uses modern CSS relative color syntax when supported, falls back to HSLA manipulation + * @param color - CSS color string (required) + * @param alpha - Alpha value between 0 (transparent) and 1 (opaque) + * @returns Color string with the specified alpha value + * @throws {Error} When color is not provided + * @example + * ```typescript + * const halfTransparent = colors.setAlpha('#ff0000', 0.5); // 50% transparent red + * const fullyOpaque = colors.setAlpha('rgba(255, 0, 0, 0.3)', 1); // Fully opaque red + * ``` + */ + setAlpha: (color: string, alpha: number): string => { + if (cssSupports.modernColor()) { + return modernColors.setAlpha(color, alpha); + } + return legacyColors.setAlpha(color, alpha); + }, + + /** + * Adjusts a color's lightness for better contrast or visual hierarchy + * Uses modern CSS relative color syntax when supported, falls back to HSLA manipulation + * @param color - CSS color string or undefined + * @param lightness - Lightness adjustment amount (default: 5) + * @returns Color with adjusted lightness or undefined if input is undefined + * @example + * ```typescript + * const adjusted = colors.adjustForLightness('#333333', 10); // Slightly lighter dark gray + * const subtle = colors.adjustForLightness('#666666'); // Subtle lightness adjustment (5 units) + * ``` + */ + adjustForLightness: (color: string | undefined, lightness = 5): string | undefined => { + if (cssSupports.modernColor()) { + return modernColors.adjustForLightness(color, lightness); + } + return legacyColors.adjustForLightness(color, lightness); + }, +}; + +export { modernColors, legacyColors }; diff --git a/packages/clerk-js/src/ui/utils/colors.ts b/packages/clerk-js/src/ui/utils/colors/legacy.ts similarity index 86% rename from packages/clerk-js/src/ui/utils/colors.ts rename to packages/clerk-js/src/ui/utils/colors/legacy.ts index d3ee83e7550..a1870f7df32 100644 --- a/packages/clerk-js/src/ui/utils/colors.ts +++ b/packages/clerk-js/src/ui/utils/colors/legacy.ts @@ -254,18 +254,55 @@ const hslaColorToHslaString = ({ h, s, l, a }: HslaColor): HslaColorString => { return `hsla(${h}, ${s}%, ${l}%, ${a ?? 1})` as HslaColorString; }; +const resolveCssVariable = (str: string): string | null => { + // Check if it's a CSS variable (starts with var() + const cssVarRegex = /^var\(\s*(--[^,\s)]+)\s*(?:,\s*([^)]+))?\s*\)$/; + const match = str.match(cssVarRegex); + + if (!match) { + return null; + } + + const [, variableName, fallbackValue] = match; + + // Try to get the computed value from CSS custom property + if (typeof window !== 'undefined' && window.getComputedStyle) { + try { + const computedStyle = window.getComputedStyle(document.documentElement); + const resolvedValue = computedStyle.getPropertyValue(variableName).trim(); + + if (resolvedValue) { + return resolvedValue; + } + } catch { + // Silently fail and try fallback + } + } + + // If we can't resolve the variable, try the fallback value + if (fallbackValue) { + return fallbackValue.trim(); + } + + return null; +}; + const parse = (str: string): ParsedResult => { - const prefix = str.substr(0, 3).toLowerCase(); + // First try to resolve CSS variables + const resolvedStr = resolveCssVariable(str); + const colorStr = resolvedStr || str; + + const prefix = colorStr.substr(0, 3).toLowerCase(); let res; if (prefix === 'hsl') { - res = { model: 'hsl', value: parseHsl(str) }; + res = { model: 'hsl', value: parseHsl(colorStr) }; } else if (prefix === 'hwb') { - res = { model: 'hwb', value: parseHwb(str) }; + res = { model: 'hwb', value: parseHwb(colorStr) }; } else { - res = { model: 'rgb', value: parseRgb(str) }; + res = { model: 'rgb', value: parseRgb(colorStr) }; } if (!res || !res.value) { - throw new Error(`Clerk: "${str}" cannot be used as a color within 'variables'. You can pass one of: + throw new Error(`Clerk: "${colorStr}" cannot be used as a color within 'variables'. You can pass one of: - any valid hsl or hsla color - any valid rgb or rgba color - any valid hex color diff --git a/packages/clerk-js/src/ui/utils/colors/modern.ts b/packages/clerk-js/src/ui/utils/colors/modern.ts new file mode 100644 index 00000000000..5da2b676415 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/modern.ts @@ -0,0 +1,106 @@ +/** + * CSS-based color manipulation utilities + * Uses color-mix() and relative color syntax when supported + */ + +import { cssSupports } from '../cssSupports'; +import { COLOR_BOUNDS, MODERN_CSS_LIMITS } from './constants'; +import { createAlphaColorMixString, createColorMixString, createRelativeColorString } from './utils'; + +/** + * CSS-based color manipulation utilities + * Uses color-mix() and relative color syntax when supported + */ +export const colors = { + /** + * Lightens a color by a percentage + */ + lighten: (color: string | undefined, percentage = 0): string | undefined => { + if (!color) return undefined; + + if (cssSupports.relativeColorSyntax()) { + // Use relative color syntax for precise lightness control + const lightnessIncrease = percentage * 100; // Convert to percentage + return createRelativeColorString(color, 'h', 's', `calc(l + ${lightnessIncrease}%)`); + } + + if (cssSupports.colorMix()) { + // Use color-mix as fallback + const mixPercentage = Math.min(percentage * 100, MODERN_CSS_LIMITS.MAX_LIGHTNESS_MIX); + return createColorMixString(color, 'white', mixPercentage); + } + + return color; // Return original if no CSS support + }, + + /** + * Makes a color transparent by a percentage + */ + makeTransparent: (color: string | undefined, percentage = 0): string | undefined => { + if (!color || color.toString() === '') return undefined; + + if (cssSupports.colorMix()) { + const alphaPercentage = Math.max((1 - percentage) * 100, MODERN_CSS_LIMITS.MIN_ALPHA_PERCENTAGE); + return createAlphaColorMixString(color, alphaPercentage); + } + + return color; // Return original if no CSS support + }, + + /** + * Makes a color completely opaque + */ + makeSolid: (color: string | undefined): string | undefined => { + if (!color) return undefined; + + if (cssSupports.relativeColorSyntax()) { + // Set alpha to 1 using relative color syntax + return createRelativeColorString(color, 'h', 's', 'l', '1'); + } + + if (cssSupports.colorMix()) { + // Mix with itself at 100% to remove transparency + return `color-mix(in srgb, ${color}, ${color} 100%)`; + } + + return color; // Return original if no CSS support + }, + + /** + * Sets the alpha value of a color + */ + setAlpha: (color: string, alpha: number): string => { + const clampedAlpha = Math.min(Math.max(alpha, COLOR_BOUNDS.alpha.min), COLOR_BOUNDS.alpha.max); + + if (cssSupports.relativeColorSyntax()) { + // Use relative color syntax for precise alpha control + return createRelativeColorString(color, 'h', 's', 'l', clampedAlpha.toString()); + } + + if (cssSupports.colorMix()) { + // Use color-mix with transparent + const percentage = clampedAlpha * 100; + return createAlphaColorMixString(color, percentage); + } + + return color; // Return original if no CSS support + }, + + /** + * Adjusts color for better contrast/lightness + */ + adjustForLightness: (color: string | undefined, lightness = 5): string | undefined => { + if (!color) return undefined; + + if (cssSupports.colorMix()) { + // Use color-mix with white for lightness adjustment - more conservative approach + const mixPercentage = Math.min( + lightness * MODERN_CSS_LIMITS.MIX_MULTIPLIER, + MODERN_CSS_LIMITS.MAX_LIGHTNESS_ADJUSTMENT, + ); + return createColorMixString(color, 'white', mixPercentage); + } + + return color; // Return original if no CSS support + }, +}; diff --git a/packages/clerk-js/src/ui/utils/colors/scales.ts b/packages/clerk-js/src/ui/utils/colors/scales.ts new file mode 100644 index 00000000000..2e9feb18d3b --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/scales.ts @@ -0,0 +1,276 @@ +import type { ColorScale, CssColorOrAlphaScale, CssColorOrScale, HslaColorString } from '@clerk/types'; + +import { cssSupports } from '../cssSupports'; +import { ALPHA_VALUES, COLOR_SCALE, DARK_SHADES, LIGHT_SHADES, LIGHTNESS_CONFIG } from './constants'; +import { colors as legacyColors } from './legacy'; +import { createEmptyColorScale, generateAlphaColorMix, getSupportedColorVariant } from './utils'; + +// Types for themed scales +type InternalColorScale = ColorScale & Partial>; +type WithPrefix, Prefix extends string> = { + [K in keyof T as `${Prefix}${K & string}`]: T[K]; +}; + +/** + * Apply a prefix to a color scale + * @param scale - The color scale to apply the prefix to + * @param prefix - The prefix to apply + * @returns The color scale with the prefix applied + */ +function applyScalePrefix( + scale: ColorScale, + prefix: Prefix, +): Record<`${Prefix}${keyof ColorScale}`, string> { + const result: Record = {}; + + for (const [shade, color] of Object.entries(scale)) { + if (color !== undefined) { + result[prefix + shade] = color; + } + } + + return result as Record<`${Prefix}${keyof ColorScale}`, string>; +} + +/** + * Modern CSS alpha scale generation + */ +function generateModernAlphaScale(baseColor: string): ColorScale { + const scale = createEmptyColorScale(); + + COLOR_SCALE.forEach(shade => { + scale[shade] = generateAlphaColorMix(baseColor, shade); + }); + + return scale as ColorScale; +} + +/** + * Legacy HSLA alpha scale generation + */ +function generateLegacyAlphaScale(baseColor: string): ColorScale { + const scale = createEmptyColorScale(); + const parsedColor = legacyColors.toHslaColor(baseColor); + const baseWithoutAlpha = legacyColors.setHslaAlpha(parsedColor, 0); + + COLOR_SCALE.forEach((shade, index) => { + const alpha = ALPHA_VALUES[index] ?? 1; + const alphaColor = legacyColors.setHslaAlpha(baseWithoutAlpha, alpha); + scale[shade] = legacyColors.toHslaString(alphaColor); + }); + + return scale as ColorScale; +} + +/** + * Modern CSS lightness scale generation + */ +function generateModernLightnessScale(baseColor: string): ColorScale { + const scale = createEmptyColorScale(); + + COLOR_SCALE.forEach(shade => { + scale[shade] = getSupportedColorVariant(baseColor, shade); + }); + + return scale as ColorScale; +} + +/** + * Legacy HSLA lightness scale generation + */ +function generateLegacyLightnessScale(baseColor: string): ColorScale { + const scale = createEmptyColorScale(); + const parsedColor = legacyColors.toHslaColor(baseColor); + + // Set the base 500 shade + scale['500'] = legacyColors.toHslaString(parsedColor); + + // Calculate lightness steps + const lightStep = (LIGHTNESS_CONFIG.TARGET_LIGHT - parsedColor.l) / LIGHT_SHADES.length; + const darkStep = (parsedColor.l - LIGHTNESS_CONFIG.TARGET_DARK) / DARK_SHADES.length; + + // Generate light shades (lighter than base) + LIGHT_SHADES.forEach((shade, index) => { + const lightnessIncrease = (index + 1) * lightStep; + const lightColor = legacyColors.changeHslaLightness(parsedColor, lightnessIncrease); + scale[shade] = legacyColors.toHslaString(lightColor); + }); + + // Generate dark shades (darker than base) + DARK_SHADES.forEach((shade, index) => { + const lightnessDecrease = (index + 1) * darkStep * -1; + const darkColor = legacyColors.changeHslaLightness(parsedColor, lightnessDecrease); + scale[shade] = legacyColors.toHslaString(darkColor); + }); + + return scale as ColorScale; +} + +/** + * Processes color input and validates it + */ +function processColorInput( + color: string | ColorScale | CssColorOrScale | undefined, +): { baseColor: string; userScale?: ColorScale } | null { + if (!color) return null; + + if (typeof color === 'string') { + return { baseColor: color }; + } + + // If it's already a color scale object, extract the base color (500 shade) + if (color['500']) { + return { + baseColor: color['500'], + userScale: color as ColorScale, + }; + } + + return null; +} + +/** + * Merges user-defined colors with generated scale + */ +function mergeWithUserScale(generated: ColorScale, userScale?: ColorScale): ColorScale { + if (!userScale) return generated; + + return { ...generated, ...userScale }; +} + +/** + * Unified alpha scale generator that automatically chooses between modern and legacy implementations + * @param color - Base color string or existing color scale + * @returns Complete color scale with alpha variations + */ +export function generateAlphaScale( + color: string | ColorScale | CssColorOrScale | undefined, +): ColorScale { + const processed = processColorInput(color); + if (!processed) { + return createEmptyColorScale() as ColorScale; + } + + const { baseColor, userScale } = processed; + + // Generate scale using modern or legacy implementation + const generated = cssSupports.modernColor() + ? generateModernAlphaScale(baseColor) + : generateLegacyAlphaScale(baseColor); + + // Merge with user-provided colors if any + return mergeWithUserScale(generated, userScale); +} + +/** + * Unified lightness scale generator that automatically chooses between modern and legacy implementations + * @param color - Base color string or existing color scale + * @returns Complete color scale with lightness variations + */ +export function generateLightnessScale( + color: string | ColorScale | CssColorOrScale | undefined, +): ColorScale { + const processed = processColorInput(color); + if (!processed) { + return createEmptyColorScale() as ColorScale; + } + + const { baseColor, userScale } = processed; + + // Generate scale using modern or legacy implementation + const generated = cssSupports.modernColor() + ? generateModernLightnessScale(baseColor) + : generateLegacyLightnessScale(baseColor); + + // Merge with user-provided colors if any + return mergeWithUserScale(generated, userScale); +} + +/** + * Direct access to modern scale generators (for testing or when modern CSS is guaranteed) + */ +export const modernScales = { + generateAlphaScale: generateModernAlphaScale, + generateLightnessScale: generateModernLightnessScale, +} as const; + +/** + * Direct access to legacy scale generators (for testing or compatibility) + */ +export const legacyScales = { + generateAlphaScale: generateLegacyAlphaScale, + generateLightnessScale: generateLegacyLightnessScale, +} as const; + +/** + * Converts a color scale to CSS color strings + * Works with both modern CSS (color-mix, relative colors) and legacy HSLA + */ +function convertScaleToCssStrings(scale: ColorScale): ColorScale { + const result: Partial> = {}; + + for (const [shade, color] of Object.entries(scale)) { + if (color && color !== undefined) { + // For modern CSS color-mix values, we keep them as-is since they're already valid CSS + // For legacy HSLA values, they're already in HSLA format + result[shade as keyof ColorScale] = color as HslaColorString; + } + } + + return result as ColorScale; +} + +/** + * Applies prefix to a color scale and converts to CSS color strings + */ +function prefixAndConvertScale( + scale: ColorScale, + prefix: Prefix, +): WithPrefix, Prefix> { + const cssScale = convertScaleToCssStrings(scale); + return applyScalePrefix(cssScale, prefix) as unknown as WithPrefix, Prefix>; +} + +/** + * Converts a color option to a themed alpha scale with prefix + * Returns CSS color values (modern color-mix/relative colors or legacy HSLA) + * @param colorOption - Color input (string or alpha scale object) + * @param prefix - Prefix to apply to scale keys + * @returns Prefixed CSS color scale or undefined + */ +export const colorOptionToThemedAlphaScale = ( + colorOption: CssColorOrAlphaScale | undefined, + prefix: Prefix, +): WithPrefix, Prefix> | undefined => { + if (!colorOption) { + return undefined; + } + + // Generate alpha scale using the unified scale generator + const scale = generateAlphaScale(colorOption); + + // Convert to CSS strings and apply prefix + return prefixAndConvertScale(scale, prefix); +}; + +/** + * Converts a color option to a themed lightness scale with prefix + * Returns CSS color values (modern color-mix/relative colors or legacy HSLA) + * @param colorOption - Color input (string or lightness scale object) + * @param prefix - Prefix to apply to scale keys + * @returns Prefixed CSS color scale or undefined + */ +export const colorOptionToThemedLightnessScale = ( + colorOption: CssColorOrScale | undefined, + prefix: Prefix, +): WithPrefix, Prefix> | undefined => { + if (!colorOption) { + return undefined; + } + + // Generate lightness scale using the unified scale generator + const scale = generateLightnessScale(colorOption); + + // Convert to CSS strings and apply prefix + return prefixAndConvertScale(scale, prefix); +}; diff --git a/packages/clerk-js/src/ui/utils/colors/utils.ts b/packages/clerk-js/src/ui/utils/colors/utils.ts new file mode 100644 index 00000000000..fb386487fb2 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/utils.ts @@ -0,0 +1,137 @@ +import type { ColorScale } from '@clerk/types'; + +import { cssSupports } from '../cssSupports'; +import type { ColorShade } from './constants'; +import { ALL_SHADES, ALPHA_PERCENTAGES, LIGHTNESS_CONFIG, LIGHTNESS_MIX_DATA, RELATIVE_SHADE_STEPS } from './constants'; + +/** + * Pre-computed empty color scale to avoid object creation + */ +const EMPTY_COLOR_SCALE: ColorScale = Object.freeze( + ALL_SHADES.reduce( + (scale, shade) => { + scale[shade] = undefined; + return scale; + }, + {} as ColorScale, + ), +); + +/** + * Fast empty color scale creation - returns pre-computed frozen object + */ +export const createEmptyColorScale = (): ColorScale => { + return { ...EMPTY_COLOR_SCALE }; +}; + +/** + * Core color generation functions + */ + +/** + * Create a color-mix string + * @param baseColor - The base color + * @param mixColor - The color to mix with + * @param percentage - The percentage of the mix + * @returns The color-mix string + */ +export function createColorMixString(baseColor: string, mixColor: string, percentage: number): string { + return `color-mix(in srgb, ${baseColor}, ${mixColor} ${percentage}%)`; +} + +/** + * Generate a relative color syntax string + * @param color - The base color + * @param hue - The hue component + * @param saturation - The saturation component + * @param lightness - The lightness component + * @param alpha - The alpha component (optional) + * @returns The relative color syntax string + */ +export function createRelativeColorString( + color: string, + hue: string, + saturation: string, + lightness: string, + alpha?: string, +): string { + return `hsl(from ${color} ${hue} ${saturation} ${lightness}${alpha ? ` / ${alpha}` : ''})`; +} + +/** + * Create an alpha color-mix string + * @param color - The base color + * @param alphaPercentage - The alpha percentage + * @returns The alpha color-mix string + */ +export function createAlphaColorMixString(color: string, alphaPercentage: number): string { + return `color-mix(in srgb, transparent, ${color} ${alphaPercentage}%)`; +} + +/** + * Generate a relative color syntax string + * @param color - The base color + * @param shade - The shade to generate the color for + * @returns The relative color syntax string + */ +export function generateRelativeColorSyntax(color: string, shade: ColorShade): string { + if (shade === 500) return color; + + const steps = RELATIVE_SHADE_STEPS[shade]; + if (!steps) return color; + + const { TARGET_LIGHT, TARGET_DARK, LIGHT_STEPS, DARK_STEPS } = LIGHTNESS_CONFIG; + + // Light shades (25-400) + if (shade < 500) { + return createRelativeColorString( + color, + 'h', + 's', + `calc(l + (${steps} * ((${TARGET_LIGHT} - l) / ${LIGHT_STEPS})))`, + ); + } + + // Dark shades (600-950) + return createRelativeColorString(color, 'h', 's', `calc(l - (${steps} * ((l - ${TARGET_DARK}) / ${DARK_STEPS})))`); +} + +/** + * Generate a color-mix string + * @param color - The base color + * @param shade - The shade to generate the color for + * @returns The color-mix string + */ +export function generateColorMixSyntax(color: string, shade: ColorShade): string { + if (shade === 500) return color; + + const mixData = LIGHTNESS_MIX_DATA[shade]; + if (!mixData.mixColor) return color; + + return createColorMixString(color, mixData.mixColor, mixData.percentage); +} + +export function generateAlphaColorMix(color: string, shade: ColorShade): string { + const alphaPercentage = ALPHA_PERCENTAGES[shade]; + return createAlphaColorMixString(color, alphaPercentage); +} + +/** + * Get the optimal color variant for the given shade + * @param color - The base color + * @param shade - The shade to generate the color for + * @returns The optimal color variant + */ +export function getSupportedColorVariant(color: string, shade: ColorShade): string { + if (shade === 500) return color; + + if (cssSupports.relativeColorSyntax()) { + return generateRelativeColorSyntax(color, shade); + } + + if (cssSupports.colorMix()) { + return generateColorMixSyntax(color, shade); + } + + return color; +} diff --git a/packages/clerk-js/src/ui/utils/cssSupports.ts b/packages/clerk-js/src/ui/utils/cssSupports.ts new file mode 100644 index 00000000000..802efe33aaf --- /dev/null +++ b/packages/clerk-js/src/ui/utils/cssSupports.ts @@ -0,0 +1,50 @@ +const CSS_FEATURE_TESTS: Record = { + relativeColorSyntax: 'color: hsl(from white h s l)', + colorMix: 'color: color-mix(in srgb, white, black)', +} as const; + +let SUPPORTS_RELATIVE_COLOR: boolean | undefined; +let SUPPORTS_COLOR_MIX: boolean | undefined; +let SUPPORTS_MODERN_COLOR: boolean | undefined; + +export const cssSupports = { + relativeColorSyntax: () => { + if (SUPPORTS_RELATIVE_COLOR !== undefined) return SUPPORTS_RELATIVE_COLOR; + try { + SUPPORTS_RELATIVE_COLOR = CSS.supports(CSS_FEATURE_TESTS.relativeColorSyntax); + } catch { + SUPPORTS_RELATIVE_COLOR = false; + } + + return SUPPORTS_RELATIVE_COLOR; + }, + colorMix: () => { + if (SUPPORTS_COLOR_MIX !== undefined) return SUPPORTS_COLOR_MIX; + try { + SUPPORTS_COLOR_MIX = CSS.supports(CSS_FEATURE_TESTS.colorMix); + } catch { + SUPPORTS_COLOR_MIX = false; + } + + return SUPPORTS_COLOR_MIX; + }, + /** + * Returns true if either relativeColorSyntax or colorMix is supported + */ + modernColor() { + if (SUPPORTS_MODERN_COLOR !== undefined) return SUPPORTS_MODERN_COLOR; + try { + SUPPORTS_MODERN_COLOR = this.relativeColorSyntax() || this.colorMix(); + } catch { + SUPPORTS_MODERN_COLOR = false; + } + + return SUPPORTS_MODERN_COLOR; + }, +}; + +export const clearCache = () => { + SUPPORTS_RELATIVE_COLOR = undefined; + SUPPORTS_COLOR_MIX = undefined; + SUPPORTS_MODERN_COLOR = undefined; +};