diff --git a/.changeset/chubby-parts-try.md b/.changeset/chubby-parts-try.md new file mode 100644 index 00000000000..d06a51e52c5 --- /dev/null +++ b/.changeset/chubby-parts-try.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +--- + +Add CSS variable support to the `appearance.variables` object, enabling use of CSS custom properties. For example, you can now use `colorPrimary: 'var(--brand-color)'` to reference CSS variables defined in your stylesheets. + +This feature includes automatic fallback support for browsers that don't support modern CSS color manipulation features. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 23508f6f68f..09c556354dd 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "111.8KB" }, - { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "112.1KB" }, + { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "113.67KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" }, @@ -23,7 +23,7 @@ { "path": "./dist/waitlist*.js", "maxSize": "1.5KB" }, { "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" }, { "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" }, - { "path": "./dist/checkout*.js", "maxSize": "8.34KB" }, + { "path": "./dist/checkout*.js", "maxSize": "8.4KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/up-plans-page*.js", "maxSize": "1.0KB" }, diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx index a5071b8de59..1c09ebe39e0 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx @@ -113,8 +113,8 @@ const NotificationCountBadgeSwitcherTrigger = () => { ({ position: 'absolute', - top: `-${t.space.$2}`, - right: `-${t.space.$2}`, + top: `calc(${t.space.$2} * -1)`, + right: `calc(${t.space.$2} * -1)`, })} notificationCount={notificationCount} /> diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index 5ce088e1170..af2bee8f80b 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -13,37 +13,40 @@ import { useCardState } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtons } from '@/ui/elements/FormButtons'; import { FormContainer } from '@/ui/elements/FormContainer'; +import { resolveComputedCSSColor, resolveComputedCSSProperty } from '@/ui/utils/cssVariables'; import { handleError } from '@/ui/utils/errorHandler'; -import { normalizeColorString } from '@/ui/utils/normalizeColorString'; import { useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts'; import { descriptors, Flex, localizationKeys, Spinner, useAppearance, useLocalizations } from '../../customizables'; import type { LocalizationKey } from '../../localization'; -const useStripeAppearance = () => { +const useStripeAppearance = (node: HTMLElement | null) => { const theme = useAppearance().parsedInternalTheme; return useMemo(() => { + if (!node) { + return undefined; + } const { colors, fontWeights, fontSizes, radii, space } = theme; return { - colorPrimary: normalizeColorString(colors.$primary500), - colorBackground: normalizeColorString(colors.$colorInputBackground), - colorText: normalizeColorString(colors.$colorText), - colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), - colorSuccess: normalizeColorString(colors.$success500), - colorDanger: normalizeColorString(colors.$danger500), - colorWarning: normalizeColorString(colors.$warning500), - fontWeightNormal: fontWeights.$normal.toString(), - fontWeightMedium: fontWeights.$medium.toString(), - fontWeightBold: fontWeights.$bold.toString(), - fontSizeXl: fontSizes.$xl, - fontSizeLg: fontSizes.$lg, - fontSizeSm: fontSizes.$md, - fontSizeXs: fontSizes.$sm, - borderRadius: radii.$md, - spacingUnit: space.$1, + colorPrimary: resolveComputedCSSColor(node, colors.$primary500, colors.$colorBackground), + colorBackground: resolveComputedCSSColor(node, colors.$colorInputBackground, colors.$colorBackground), + colorText: resolveComputedCSSColor(node, colors.$colorText, colors.$colorBackground), + colorTextSecondary: resolveComputedCSSColor(node, colors.$colorTextSecondary, colors.$colorBackground), + colorSuccess: resolveComputedCSSColor(node, colors.$success500, colors.$colorBackground), + colorDanger: resolveComputedCSSColor(node, colors.$danger500, colors.$colorBackground), + colorWarning: resolveComputedCSSColor(node, colors.$warning500, colors.$colorBackground), + fontWeightNormal: resolveComputedCSSProperty(node, 'font-weight', fontWeights.$normal.toString()), + fontWeightMedium: resolveComputedCSSProperty(node, 'font-weight', fontWeights.$medium.toString()), + fontWeightBold: resolveComputedCSSProperty(node, 'font-weight', fontWeights.$bold.toString()), + fontSizeXl: resolveComputedCSSProperty(node, 'font-size', fontSizes.$xl), + fontSizeLg: resolveComputedCSSProperty(node, 'font-size', fontSizes.$lg), + fontSizeSm: resolveComputedCSSProperty(node, 'font-size', fontSizes.$md), + fontSizeXs: resolveComputedCSSProperty(node, 'font-size', fontSizes.$sm), + borderRadius: resolveComputedCSSProperty(node, 'border-radius', radii.$lg), + spacingUnit: resolveComputedCSSProperty(node, 'padding', space.$1), }; - }, [theme]); + }, [theme, node]); }; type AddPaymentSourceProps = { @@ -66,11 +69,12 @@ const [AddPaymentSourceContext, useAddPaymentSourceContext] = createContextAndHo const AddPaymentSourceRoot = ({ children, checkout, ...rest }: PropsWithChildren) => { const subscriberType = useSubscriberTypeContext(); + const stripeAppearanceNode = useRef(null); const { t } = useLocalizations(); const [headerTitle, setHeaderTitle] = useState(undefined); const [headerSubtitle, setHeaderSubtitle] = useState(undefined); const [submitLabel, setSubmitLabel] = useState(undefined); - const stripeAppearance = useStripeAppearance(); + const stripeAppearance = useStripeAppearance(stripeAppearanceNode.current); return ( +
({ + cssSupports: { + modernColor: vi.fn(), + }, +})); + +import { cssSupports } from '@/ui/utils/cssSupports'; + +import { removeInvalidValues } from '../parseVariables'; + +const mockModernColorSupport = vi.mocked(cssSupports.modernColor); + +describe('removeInvalidValues', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockModernColorSupport.mockReturnValue(false); + }); + + it('should return the variables as-is if modern color support is present', () => { + mockModernColorSupport.mockReturnValue(true); + const variables = { + colorPrimary: 'var(--color-primary)', + }; + + const result = removeInvalidValues(variables); + expect(result).toEqual({ colorPrimary: 'var(--color-primary)' }); + }); + + it('should remove invalid string values if modern color support is not present', () => { + mockModernColorSupport.mockReturnValue(false); + const variables = { + colorPrimary: 'var(--color-primary)', + colorDanger: '#ff0000', + }; + + const result = removeInvalidValues(variables); + expect(result).toEqual({ colorDanger: '#ff0000' }); + }); + + it('should remove invalid object values if modern color support is not present', () => { + mockModernColorSupport.mockReturnValue(false); + const variables = { + colorPrimary: { + 400: 'var(--color-primary-500)', + 500: '#fff', + }, + colorDanger: { + 500: '#ff0000', + }, + }; + + const result = removeInvalidValues(variables); + expect(result).toEqual({ colorDanger: { 500: '#ff0000' } }); + }); +}); diff --git a/packages/clerk-js/src/ui/customizables/parseVariables.ts b/packages/clerk-js/src/ui/customizables/parseVariables.ts index 5afe51f7c11..9a8a49017a7 100644 --- a/packages/clerk-js/src/ui/customizables/parseVariables.ts +++ b/packages/clerk-js/src/ui/customizables/parseVariables.ts @@ -2,46 +2,89 @@ 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 { cssSupports } from '../utils/cssSupports'; import { fromEntries } from '../utils/fromEntries'; import { removeUndefinedProps } from '../utils/removeUndefinedProps'; export const createColorScales = (theme: Theme) => { - const variables = theme.variables || {}; + const variables = removeInvalidValues(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, 0.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 removeInvalidValues = (variables: NonNullable): NonNullable => { + // Check for modern color support. If present, we can simply return the variables as-is since we support everything + // CSS supports. + if (cssSupports.modernColor()) { + return variables; + } + + // If not, we need to remove any values that are specified with CSS variables, as our color scale generation only + // supports CSS variables using modern CSS functionality. + const validVariables: Theme['variables'] = Object.fromEntries( + Object.entries(variables).filter(([key, value]) => { + if (typeof value === 'string') { + const isValid = !value.startsWith('var('); + if (!isValid) { + console.warn( + `Invalid color value: ${value} for key: ${key}, using default value instead. Using CSS variables is not supported in this browser.`, + ); + } + return isValid; + } + + if (typeof value === 'object') { + return Object.entries(value).every(([key, value]) => { + if (typeof value !== 'string') return true; + + const isValid = !value.startsWith('var('); + if (!isValid) { + console.warn( + `Invalid color value: ${value} for key: ${key}, using default value instead. Using CSS variables is not supported in this browser.`, + ); + } + + return isValid; + }); + } + + return false; + }), + ); + + return validVariables; }; export const createRadiiUnits = (theme: Theme) => { @@ -51,12 +94,11 @@ export const createRadiiUnits = (theme: Theme) => { } const md = borderRadius === 'none' ? '0' : borderRadius; - const { numericValue, unit = 'rem' } = splitCssUnit(md); return { - sm: (numericValue * 0.66).toString() + unit, + sm: `calc(${md} * 0.66)`, md, - lg: (numericValue * 1.33).toString() + unit, - xl: (numericValue * 2).toString() + unit, + lg: `calc(${md} * 1.33)`, + xl: `calc(${md} * 2)`, }; }; @@ -65,12 +107,11 @@ export const createSpaceScale = (theme: Theme) => { if (spacingUnit === undefined) { return; } - const { numericValue, unit } = splitCssUnit(spacingUnit); return fromEntries( spaceScaleKeys.map(k => { const num = Number.parseFloat(k.replace('x', '.')); const percentage = (num / 0.5) * 0.125; - return [k, `${numericValue * percentage}${unit}`]; + return [k, `calc(${spacingUnit} * ${percentage})`]; }), ); }; @@ -83,13 +124,12 @@ export const createFontSizeScale = (theme: Theme): Record { const { fontFamily, fontFamilyButtons } = theme.variables || {}; return removeUndefinedProps({ main: fontFamily, buttons: fontFamilyButtons }); }; - -const splitCssUnit = (str: string) => { - const numericValue = Number.parseFloat(str); - const unit = str.replace(numericValue.toString(), '') || undefined; - return { numericValue, unit }; -}; diff --git a/packages/clerk-js/src/ui/elements/Card/CardFooter.tsx b/packages/clerk-js/src/ui/elements/Card/CardFooter.tsx index e0410211e9c..cd21424ede7 100644 --- a/packages/clerk-js/src/ui/elements/Card/CardFooter.tsx +++ b/packages/clerk-js/src/ui/elements/Card/CardFooter.tsx @@ -49,7 +49,7 @@ export const CardFooter = React.forwardRef((pro elementDescriptor={descriptors.footer} sx={[ t => ({ - marginTop: `-${t.space.$2}`, + marginTop: `calc(${t.space.$2} * -1)`, paddingTop: t.space.$2, background: common.mergedColorsBackground( colors.setAlpha(t.colors.$colorBackground, 1), diff --git a/packages/clerk-js/src/ui/elements/CodeControl.tsx b/packages/clerk-js/src/ui/elements/CodeControl.tsx index 7e5118ed273..77ee4ac3366 100644 --- a/packages/clerk-js/src/ui/elements/CodeControl.tsx +++ b/packages/clerk-js/src/ui/elements/CodeControl.tsx @@ -336,7 +336,7 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { hasError={feedbackType === 'error'} elementDescriptor={descriptors.otpCodeFieldInputs} gap={2} - sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `-${t.space.$1}`, ...centerSx })} + sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `calc(${t.space.$1} * -1)`, ...centerSx })} role='group' aria-label='Verification code input' > diff --git a/packages/clerk-js/src/ui/elements/Navbar.tsx b/packages/clerk-js/src/ui/elements/Navbar.tsx index 789c136adfc..5abd510aefc 100644 --- a/packages/clerk-js/src/ui/elements/Navbar.tsx +++ b/packages/clerk-js/src/ui/elements/Navbar.tsx @@ -159,7 +159,7 @@ const NavbarContainer = ( t.colors.$neutralAlpha50, ), padding: `${t.space.$6} ${t.space.$5} ${t.space.$4} ${t.space.$3}`, - marginRight: `-${t.space.$2}`, + marginRight: `calc(${t.space.$2} * -1)`, color: t.colors.$colorText, justifyContent: 'space-between', })} @@ -332,7 +332,7 @@ export const NavbarMenuButtonRow = ({ navbarTitleLocalizationKey, ...props }: Na t.colors.$neutralAlpha50, ), padding: `${t.space.$2} ${t.space.$3} ${t.space.$4} ${t.space.$3}`, - marginBottom: `-${t.space.$2}`, + marginBottom: `calc(${t.space.$2} * -1)`, [mqu.md]: { display: 'flex', }, diff --git a/packages/clerk-js/src/ui/elements/PopoverCard.tsx b/packages/clerk-js/src/ui/elements/PopoverCard.tsx index 2a00474443c..f9dd69aa961 100644 --- a/packages/clerk-js/src/ui/elements/PopoverCard.tsx +++ b/packages/clerk-js/src/ui/elements/PopoverCard.tsx @@ -85,7 +85,7 @@ const PopoverCardFooter = (props: PropsOfComponent) => { colors.setAlpha(t.colors.$colorBackground, 1), t.colors.$neutralAlpha50, ), - marginTop: `-${t.space.$2}`, + marginTop: `calc(${t.space.$2} * -1)`, paddingTop: t.space.$2, '&:empty': { padding: 0, 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/__tests__/cssVariables.spec.ts b/packages/clerk-js/src/ui/utils/__tests__/cssVariables.spec.ts new file mode 100644 index 00000000000..83a57ed898a --- /dev/null +++ b/packages/clerk-js/src/ui/utils/__tests__/cssVariables.spec.ts @@ -0,0 +1,522 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + extractCSSVariableValue, + extractCSSVariableValueWithFallback, + extractMultipleCSSVariables, + isCSSVariable, + resolveComputedCSSColor, + resolveComputedCSSProperty, + resolveCSSVariable, +} from '../cssVariables'; + +// Mock DOM APIs +const mockGetComputedStyle = vi.fn(); +const mockGetPropertyValue = vi.fn(); + +// Setup DOM mocks +Object.defineProperty(window, 'getComputedStyle', { + value: mockGetComputedStyle, + writable: true, +}); + +// Mock document.documentElement +Object.defineProperty(document, 'documentElement', { + value: { + style: {}, + }, + writable: true, +}); + +describe('CSS Variable Utilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Setup getComputedStyle mock + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: mockGetPropertyValue, + }); + mockGetPropertyValue.mockReturnValue(''); + }); + + describe('isCSSVariable', () => { + it('should return true for valid CSS variables', () => { + expect(isCSSVariable('var(--color)')).toBe(true); + expect(isCSSVariable('var(--primary-color)')).toBe(true); + expect(isCSSVariable('var(--color, red)')).toBe(true); + expect(isCSSVariable('var(--color, rgba(255, 0, 0, 0.5))')).toBe(true); + expect(isCSSVariable('var( --color )')).toBe(true); // with spaces + }); + + it('should return false for invalid CSS variables', () => { + expect(isCSSVariable('--color')).toBe(false); + expect(isCSSVariable('color')).toBe(false); + expect(isCSSVariable('red')).toBe(false); + expect(isCSSVariable('#ff0000')).toBe(false); + expect(isCSSVariable('rgb(255, 0, 0)')).toBe(false); + expect(isCSSVariable('var(color)')).toBe(false); // missing -- + expect(isCSSVariable('var(--)')).toBe(false); // empty variable name + }); + + it('should handle edge cases', () => { + expect(isCSSVariable('')).toBe(false); + expect(isCSSVariable(' ')).toBe(false); + // @ts-expect-error Testing runtime behavior + expect(isCSSVariable(null)).toBe(false); + // @ts-expect-error Testing runtime behavior + expect(isCSSVariable(undefined)).toBe(false); + }); + }); + + describe('extractCSSVariableValue', () => { + it('should extract values from different variable name formats', () => { + mockGetPropertyValue.mockReturnValue('red'); + + expect(extractCSSVariableValue('var(--color)')).toBe('red'); + expect(extractCSSVariableValue('--color')).toBe('red'); + expect(extractCSSVariableValue('color')).toBe('red'); + + expect(mockGetPropertyValue).toHaveBeenCalledWith('--color'); + }); + + it('should return null for non-existent variables', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(extractCSSVariableValue('--nonexistent')).toBe(null); + }); + + it('should trim whitespace from values', () => { + mockGetPropertyValue.mockReturnValue(' red '); + + expect(extractCSSVariableValue('--color')).toBe('red'); + }); + + it('should use custom element when provided', () => { + const mockElement = document.createElement('div'); + mockGetPropertyValue.mockReturnValue('blue'); + + extractCSSVariableValue('--color', mockElement); + + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockElement); + }); + + it('should use document.documentElement by default', () => { + mockGetPropertyValue.mockReturnValue('green'); + + extractCSSVariableValue('--color'); + + expect(mockGetComputedStyle).toHaveBeenCalledWith(document.documentElement); + }); + }); + + describe('extractCSSVariableValueWithFallback', () => { + it('should return variable value when found', () => { + mockGetPropertyValue.mockReturnValue('red'); + + expect(extractCSSVariableValueWithFallback('--color', 'blue')).toBe('red'); + }); + + it('should return fallback when variable not found', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(extractCSSVariableValueWithFallback('--color', 'blue')).toBe('blue'); + expect(extractCSSVariableValueWithFallback('--color', 42)).toBe(42); + expect(extractCSSVariableValueWithFallback('--color', null)).toBe(null); + }); + }); + + describe('extractMultipleCSSVariables', () => { + it('should extract multiple variables', () => { + mockGetPropertyValue.mockReturnValueOnce('red').mockReturnValueOnce('blue').mockReturnValueOnce(''); + + const result = extractMultipleCSSVariables(['--primary-color', '--secondary-color', '--nonexistent-color']); + + expect(result).toEqual({ + '--primary-color': 'red', + '--secondary-color': 'blue', + '--nonexistent-color': null, + }); + }); + + it('should handle empty array', () => { + const result = extractMultipleCSSVariables([]); + expect(result).toEqual({}); + }); + }); + + describe('resolveCSSVariable', () => { + it('should resolve CSS variables with values', () => { + mockGetPropertyValue.mockReturnValue('red'); + + expect(resolveCSSVariable('var(--color)')).toBe('red'); + }); + + it('should return fallback when variable not found', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(resolveCSSVariable('var(--color, blue)')).toBe('blue'); + expect(resolveCSSVariable('var(--color, rgba(255, 0, 0, 0.5))')).toBe('rgba(255, 0, 0, 0.5)'); + }); + + it('should return null when variable not found and no fallback', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(resolveCSSVariable('var(--color)')).toBe(null); + }); + + it('should return null for non-CSS variables', () => { + expect(resolveCSSVariable('red')).toBe(null); + expect(resolveCSSVariable('#ff0000')).toBe(null); + expect(resolveCSSVariable('--color')).toBe(null); + }); + + it('should handle whitespace in fallback values', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(resolveCSSVariable('var(--color, blue )')).toBe('blue'); + }); + + it('should use custom element when provided', () => { + const mockElement = document.createElement('div'); + mockGetPropertyValue.mockReturnValue('purple'); + + const result = resolveCSSVariable('var(--color)', mockElement); + + expect(result).toBe('purple'); + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockElement); + }); + }); + + describe('resolveComputedCSSProperty', () => { + const mockElement = { + appendChild: vi.fn(), + removeChild: vi.fn(), + } as any; + + const mockCreatedElement = { + style: { + setProperty: vi.fn(), + }, + } as any; + + const mockGetComputedStyle = vi.fn(); + const mockCreateElement = vi.fn(); + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Mock document.createElement + Object.defineProperty(document, 'createElement', { + value: mockCreateElement, + writable: true, + }); + + // Mock window.getComputedStyle + Object.defineProperty(window, 'getComputedStyle', { + value: mockGetComputedStyle, + writable: true, + }); + + // Setup createElement to return our mock element + mockCreateElement.mockReturnValue(mockCreatedElement); + + // Setup getComputedStyle to return mock styles + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('resolved-value'), + }); + }); + + it('should resolve a basic CSS property', () => { + const result = resolveComputedCSSProperty(mockElement, 'font-weight', '400'); + + expect(mockCreateElement).toHaveBeenCalledWith('div'); + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('font-weight', '400'); + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.removeChild).toHaveBeenCalledWith(mockCreatedElement); + expect(result).toBe('resolved-value'); + }); + + it('should resolve CSS variables', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('16px'), + }); + + const result = resolveComputedCSSProperty(mockElement, 'font-size', 'var(--font-size-base)'); + + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('font-size', 'var(--font-size-base)'); + expect(result).toBe('16px'); + }); + + it('should handle font-weight properties', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('700'), + }); + + const result = resolveComputedCSSProperty(mockElement, 'font-weight', 'var(--font-weight-bold)'); + + expect(result).toBe('700'); + }); + + it('should handle border-radius properties', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('8px'), + }); + + const result = resolveComputedCSSProperty(mockElement, 'border-radius', 'var(--border-radius-lg)'); + + expect(result).toBe('8px'); + }); + + it('should handle spacing/padding properties', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('4px'), + }); + + const result = resolveComputedCSSProperty(mockElement, 'padding', 'var(--space-1)'); + + expect(result).toBe('4px'); + }); + + it('should trim whitespace from resolved values', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue(' 500 '), + }); + + const result = resolveComputedCSSProperty(mockElement, 'font-weight', 'var(--font-weight-medium)'); + + expect(result).toBe('500'); + }); + + it('should handle multiple property types in sequence', () => { + const properties = [ + { name: 'font-size', value: 'var(--text-lg)', expected: '18px' }, + { name: 'font-weight', value: 'var(--font-bold)', expected: '700' }, + { name: 'border-radius', value: 'var(--radius-md)', expected: '6px' }, + ]; + + properties.forEach(({ name, value, expected }) => { + mockGetComputedStyle.mockReturnValueOnce({ + getPropertyValue: vi.fn().mockReturnValue(expected), + }); + + const result = resolveComputedCSSProperty(mockElement, name, value); + expect(result).toBe(expected); + }); + + expect(mockCreateElement).toHaveBeenCalledTimes(3); + }); + + it('should properly clean up DOM elements', () => { + resolveComputedCSSProperty(mockElement, 'font-size', '14px'); + + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.removeChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.appendChild).toHaveBeenCalledBefore(mockElement.removeChild); + }); + }); + + describe('resolveComputedCSSColor', () => { + const mockElement = { + appendChild: vi.fn(), + removeChild: vi.fn(), + } as any; + + let mockCanvasInstance: any; + + const createMockCanvas = () => ({ + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue(mockCanvasContext), + }); + + const mockCanvasContext = { + fillStyle: '', + fillRect: vi.fn(), + getImageData: vi.fn(), + } as any; + + const mockCreatedElement = { + style: { + setProperty: vi.fn(), + }, + } as any; + + const mockCreateElement = vi.fn(); + const mockGetComputedStyle = vi.fn(); + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Mock document.createElement + Object.defineProperty(document, 'createElement', { + value: mockCreateElement, + writable: true, + }); + + // Mock window.getComputedStyle + Object.defineProperty(window, 'getComputedStyle', { + value: mockGetComputedStyle, + writable: true, + }); + + // Setup createElement to return appropriate mocks + mockCreateElement.mockImplementation((tagName: string) => { + if (tagName === 'div') { + return mockCreatedElement; + } + if (tagName === 'canvas') { + mockCanvasInstance = createMockCanvas(); + return mockCanvasInstance; + } + return {}; + }); + + // Setup getComputedStyle to return mock styles + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(255, 0, 0)'), + }); + + // Setup canvas context + mockCanvasContext.getImageData.mockReturnValue({ + data: [255, 0, 0, 255], // Red color + }); + }); + + it('should resolve a basic color to hex format', () => { + const result = resolveComputedCSSColor(mockElement, 'red'); + + expect(mockCreateElement).toHaveBeenCalledWith('div'); + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('color', 'red'); + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.removeChild).toHaveBeenCalledWith(mockCreatedElement); + expect(result).toBe('#ff0000'); + }); + + it('should handle CSS variables', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(0, 128, 255)'), + }); + mockCanvasContext.getImageData.mockReturnValue({ + data: [0, 128, 255, 255], + }); + + const result = resolveComputedCSSColor(mockElement, 'var(--primary-color)'); + + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('color', 'var(--primary-color)'); + expect(result).toBe('#0080ff'); + }); + + it('should use custom background color', () => { + const result = resolveComputedCSSColor(mockElement, 'blue', 'black'); + + expect(mockCanvasContext.fillRect).toHaveBeenCalledWith(0, 0, 1, 1); + expect(result).toBe('#ff0000'); + }); + + it('should default to white background when not specified', () => { + const result = resolveComputedCSSColor(mockElement, 'green'); + + expect(mockCanvasContext.fillRect).toHaveBeenCalledWith(0, 0, 1, 1); + expect(result).toBe('#ff0000'); + }); + + it('should handle canvas context creation failure', () => { + mockCanvasInstance = createMockCanvas(); + mockCanvasInstance.getContext.mockReturnValue(null); + mockCreateElement.mockImplementation((tagName: string) => { + if (tagName === 'div') { + return mockCreatedElement; + } + if (tagName === 'canvas') { + return mockCanvasInstance; + } + return {}; + }); + + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(255, 0, 0)'), + }); + + const result = resolveComputedCSSColor(mockElement, 'red'); + + expect(result).toBe('rgb(255, 0, 0)'); + }); + + it('should properly format single-digit hex values', () => { + mockCanvasContext.getImageData.mockReturnValue({ + data: [15, 5, 10, 255], // RGB values that would be single digit in hex + }); + + const result = resolveComputedCSSColor(mockElement, 'red'); + + // Should pad single digits with 0 + expect(result).toBe('#0f050a'); + }); + + it('should handle rgba colors with transparency', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgba(255, 128, 64, 0.5)'), + }); + mockCanvasContext.getImageData.mockReturnValue({ + data: [255, 128, 64, 128], // With alpha + }); + + const result = resolveComputedCSSColor(mockElement, 'rgba(255, 128, 64, 0.5)'); + + expect(result).toBe('#ff8040'); + }); + + it('should handle complex CSS color functions', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(120, 60, 180)'), + }); + mockCanvasContext.getImageData.mockReturnValue({ + data: [120, 60, 180, 255], + }); + + const result = resolveComputedCSSColor(mockElement, 'hsl(270, 50%, 47%)'); + + expect(result).toBe('#783cb4'); + }); + + it('should create canvas with correct dimensions', () => { + resolveComputedCSSColor(mockElement, 'red'); + + const canvasCall = mockCreateElement.mock.calls.find(call => call[0] === 'canvas'); + expect(canvasCall).toBeDefined(); + + // Verify canvas setup + expect(mockCanvasInstance.width).toBe(1); + expect(mockCanvasInstance.height).toBe(1); + }); + + it('should call getImageData with correct parameters', () => { + resolveComputedCSSColor(mockElement, 'blue'); + + expect(mockCanvasContext.getImageData).toHaveBeenCalledWith(0, 0, 1, 1); + }); + + it('should properly clean up DOM element', () => { + resolveComputedCSSColor(mockElement, 'purple'); + + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.removeChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.appendChild).toHaveBeenCalledBefore(mockElement.removeChild); + }); + + it('should set color style on temporary element', () => { + const testColor = 'var(--test-color)'; + + resolveComputedCSSColor(mockElement, testColor); + + expect(mockCreateElement).toHaveBeenCalledWith('div'); + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('color', testColor); + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockCreatedElement); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/__tests__/normalizeColorString.spec.ts b/packages/clerk-js/src/ui/utils/__tests__/normalizeColorString.spec.ts deleted file mode 100644 index cc2ab505544..00000000000 --- a/packages/clerk-js/src/ui/utils/__tests__/normalizeColorString.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import { normalizeColorString } from '../normalizeColorString'; - -describe('normalizeColorString', () => { - beforeEach(() => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) as vi.Mock; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - // Hex color tests - test('should keep 3-char hex colors unchanged', () => { - expect(normalizeColorString('#123')).toBe('#123'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should keep 6-char hex colors unchanged', () => { - expect(normalizeColorString('#123456')).toBe('#123456'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should remove alpha from 4-char hex colors', () => { - expect(normalizeColorString('#123F')).toBe('#123'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should remove alpha from 8-char hex colors', () => { - expect(normalizeColorString('#12345678')).toBe('#123456'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should warn for invalid hex formats but return the original', () => { - expect(normalizeColorString('#12')).toBe('#12'); - expect(console.warn).toHaveBeenCalledTimes(1); - - (console.warn as vi.Mock).mockClear(); - expect(normalizeColorString('#12345')).toBe('#12345'); - expect(console.warn).toHaveBeenCalledTimes(1); - }); - - // RGB color tests - test('should keep rgb format unchanged but normalize whitespace', () => { - expect(normalizeColorString('rgb(255, 0, 0)')).toBe('rgb(255, 0, 0)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should convert rgba to rgb', () => { - expect(normalizeColorString('rgba(255, 0, 0, 0.5)')).toBe('rgb(255, 0, 0)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should handle rgb with whitespace variations', () => { - expect(normalizeColorString('rgb(255,0,0)')).toBe('rgb(255, 0, 0)'); - expect(normalizeColorString('rgb( 255 , 0 , 0 )')).toBe('rgb(255, 0, 0)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - // HSL color tests - test('should keep hsl format unchanged but normalize whitespace', () => { - expect(normalizeColorString('hsl(120, 100%, 50%)')).toBe('hsl(120, 100%, 50%)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should convert hsla to hsl', () => { - expect(normalizeColorString('hsla(120, 100%, 50%, 0.8)')).toBe('hsl(120, 100%, 50%)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should handle hsl with whitespace variations', () => { - expect(normalizeColorString('hsl(120,100%,50%)')).toBe('hsl(120, 100%, 50%)'); - expect(normalizeColorString('hsl( 120 , 100% , 50% )')).toBe('hsl(120, 100%, 50%)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - // Warning tests for invalid inputs - test('should warn for invalid color formats but return the original', () => { - expect(normalizeColorString('')).toBe(''); - expect(console.warn).toHaveBeenCalledTimes(1); - - (console.warn as vi.Mock).mockClear(); - expect(normalizeColorString('invalid')).toBe('invalid'); - expect(console.warn).toHaveBeenCalledTimes(1); - - (console.warn as vi.Mock).mockClear(); - expect(normalizeColorString('rgb(255,0)')).toBe('rgb(255,0)'); - expect(console.warn).toHaveBeenCalledTimes(1); - }); - - test('should warn for non-string inputs but return the original or empty string', () => { - expect(normalizeColorString(null as any)).toBe(''); - expect(console.warn).toHaveBeenCalledTimes(1); - - (console.warn as vi.Mock).mockClear(); - expect(normalizeColorString(123 as any)).toBe(123 as any); - expect(console.warn).toHaveBeenCalledTimes(1); - }); - - // Edge cases - test('should handle trimming whitespace', () => { - expect(normalizeColorString(' #123 ')).toBe('#123'); - expect(normalizeColorString('\n rgb(255, 0, 0) \t')).toBe('rgb(255, 0, 0)'); - expect(console.warn).not.toHaveBeenCalled(); - }); -}); 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..e4b38c5b02a --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/legacy.spec.ts @@ -0,0 +1,449 @@ +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 mockGetComputedStyle = vi.fn(); + const mockWindow = { + getComputedStyle: mockGetComputedStyle, + }; + + beforeEach(() => { + // @ts-ignore + global.window = mockWindow; + // @ts-ignore + global.getComputedStyle = mockGetComputedStyle; + // @ts-ignore + global.document = { + documentElement: document?.createElement?.('div') || {}, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + // @ts-ignore + global.window = undefined; + // @ts-ignore + global.getComputedStyle = undefined; + // @ts-ignore + global.document = undefined; + }); + + it('should resolve CSS variables with hex values', () => { + mockGetComputedStyle.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', () => { + mockGetComputedStyle.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', () => { + mockGetComputedStyle.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', () => { + mockGetComputedStyle.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', () => { + mockGetComputedStyle.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', () => { + mockGetComputedStyle.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; + // @ts-ignore + global.getComputedStyle = 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..8df9abee00a --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/scales.spec.ts @@ -0,0 +1,367 @@ +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); + }); + + it('should throw error when color scale object is missing 500 shade', () => { + const invalidScale = { '25': '#fef2f2', '100': '#fecaca', '600': '#dc2626' }; + + expect(() => generateAlphaScale(invalidScale as any)).toThrow('You need to provide at least the 500 shade'); + expect(() => generateLightnessScale(invalidScale as any)).toThrow('You need to provide at least the 500 shade'); + }); + }); + + 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..94f41fe9f20 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts @@ -0,0 +1,188 @@ +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(), + }, +})); + +// Mock DOM APIs +const mockGetComputedStyle = vi.fn(); +const mockGetPropertyValue = vi.fn(); + +// Get the mocked functions +const mockRelativeColorSyntax = vi.mocked(cssSupports.relativeColorSyntax); +const mockColorMix = vi.mocked(cssSupports.colorMix); + +// Setup DOM mocks +Object.defineProperty(window, 'getComputedStyle', { + value: mockGetComputedStyle, + writable: true, +}); + +// Mock document.documentElement +Object.defineProperty(document, 'documentElement', { + value: { + style: {}, + }, + writable: true, +}); + +describe('Color Utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + // Setup getComputedStyle mock + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: mockGetPropertyValue, + }); + mockGetPropertyValue.mockReturnValue(''); + }); + + 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 94% rename from packages/clerk-js/src/ui/utils/colors.ts rename to packages/clerk-js/src/ui/utils/colors/legacy.ts index d3ee83e7550..5e59377c60b 100644 --- a/packages/clerk-js/src/ui/utils/colors.ts +++ b/packages/clerk-js/src/ui/utils/colors/legacy.ts @@ -10,6 +10,8 @@ import type { HslaColor, HslaColorString } from '@clerk/types'; +import { resolveCSSVariable } from '../cssVariables'; + const abbrRegex = /^#([a-f0-9]{3,4})$/i; const hexRegex = /^#([a-f0-9]{6})([a-f0-9]{2})?$/i; const rgbaRegex = @@ -255,17 +257,21 @@ const hslaColorToHslaString = ({ h, s, l, a }: HslaColor): HslaColorString => { }; 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..bc040ca894e --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/scales.ts @@ -0,0 +1,291 @@ +import type { ColorScale, CssColorOrAlphaScale, CssColorOrScale, HslaColorString } from '@clerk/types'; + +import { cssSupports } from '../cssSupports'; +import { ALL_SHADES, 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, + }; + } + + // If it's an object, check if it has any valid shade keys + if (typeof color === 'object') { + const hasValidShadeKeys = ALL_SHADES.some((shade: keyof ColorScale) => color[shade]); + + if (hasValidShadeKeys && !color['500']) { + // Has valid shade keys but missing 500 - this is an error + throw new Error('You need to provide at least the 500 shade'); + } + + // No valid shade keys - treat as invalid input + if (!hasValidShadeKeys) { + return null; + } + } + + 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; +}; diff --git a/packages/clerk-js/src/ui/utils/cssVariables.ts b/packages/clerk-js/src/ui/utils/cssVariables.ts new file mode 100644 index 00000000000..40ce87f6353 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/cssVariables.ts @@ -0,0 +1,235 @@ +/** + * Extracts the computed value of a CSS custom property (CSS variable) + * @param variableName - The CSS variable name in any of these formats: + * - 'var(--color)' + * - '--color' + * - 'color' (will be prefixed with --) + * @param element - Optional element to get the variable from (defaults to document.documentElement) + * @returns The computed CSS variable value as a string, or null if not found + * @example + * const colorValue = extractCSSVariableValue('var(--color)'); // "red" + * const colorValue2 = extractCSSVariableValue('--color'); // "red" + * const colorValue3 = extractCSSVariableValue('color'); // "red" + * const colorValue4 = extractCSSVariableValue('--nonexistent'); // null + * const colorValue5 = extractCSSVariableValue('--nonexistent', document.body); // null + * const colorValue6 = extractCSSVariableValue('--nonexistent', document.body, '#000000'); // "#000000" + */ +export function extractCSSVariableValue(variableName: string, element?: Element): string | null { + // Handle both browser and server environments + if (typeof window === 'undefined' || typeof getComputedStyle === 'undefined') { + return null; + } + + // Handle different input formats + let cleanVariableName: string; + + if (variableName.startsWith('var(') && variableName.endsWith(')')) { + // Extract from 'var(--color)' format + cleanVariableName = variableName.slice(4, -1).trim(); + } else if (variableName.startsWith('--')) { + // Already in '--color' format + cleanVariableName = variableName; + } else { + // Add -- prefix to 'color' format + cleanVariableName = `--${variableName}`; + } + + // Use provided element or default to document root + // Handle cases where document might not be available or element might be null + let targetElement: Element; + try { + if (element) { + targetElement = element; + } else if (typeof document !== 'undefined' && document.documentElement) { + targetElement = document.documentElement; + } else { + return null; + } + } catch { + return null; + } + + // Get computed style and extract the variable value + try { + const computedStyle = getComputedStyle(targetElement); + const value = computedStyle.getPropertyValue(cleanVariableName).trim(); + return value || null; + } catch { + return null; + } +} + +/** + * Alternative version that also accepts fallback values + * @param variableName - The CSS variable name + * @param fallback - Fallback value if variable is not found + * @param element - Optional element to get the variable from + * @returns The CSS variable value or fallback + */ +export function extractCSSVariableValueWithFallback( + variableName: string, + fallback: T, + element?: Element, +): string | T { + const value = extractCSSVariableValue(variableName, element); + return value || fallback; +} + +/** + * Gets multiple CSS variables at once + * @param variableNames - Array of CSS variable names + * @param element - Optional element to get variables from + * @returns Object mapping variable names to their values + * @example + * const variables = extractMultipleCSSVariables([ + * '--primary-color', + * '--secondary-color', + * '--font-size' + * ]); + */ +export function extractMultipleCSSVariables(variableNames: string[], element?: Element): Record { + return variableNames.reduce( + (acc, varName) => { + acc[varName] = extractCSSVariableValue(varName, element); + return acc; + }, + {} as Record, + ); +} + +/** + * Checks if a given value represents a CSS variable (var() function) + * @param value - The value to check + * @returns True if the value is a CSS variable, false otherwise + * @example + * isCSSVariable('var(--color)'); // true + * isCSSVariable('var(--color, red)'); // true + * isCSSVariable('--color'); // false + * isCSSVariable('red'); // false + * isCSSVariable('#ff0000'); // false + */ +export function isCSSVariable(value: string): boolean { + if (!value || typeof value !== 'string') { + return false; + } + + const trimmed = value.trim(); + + // Must start with var( and end with ) + if (!trimmed.startsWith('var(') || !trimmed.endsWith(')')) { + return false; + } + + // Extract content between var( and ) + const content = trimmed.slice(4, -1).trim(); + + // Must start with -- + if (!content.startsWith('--')) { + return false; + } + + // Find the variable name (everything before the first comma, if any) + const commaIndex = content.indexOf(','); + const variableName = commaIndex === -1 ? content : content.slice(0, commaIndex).trim(); + + // Variable name must be valid (--something) + return /^--[a-zA-Z0-9-_]+$/.test(variableName); +} + +/** + * Resolves a CSS variable to its computed value, with fallback support + * Handles var() syntax and extracts variable name and fallback value + * @param value - The CSS variable string (e.g., 'var(--color, red)') + * @param element - Optional element to get the variable from + * @returns The resolved value or null if not found and no fallback provided + * @example + * resolveCSSVariable('var(--primary-color)'); // "blue" (if --primary-color is blue) + * resolveCSSVariable('var(--missing-color, red)'); // "red" (fallback) + * resolveCSSVariable('var(--missing-color)'); // null + * resolveCSSVariable('red'); // null (not a CSS variable) + */ +export function resolveCSSVariable(value: string, element?: Element): string | null { + if (!isCSSVariable(value)) { + return null; + } + + // Extract content between var( and ) + const content = value.trim().slice(4, -1).trim(); + + // Find the variable name and fallback value + const commaIndex = content.indexOf(','); + let variableName: string; + let fallbackValue: string | null = null; + + if (commaIndex === -1) { + variableName = content; + } else { + variableName = content.slice(0, commaIndex).trim(); + fallbackValue = content.slice(commaIndex + 1).trim(); + } + + // Try to get the resolved variable value + const resolvedValue = extractCSSVariableValue(variableName, element); + + if (resolvedValue) { + return resolvedValue; + } + + // If variable couldn't be resolved, return the fallback value if provided + return fallbackValue; +} + +/** + * Resolves a CSS property to its computed value, in the context of a DOM element + * This is used to resolve CSS variables to their computed values, in the context of a DOM element. + * + * @param parentElement - The parent element to resolve the property in the context of + * @param propertyName - The CSS property name (e.g., 'color', 'font-weight', 'font-size') + * @param propertyValue - The property value to resolve (can be a CSS variable) + * @returns The resolved property value as a string + */ +export function resolveComputedCSSProperty( + parentElement: HTMLElement, + propertyName: string, + propertyValue: string, +): string { + const element = document.createElement('div'); + element.style.setProperty(propertyName, propertyValue); + parentElement.appendChild(element); + const computedStyle = window.getComputedStyle(element); + const computedValue = computedStyle.getPropertyValue(propertyName); + parentElement.removeChild(element); + return computedValue.trim(); +} + +/** + * Resolves a color to its computed value, in the context of a DOM element + * This is used to resolve CSS variables to their computed values, in the context of a DOM element to support passing + * CSS variables to Stripe Elements. + * + * @param parentElement - The parent element to resolve the color in the context of + * @param color - The color to resolve + * @param backgroundColor - The background color to use for the canvas, this is used to ensure colors that + * contain an alpha value mix together correctly. So the output matches the alpha usage in the CSS. + * @returns The resolved color as a hex string + */ +export function resolveComputedCSSColor(parentElement: HTMLElement, color: string, backgroundColor: string = 'white') { + const computedColor = resolveComputedCSSProperty(parentElement, 'color', color); + const computedBackgroundColor = resolveComputedCSSProperty(parentElement, 'color', backgroundColor); + + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + return computedColor; + } + + ctx.fillStyle = computedBackgroundColor; + ctx.fillRect(0, 0, 1, 1); + ctx.fillStyle = computedColor; + ctx.fillRect(0, 0, 1, 1); + const { data } = ctx.getImageData(0, 0, 1, 1); + return `#${data[0].toString(16).padStart(2, '0')}${data[1].toString(16).padStart(2, '0')}${data[2].toString(16).padStart(2, '0')}`; +} diff --git a/packages/clerk-js/src/ui/utils/normalizeColorString.ts b/packages/clerk-js/src/ui/utils/normalizeColorString.ts deleted file mode 100644 index 69602749402..00000000000 --- a/packages/clerk-js/src/ui/utils/normalizeColorString.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Normalizes color format strings by removing alpha values if present - * Handles conversions between: - * - Hex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA → #RGB or #RRGGBB - * - RGB: rgb(r, g, b), rgba(r, g, b, a) → rgb(r, g, b) - * - HSL: hsl(h, s%, l%), hsla(h, s%, l%, a) → hsl(h, s%, l%) - * - * @param colorString - The color string to normalize - * @returns The normalized color string without alpha components, or the original string if invalid - */ -export function normalizeColorString(colorString: string): string { - if (!colorString || typeof colorString !== 'string') { - console.warn('Invalid input: color string must be a non-empty string'); - return colorString || ''; - } - - const trimmed = colorString.trim(); - - // Handle empty strings - if (trimmed === '') { - console.warn('Invalid input: color string cannot be empty'); - return ''; - } - - // Handle hex colors - if (trimmed.startsWith('#')) { - // Validate hex format - if (!/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) { - console.warn(`Invalid hex color format: ${colorString}`); - return trimmed; - } - - // #RGBA format (4 chars) - if (trimmed.length === 5) { - return '#' + trimmed.slice(1, 4); - } - // #RRGGBBAA format (9 chars) - if (trimmed.length === 9) { - return '#' + trimmed.slice(1, 7); - } - // Regular hex formats (#RGB, #RRGGBB) - return trimmed; - } - - // Handle rgb/rgba - if (/^rgba?\(/.test(trimmed)) { - // Extract and normalize rgb values - const rgbMatch = trimmed.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); - if (rgbMatch) { - // Already in rgb format, normalize whitespace - return `rgb(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]})`; - } - - // Extract and normalize rgba values - const rgbaMatch = trimmed.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/); - if (rgbaMatch) { - // Convert rgba to rgb, normalize whitespace - return `rgb(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]})`; - } - - console.warn(`Invalid RGB/RGBA format: ${colorString}`); - return trimmed; - } - - // Handle hsl/hsla - if (/^hsla?\(/.test(trimmed)) { - // Extract and normalize hsl values - const hslMatch = trimmed.match(/^hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)$/); - if (hslMatch) { - // Already in hsl format, normalize whitespace - return `hsl(${hslMatch[1]}, ${hslMatch[2]}%, ${hslMatch[3]}%)`; - } - - // Extract and normalize hsla values - const hslaMatch = trimmed.match(/^hsla\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*,\s*([\d.]+)\s*\)$/); - if (hslaMatch) { - // Convert hsla to hsl, normalize whitespace - return `hsl(${hslaMatch[1]}, ${hslaMatch[2]}%, ${hslaMatch[3]}%)`; - } - - console.warn(`Invalid HSL/HSLA format: ${colorString}`); - return trimmed; - } - - // If we reach here, the input is not a recognized color format - console.warn(`Unrecognized color format: ${colorString}`); - return trimmed; -}