diff --git a/.changeset/busy-impalas-wink.md b/.changeset/busy-impalas-wink.md new file mode 100644 index 00000000000..eb4502e601a --- /dev/null +++ b/.changeset/busy-impalas-wink.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/themes': minor +'@clerk/types': minor +--- + +TODO diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index dc2341dcfb5..24e67cc466e 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -6,7 +6,7 @@ { "path": "./dist/clerk.headless*.js", "maxSize": "55.2KB" }, { "path": "./dist/ui-common*.js", "maxSize": "113KB" }, { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" }, - { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, + { "path": "./dist/vendors*.js", "maxSize": "40.6KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx b/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx index dfedab25078..4d6f5b075dc 100644 --- a/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx +++ b/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx @@ -1,4 +1,7 @@ import { createContextAndHook, useDeepEqualMemo } from '@clerk/shared/react'; +import type { BaseTheme, BaseThemeTaggedType } from '@clerk/types'; +// eslint-disable-next-line no-restricted-imports +import { css, Global } from '@emotion/react'; import React from 'react'; import type { AppearanceCascade, ParsedAppearance } from './parseAppearance'; @@ -17,7 +20,34 @@ const AppearanceProvider = (props: AppearanceProviderProps) => { return { value }; }, [props.appearance, props.globalAppearance]); - return {props.children}; + // Extract global CSS from theme if it exists + const getGlobalCss = (theme: BaseTheme | BaseTheme[] | undefined) => { + if ( + typeof theme === 'object' && + !Array.isArray(theme) && + '__type' in theme && + theme.__type === 'prebuilt_appearance' + ) { + // Cast to the specific type that includes __internal_globalCss + return (theme as BaseThemeTaggedType & { __internal_globalCss?: string }).__internal_globalCss; + } + return null; + }; + + const globalCss = getGlobalCss(props.appearance?.theme) || getGlobalCss(props.globalAppearance?.theme); + + return ( + + {globalCss && ( + + )} + {props.children} + + ); }; export { AppearanceProvider, useAppearance }; diff --git a/packages/themes/package.json b/packages/themes/package.json index 0282576c861..1318c507612 100644 --- a/packages/themes/package.json +++ b/packages/themes/package.json @@ -45,14 +45,17 @@ "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", "lint": "eslint src", - "lint:attw": "attw --pack . --exclude-entrypoints shadcn.css --profile node16" + "lint:attw": "attw --pack . --exclude-entrypoints shadcn.css --profile node16", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@clerk/types": "workspace:^", "tslib": "catalog:repo" }, "devDependencies": { - "tsup": "catalog:repo" + "tsup": "catalog:repo", + "vitest": "catalog:repo" }, "engines": { "node": ">=18.17.0" diff --git a/packages/themes/src/__tests__/createTheme.test.ts b/packages/themes/src/__tests__/createTheme.test.ts new file mode 100644 index 00000000000..507581b0aa8 --- /dev/null +++ b/packages/themes/src/__tests__/createTheme.test.ts @@ -0,0 +1,1079 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + convertTuplesToCssVariables, + createThemeFactory, + createThemeObject, + experimental_createTheme, + mergeThemeConfigurations, + normalizeDarkModeSelector, + resolveBaseTheme, + resolveElementsConfiguration, + toKebabCase, + transformSelectorForNesting, +} from '../createTheme'; + +describe('Helper Functions', () => { + describe('toKebabCase', () => { + it('should convert camelCase to kebab-case', () => { + expect(toKebabCase('colorBackground')).toBe('color-background'); + expect(toKebabCase('colorPrimaryForeground')).toBe('color-primary-foreground'); + expect(toKebabCase('fontFamily')).toBe('font-family'); + expect(toKebabCase('backgroundColor')).toBe('background-color'); + }); + + it('should handle single words', () => { + expect(toKebabCase('color')).toBe('color'); + }); + + it('should handle already lowercase strings', () => { + expect(toKebabCase('background')).toBe('background'); + }); + + it('should handle consecutive capitals', () => { + expect(toKebabCase('XMLParser')).toBe('x-m-l-parser'); + }); + + it('should not add leading hyphen for strings starting with uppercase', () => { + expect(toKebabCase('ColorBackground')).toBe('color-background'); + expect(toKebabCase('Primary')).toBe('primary'); + }); + }); + + describe('transformSelectorForNesting', () => { + it('should transform class selectors to use CSS nesting', () => { + expect(transformSelectorForNesting('.dark')).toBe('.dark &'); + expect(transformSelectorForNesting('.theme-dark')).toBe('.theme-dark &'); + expect(transformSelectorForNesting('.custom-selector')).toBe('.custom-selector &'); + }); + + it('should transform ID selectors to use CSS nesting', () => { + expect(transformSelectorForNesting('#dark')).toBe('#dark &'); + expect(transformSelectorForNesting('#theme-dark')).toBe('#theme-dark &'); + expect(transformSelectorForNesting('#custom-selector')).toBe('#custom-selector &'); + }); + + it('should not transform selectors that already contain &', () => { + expect(transformSelectorForNesting('.dark &')).toBe('.dark &'); + expect(transformSelectorForNesting('#theme &')).toBe('#theme &'); + expect(transformSelectorForNesting('.theme & .dark')).toBe('.theme & .dark'); + }); + + it('should not transform media queries', () => { + expect(transformSelectorForNesting('@media (prefers-color-scheme: dark)')).toBe( + '@media (prefers-color-scheme: dark)', + ); + expect(transformSelectorForNesting('@media screen and (max-width: 768px)')).toBe( + '@media screen and (max-width: 768px)', + ); + }); + + it('should not transform non-class/non-ID selectors', () => { + expect(transformSelectorForNesting('[data-theme="dark"]')).toBe('[data-theme="dark"]'); + expect(transformSelectorForNesting('html')).toBe('html'); + expect(transformSelectorForNesting('body')).toBe('body'); + }); + + it('should handle complex class selectors', () => { + expect(transformSelectorForNesting('.dark.active')).toBe('.dark.active &'); + expect(transformSelectorForNesting('.theme-dark .nested')).toBe('.theme-dark .nested &'); + }); + + it('should handle complex ID selectors', () => { + expect(transformSelectorForNesting('#dark-theme')).toBe('#dark-theme &'); + expect(transformSelectorForNesting('#theme .nested')).toBe('#theme .nested &'); + }); + }); + + describe('normalizeDarkModeSelector', () => { + it('should return provided selector when valid', () => { + expect(normalizeDarkModeSelector('.custom-dark')).toBe('.custom-dark'); + expect(normalizeDarkModeSelector('[data-theme="dark"]')).toBe('[data-theme="dark"]'); + expect(normalizeDarkModeSelector('.my-theme')).toBe('.my-theme'); + }); + + it('should opt out by default when no selector provided', () => { + expect(normalizeDarkModeSelector(undefined)).toBeNull(); // Light-only by default + expect(normalizeDarkModeSelector('')).toBeNull(); // Empty string opts out + expect(normalizeDarkModeSelector(' ')).toBeNull(); // Whitespace-only opts out + }); + + it('should return null when false is passed to opt out', () => { + expect(normalizeDarkModeSelector(false)).toBeNull(); + }); + + it('should trim whitespace', () => { + expect(normalizeDarkModeSelector(' .custom-dark ')).toBe('.custom-dark'); + }); + }); + + describe('convertTuplesToCssVariables', () => { + it('should process tuple variables and generate CSS with default selector', () => { + const variables = { + colorBackground: ['#ffffff', '#000000'], + colorPrimary: ['#blue', '#lightblue'], + fontFamily: 'Arial', // Non-tuple should pass through + }; + + const result = convertTuplesToCssVariables(variables, '.dark'); + + expect(result.processedVariables).toEqual({ + colorBackground: 'var(--clerk-color-background)', + colorPrimary: 'var(--clerk-color-primary)', + fontFamily: 'Arial', + }); + + expect(result.cssString).toContain(':root {'); + expect(result.cssString).toContain('--clerk-color-background: #ffffff;'); + expect(result.cssString).toContain('--clerk-color-primary: #blue;'); + expect(result.cssString).toContain('.dark {'); + expect(result.cssString).toContain('--clerk-color-background: #000000;'); + expect(result.cssString).toContain('--clerk-color-primary: #lightblue;'); + }); + + it('should generate CSS with custom dark mode selector', () => { + const variables = { + colorBackground: ['#fff', '#000'], + }; + + const result = convertTuplesToCssVariables(variables, '[data-theme="dark"]'); + + expect(result.cssString).toContain('[data-theme="dark"] {'); + expect(result.cssString).toContain('--clerk-color-background: #000;'); + }); + + it('should require explicit media query selector', () => { + const variables = { + colorBackground: ['#fff', '#000'], + }; + + // Must be explicit - no fallbacks + const result = convertTuplesToCssVariables(variables, '@media (prefers-color-scheme: dark)'); + + expect(result.cssString).toContain('@media (prefers-color-scheme: dark)'); + expect(result.cssString).toContain(':root {'); + expect(result.processedVariables.colorBackground).toBe('var(--clerk-color-background)'); + }); + + it('should handle media query selectors correctly', () => { + const variables = { + colorBackground: ['#fff', '#000'], + }; + + const result = convertTuplesToCssVariables(variables, '@media (prefers-color-scheme: dark)'); + + expect(result.cssString).toContain('@media (prefers-color-scheme: dark) {'); + expect(result.cssString).toContain(' :root {'); // Check for nested :root + expect(result.cssString).toContain(' --clerk-color-background: #000;'); + expect(result.cssString).toContain(' }'); // Closing :root + expect(result.cssString).toContain(' }'); // Closing media query + }); + + it('should opt out of CSS generation when darkModeSelector is null', () => { + const variables = { + colorBackground: ['#fff', '#000'], + colorPrimary: ['#blue', '#lightblue'], + colorSecondary: '#red', // Non-tuple should pass through + }; + + const result = convertTuplesToCssVariables(variables, null); + + expect(result.processedVariables).toEqual({ + colorBackground: '#fff', // Light value only + colorPrimary: '#blue', // Light value only + colorSecondary: '#red', // Non-tuple passes through + }); + expect(result.cssString).toBeNull(); // No CSS generated + }); + + it('should handle empty variables', () => { + const result = convertTuplesToCssVariables({}); + expect(result.processedVariables).toEqual({}); + expect(result.cssString).toBeNull(); + }); + + it('should handle non-tuple arrays gracefully', () => { + const variables = { + colorBackground: [], // Invalid tuple + colorPrimary: ['#blue'], // Single value + colorSecondary: ['#red', '#darkred'], // Valid tuple + }; + + const result = convertTuplesToCssVariables(variables, '.dark'); + + expect(result.processedVariables).toEqual({ + colorBackground: [], + colorPrimary: ['#blue'], + colorSecondary: 'var(--clerk-color-secondary)', + }); + expect(result.cssString).toContain('--clerk-color-secondary: #red;'); + expect(result.cssString).toContain('--clerk-color-secondary: #darkred;'); + }); + + it('should convert camelCase variable names to kebab-case CSS variables', () => { + const variables = { + colorPrimaryForeground: ['#000', '#fff'], + }; + + const result = convertTuplesToCssVariables(variables, '.dark'); + + expect(result.processedVariables.colorPrimaryForeground).toBe('var(--clerk-color-primary-foreground)'); + expect(result.cssString).toContain('--clerk-color-primary-foreground: #000;'); + expect(result.cssString).toContain('--clerk-color-primary-foreground: #fff;'); + }); + }); + + describe('resolveElementsConfiguration', () => { + it('should return undefined for no elements', () => { + expect(resolveElementsConfiguration(undefined, '.dark')).toBeUndefined(); + }); + + it('should return static elements as-is', () => { + const elements = { + button: { backgroundColor: 'red' }, + input: { borderColor: 'blue' }, + }; + + const result = resolveElementsConfiguration(elements, '.dark'); + expect(result).toEqual(elements); + }); + + it('should call function-based elements with transformed darkModeSelector', () => { + const elementsFn = vi.fn().mockReturnValue({ + button: { backgroundColor: 'red' }, + }); + + const result = resolveElementsConfiguration(elementsFn, '.custom-dark'); + + expect(elementsFn).toHaveBeenCalledWith('.custom-dark &'); // Now transformed with & + expect(result).toEqual({ button: { backgroundColor: 'red' } }); + }); + + it('should handle function errors gracefully', () => { + const elementsFn = vi.fn().mockImplementation(() => { + throw new Error('Function error'); + }); + + // Mock console.warn to avoid test output noise + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = resolveElementsConfiguration(elementsFn, '.dark'); + + expect(result).toBeUndefined(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Clerk Theme] Failed to call elements function with darkModeSelector:', + expect.any(Error), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should return undefined when darkModeSelector is null (opted out)', () => { + const elementsFn = vi.fn().mockReturnValue({ + button: { backgroundColor: 'red' }, + }); + + const result = resolveElementsConfiguration(elementsFn, null); + + expect(elementsFn).not.toHaveBeenCalled(); // Function should not be called when opted out + expect(result).toBeUndefined(); + }); + }); + + describe('resolveBaseTheme', () => { + it('should return undefined for no baseTheme', () => { + expect(resolveBaseTheme(undefined)).toBeUndefined(); + }); + + it('should return static theme as-is', () => { + const baseTheme = { + __type: 'prebuilt_appearance' as const, + variables: { colorPrimary: '#blue' }, + }; + + const result = resolveBaseTheme(baseTheme); + expect(result).toBe(baseTheme); + }); + + it('should call factory function with options', () => { + const baseThemeFactory = vi.fn().mockReturnValue({ + __type: 'prebuilt_appearance', + variables: { colorPrimary: '#green' }, + }); + + const options = { darkModeSelector: '.custom' }; + const result = resolveBaseTheme(baseThemeFactory as any, options); + + expect(baseThemeFactory).toHaveBeenCalledWith(options); + expect(result).toEqual({ + __type: 'prebuilt_appearance', + variables: { colorPrimary: '#green' }, + }); + }); + + it('should handle factory function errors gracefully', () => { + const baseThemeFactory = vi.fn().mockImplementation(() => { + throw new Error('Factory error'); + }); + + // Mock console.warn + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = resolveBaseTheme(baseThemeFactory as any); + + expect(result).toBe(baseThemeFactory); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Clerk Theme] Failed to call baseTheme factory function:', + expect.any(Error), + ); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('mergeThemeConfigurations', () => { + it('should merge variables from base and child themes', () => { + const baseTheme = { + __type: 'prebuilt_appearance' as const, + variables: { + colorPrimary: '#base-blue', + colorBackground: '#base-white', + }, + elements: { + button: { padding: '8px' }, + }, + }; + + const childConfig = { + variables: { + colorPrimary: '#child-red', // Override + colorSecondary: '#child-green', // Add new + }, + elements: { + input: { margin: '4px' }, // Add new + }, + }; + + const result = mergeThemeConfigurations(baseTheme, childConfig); + + expect(result.mergedVariables).toEqual({ + colorPrimary: '#child-red', // Overridden + colorBackground: '#base-white', // Inherited + colorSecondary: '#child-green', // Added + }); + + expect(result.mergedElements).toEqual({ + button: { padding: '8px' }, // Inherited + input: { margin: '4px' }, // Added + }); + + expect(result.mergedConfig).toEqual({ + __type: 'prebuilt_appearance', + variables: { + colorPrimary: '#child-red', // Override + colorSecondary: '#child-green', // Add new + }, + elements: { + input: { margin: '4px' }, // Add new + }, + }); + }); + + it('should handle missing base theme', () => { + const childConfig = { + variables: { colorPrimary: '#red' }, + elements: { button: { padding: '10px' } }, + }; + + const result = mergeThemeConfigurations(undefined, childConfig); + + expect(result.mergedVariables).toEqual({ colorPrimary: '#red' }); + expect(result.mergedElements).toEqual({ button: { padding: '10px' } }); + expect(result.mergedConfig).toEqual(childConfig); + }); + + it('should handle empty configurations', () => { + const result = mergeThemeConfigurations(undefined, {}); + + expect(result.mergedVariables).toBeUndefined(); + expect(result.mergedElements).toBeUndefined(); + expect(result.mergedConfig).toEqual({}); + }); + }); + + describe('createThemeObject', () => { + it('should create theme object with all properties', () => { + const config = { baseProperty: 'value' }; + const variables = { colorPrimary: '#red' }; + const elements = { button: { padding: '10px' } }; + const globalCss = ':root { --color: red; }'; + + const result = createThemeObject(config, variables, elements, globalCss); + + expect(result).toEqual({ + baseProperty: 'value', + __type: 'prebuilt_appearance', + variables: { colorPrimary: '#red' }, + elements: { button: { padding: '10px' } }, + __internal_globalCss: ':root { --color: red; }', + }); + }); + + it('should omit undefined properties', () => { + const config = { baseProperty: 'value' }; + + const result = createThemeObject(config, undefined, undefined, null); + + expect(result).toEqual({ + baseProperty: 'value', + __type: 'prebuilt_appearance', + }); + expect(result).not.toHaveProperty('variables'); + expect(result).not.toHaveProperty('elements'); + expect(result).not.toHaveProperty('__internal_globalCss'); + }); + }); + + describe('createThemeFactory', () => { + it('should create a factory function that generates themes', () => { + const config = { + variables: { + colorBackground: ['#ffffff', '#000000'], + colorPrimary: '#blue', + }, + elements: { + button: { padding: '10px' }, + }, + }; + + const factory = createThemeFactory(config); + const theme = factory(); + + expect(typeof factory).toBe('function'); + expect(theme).toHaveProperty('__type', 'prebuilt_appearance'); + expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value only by default + expect(theme.variables?.colorPrimary).toBe('#blue'); + expect(theme.elements).toEqual({ button: { padding: '10px' } }); + expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS by default + }); + + it('should pass darkModeSelector to elements function', () => { + const elementsFn = vi.fn().mockReturnValue({ button: { color: 'red' } }); + const config = { elements: elementsFn }; + + const factory = createThemeFactory(config); + const theme = factory({ darkModeSelector: '.custom-dark' }); + + expect(elementsFn).toHaveBeenCalledWith('.custom-dark &'); + expect(theme.elements).toEqual({ button: { color: 'red' } }); + }); + + it('should handle baseTheme with factory function', () => { + const baseThemeFactory = vi.fn().mockReturnValue({ + __type: 'prebuilt_appearance', + variables: { colorPrimary: '#base-blue' }, + }); + + const config = { + baseTheme: baseThemeFactory as any, + variables: { colorSecondary: '#red' }, + }; + + const factory = createThemeFactory(config); + const options = { darkModeSelector: '.custom' }; + const theme = factory(options); + + expect(baseThemeFactory).toHaveBeenCalledWith(options); + expect(theme.variables?.colorPrimary).toBe('#base-blue'); + expect(theme.variables?.colorSecondary).toBe('#red'); + }); + }); +}); + +describe('experimental_createTheme', () => { + describe('Basic theme creation', () => { + it('should create a theme with basic properties', () => { + const theme = experimental_createTheme({ + variables: { + colorPrimary: '#ff0000', + colorBackground: '#ffffff', + }, + }); + + expect(theme).toHaveProperty('__type', 'prebuilt_appearance'); + expect(theme.variables).toEqual({ + colorPrimary: '#ff0000', + colorBackground: '#ffffff', + }); + }); + + it('should create a theme with static elements', () => { + const theme = experimental_createTheme({ + elements: { + button: { + backgroundColor: 'blue', + }, + }, + }); + + expect(theme).toHaveProperty('__type', 'prebuilt_appearance'); + expect(theme.elements).toEqual({ + button: { + backgroundColor: 'blue', + }, + }); + }); + }); + + describe('Factory function behavior', () => { + it('should return a hybrid object that works as BaseTheme', () => { + const themeFactory = experimental_createTheme({ + variables: { colorPrimary: '#ff0000' }, + }); + + // Should work as BaseTheme directly + expect(themeFactory).toHaveProperty('__type', 'prebuilt_appearance'); + expect(themeFactory.variables).toEqual({ colorPrimary: '#ff0000' }); + }); + + it('should be callable as a factory function', () => { + const themeFactory = experimental_createTheme({ + variables: { colorPrimary: '#ff0000' }, + }); + + // Should be callable + expect(typeof themeFactory).toBe('function'); + + const themeInstance = themeFactory(); + expect(themeInstance).toHaveProperty('__type', 'prebuilt_appearance'); + expect(themeInstance.variables).toEqual({ colorPrimary: '#ff0000' }); + }); + + it('should accept darkModeSelector option when called as factory', () => { + const themeFactory = experimental_createTheme({ + variables: { colorPrimary: '#ff0000' }, + }); + + const themeInstance = themeFactory({ darkModeSelector: '.my-dark' }); + expect(themeInstance).toHaveProperty('__type', 'prebuilt_appearance'); + expect(themeInstance.variables).toEqual({ colorPrimary: '#ff0000' }); + }); + }); + + describe('darkModeSelector with elements callback', () => { + it('should not call elements function by default (light-only theme)', () => { + const elementsFn = vi.fn().mockReturnValue({ + button: { backgroundColor: 'red' }, + }); + + const themeFactory = experimental_createTheme({ + elements: elementsFn, + }); + + // When accessed as BaseTheme (default behavior) - should be light-only + expect(elementsFn).not.toHaveBeenCalled(); // No dark mode by default + expect(themeFactory.elements).toBeUndefined(); // No elements without dark mode + }); + + it('should call elements function with custom darkModeSelector', () => { + const elementsFn = vi.fn().mockReturnValue({ + button: { backgroundColor: 'blue' }, + }); + + const themeFactory = experimental_createTheme({ + elements: elementsFn, + }); + + // Reset the mock since it was called during theme creation + elementsFn.mockClear(); + + // When called as factory with custom selector + const themeInstance = themeFactory({ darkModeSelector: '.custom-dark' }); + + expect(elementsFn).toHaveBeenCalledWith('.custom-dark &'); + expect(themeInstance.elements).toEqual({ + button: { backgroundColor: 'blue' }, + }); + }); + + it('should use darkModeSelector in CSS selectors', () => { + const themeFactory = experimental_createTheme({ + elements: (darkModeSelector: string) => ({ + button: { + backgroundColor: 'red', + [darkModeSelector]: { + backgroundColor: 'blue', + }, + }, + }), + }); + + const themeInstance = themeFactory({ darkModeSelector: '.my-dark-mode' }); + + expect(themeInstance.elements).toEqual({ + button: { + backgroundColor: 'red', + '.my-dark-mode &': { + backgroundColor: 'blue', + }, + }, + }); + }); + + it('should handle complex selectors with darkModeSelector', () => { + const themeFactory = experimental_createTheme({ + elements: (darkModeSelector: string) => ({ + button: { + backgroundColor: 'red', + [darkModeSelector]: { + backgroundColor: 'blue', + }, + [`${darkModeSelector}:hover`]: { + backgroundColor: 'green', + }, + }, + }), + }); + + const themeInstance = themeFactory({ darkModeSelector: '.dark' }); + + expect(themeInstance.elements).toEqual({ + button: { + backgroundColor: 'red', + '.dark &': { + backgroundColor: 'blue', + }, + '.dark &:hover': { + backgroundColor: 'green', + }, + }, + }); + }); + }); + + describe('baseTheme inheritance', () => { + it('should merge variables from baseTheme', () => { + const baseTheme = experimental_createTheme({ + variables: { + colorPrimary: '#base-primary', + colorBackground: '#base-bg', + }, + }); + + const childTheme = experimental_createTheme({ + baseTheme, + variables: { + colorPrimary: '#child-primary', // Should override + }, + }); + + expect(childTheme.variables).toEqual({ + colorPrimary: '#child-primary', // Overridden + colorBackground: '#base-bg', // Inherited + }); + }); + + it('should merge elements from baseTheme', () => { + const baseTheme = experimental_createTheme({ + elements: { + button: { backgroundColor: 'base-color' }, + input: { borderColor: 'base-border' }, + }, + }); + + const childTheme = experimental_createTheme({ + baseTheme, + elements: { + button: { backgroundColor: 'child-color' }, // Should override + }, + }); + + expect(childTheme.elements).toEqual({ + button: { backgroundColor: 'child-color' }, // Overridden + input: { borderColor: 'base-border' }, // Inherited + }); + }); + + it('should work with baseTheme and darkModeSelector elements callback', () => { + const baseTheme = experimental_createTheme({ + variables: { colorPrimary: '#base' }, + elements: { input: { borderColor: 'gray' } }, + }); + + const childTheme = experimental_createTheme({ + baseTheme, + elements: (darkModeSelector: string) => ({ + button: { + backgroundColor: 'red', + [darkModeSelector]: { backgroundColor: 'blue' }, + }, + }), + }); + + expect(childTheme.variables).toEqual({ colorPrimary: '#base' }); + expect(childTheme.elements).toEqual({ + input: { borderColor: 'gray' }, // From base (static elements) + // Button elements function not called by default (no dark mode) + }); + }); + }); + + describe('Backwards compatibility', () => { + it('should work with existing theme usage patterns', () => { + // Simulate existing usage + const myTheme = experimental_createTheme({ + variables: { colorPrimary: '#legacy' }, + elements: { button: { padding: '10px' } }, + }); + + // Should work as BaseTheme directly (backwards compatibility) + const appearance = { theme: myTheme }; + + expect(appearance.theme).toHaveProperty('__type', 'prebuilt_appearance'); + expect(appearance.theme.variables).toEqual({ colorPrimary: '#legacy' }); + expect(appearance.theme.elements).toEqual({ button: { padding: '10px' } }); + }); + + it('should maintain type compatibility', () => { + const theme = experimental_createTheme({ + variables: { colorPrimary: '#test' }, + }); + + // These should all work without TypeScript errors + const directUsage = theme; + const factoryUsage = theme(); + const withOptions = theme({ darkModeSelector: '.custom' }); + + expect(directUsage.__type).toBe('prebuilt_appearance'); + expect(factoryUsage.__type).toBe('prebuilt_appearance'); + expect(withOptions.__type).toBe('prebuilt_appearance'); + }); + }); + + describe('Edge cases', () => { + it('should handle empty theme configuration', () => { + const theme = experimental_createTheme({}); + + expect(theme).toHaveProperty('__type', 'prebuilt_appearance'); + expect(theme.variables).toBeUndefined(); + expect(theme.elements).toBeUndefined(); + }); + + it('should handle undefined darkModeSelector (opt out)', () => { + const elementsFn = vi.fn().mockReturnValue({ button: {} }); + + const themeFactory = experimental_createTheme({ + elements: elementsFn, + }); + + elementsFn.mockClear(); + const themeInstance = themeFactory({ darkModeSelector: undefined }); + + expect(elementsFn).not.toHaveBeenCalled(); // Should opt out by default + expect(themeInstance.elements).toBeUndefined(); // No elements when opted out + }); + + it('should handle empty string darkModeSelector', () => { + const elementsFn = vi.fn().mockReturnValue({ button: {} }); + + const themeFactory = experimental_createTheme({ + elements: elementsFn, + }); + + elementsFn.mockClear(); + const themeInstance = themeFactory({ darkModeSelector: '' }); + + expect(elementsFn).not.toHaveBeenCalled(); // Should opt out for empty string + expect(themeInstance.elements).toBeUndefined(); // No elements when opted out + }); + }); + + describe('Variables with tuple values', () => { + it('should convert tuple values to light-only by default', () => { + const theme = experimental_createTheme({ + variables: { + colorBackground: ['#ffffff', '#000000'], + colorPrimary: ['#blue', '#lightblue'], + }, + }); + + // By default, tuples should use light values only (no dark mode) + expect(theme.variables?.colorBackground).toBe('#ffffff'); + expect(theme.variables?.colorPrimary).toBe('#blue'); + expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS generated by default + }); + + it('should handle custom darkModeSelector with tuple values', () => { + const themeFactory = experimental_createTheme({ + variables: { + colorBackground: ['#ffffff', '#000000'], + }, + }); + + const theme = themeFactory({ darkModeSelector: '.my-dark-theme' }); + + expect(theme.variables?.colorBackground).toBe('var(--clerk-color-background)'); + expect((theme as any).__internal_globalCss).toContain('.my-dark-theme'); + expect((theme as any).__internal_globalCss).toContain('--clerk-color-background: #000000;'); + }); + + it('should convert camelCase tuple values to light values by default', () => { + const theme = experimental_createTheme({ + variables: { + colorPrimaryForeground: ['#000', '#fff'], + colorInputBackground: ['#f0f0f0', '#333'], + }, + }); + + // By default, tuples should use light values only + expect(theme.variables?.colorPrimaryForeground).toBe('#000'); + expect(theme.variables?.colorInputBackground).toBe('#f0f0f0'); + expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS by default + + // Test explicit dark mode with kebab-case CSS variables + const darkTheme = experimental_createTheme({ + variables: { + colorPrimaryForeground: ['#000', '#fff'], + colorInputBackground: ['#f0f0f0', '#333'], + }, + })({ darkModeSelector: '.dark' }); + + expect(darkTheme.variables?.colorPrimaryForeground).toBe('var(--clerk-color-primary-foreground)'); + expect(darkTheme.variables?.colorInputBackground).toBe('var(--clerk-color-input-background)'); + expect((darkTheme as any).__internal_globalCss).toContain('--clerk-color-primary-foreground:'); + expect((darkTheme as any).__internal_globalCss).toContain('--clerk-color-input-background:'); + }); + + it('should preserve string variables as-is', () => { + const theme = experimental_createTheme({ + variables: { + colorBackground: ['#ffffff', '#000000'], // tuple + colorPrimary: '#blue', // string + fontFamily: 'Arial', // string + }, + }); + + // By default, tuples use light values only, strings preserved + expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value from tuple + expect(theme.variables?.colorPrimary).toBe('#blue'); + expect(theme.variables?.fontFamily).toBe('Arial'); + }); + + it('should work with baseTheme inheritance and tuple values', () => { + const baseTheme = experimental_createTheme({ + variables: { + colorBackground: ['#f0f0f0', '#111'], + colorPrimary: '#base-blue', + }, + }); + + const childTheme = experimental_createTheme({ + baseTheme, + variables: { + colorBackground: ['#ffffff', '#000000'], // Override with tuple + colorSecondary: '#child-red', // Add new variable + }, + }); + + // By default, tuples use light values only + expect(childTheme.variables?.colorBackground).toBe('#ffffff'); // Light value from tuple + expect(childTheme.variables?.colorPrimary).toBe('#base-blue'); // Inherited + expect(childTheme.variables?.colorSecondary).toBe('#child-red'); + expect((childTheme as any).__internal_globalCss).toBeUndefined(); // No CSS by default + }); + + it('should not generate global CSS if no tuple values exist', () => { + const theme = experimental_createTheme({ + variables: { + colorPrimary: '#blue', + fontFamily: 'Arial', + }, + }); + + expect((theme as any).__internal_globalCss).toBeUndefined(); + }); + + it('should handle empty tuple arrays gracefully', () => { + const theme = experimental_createTheme({ + variables: { + colorBackground: [], // Invalid tuple + colorPrimary: ['#blue'], // Invalid tuple (only one value) + colorSecondary: ['#red', '#darkred'], // Valid tuple + } as any, + }); + + // Invalid tuples pass through as-is, valid tuples use light values by default + expect(theme.variables?.colorBackground).toEqual([]); + expect(theme.variables?.colorPrimary).toEqual(['#blue']); + expect(theme.variables?.colorSecondary).toBe('#red'); // Light value from valid tuple + expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS by default + }); + + it('should require explicit darkModeSelector for CSS generation', () => { + // Test explicit media query selector + const mediaTheme = experimental_createTheme({ + variables: { + colorBackground: ['#ffffff', '#000000'], + }, + })({ darkModeSelector: '@media (prefers-color-scheme: dark)' }); + + expect((mediaTheme as any).__internal_globalCss).toContain('@media (prefers-color-scheme: dark)'); + expect((mediaTheme as any).__internal_globalCss).toContain(':root {'); + expect((mediaTheme as any).__internal_globalCss).toContain('--clerk-color-background: #000000'); + expect(mediaTheme.variables?.colorBackground).toBe('var(--clerk-color-background)'); + + // Test explicit class selector + const classTheme = experimental_createTheme({ + variables: { + colorBackground: ['#ffffff', '#000000'], + }, + })({ darkModeSelector: '.dark' }); + + expect((classTheme as any).__internal_globalCss).toContain('.dark {'); + expect((classTheme as any).__internal_globalCss).toContain('--clerk-color-background: #000000'); + expect(classTheme.variables?.colorBackground).toBe('var(--clerk-color-background)'); + }); + + it('should opt out of tuple CSS generation when darkModeSelector is false', () => { + const themeFactory = experimental_createTheme({ + variables: { + colorBackground: ['#ffffff', '#000000'], + colorPrimary: ['#blue', '#lightblue'], + colorSecondary: '#red', // Non-tuple + }, + }); + + const theme = themeFactory({ darkModeSelector: false }); + + expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value only + expect(theme.variables?.colorPrimary).toBe('#blue'); // Light value only + expect(theme.variables?.colorSecondary).toBe('#red'); // Non-tuple passes through + expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS generated + }); + + it('should not process elements function when opted out of dark mode', () => { + const themeFactory = experimental_createTheme({ + variables: { + colorBackground: ['#ffffff', '#000000'], + }, + elements: darkModeSelector => ({ + button: { + backgroundColor: 'red', + [darkModeSelector]: { backgroundColor: 'blue' }, + }, + }), + }); + + const theme = themeFactory({ darkModeSelector: false }); + + expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value only + expect((theme as any).__internal_globalCss).toBeUndefined(); // No tuple CSS + expect(theme.elements).toBeUndefined(); // No elements generated when opted out + }); + + it('should still work with static elements when opted out', () => { + const themeFactory = experimental_createTheme({ + variables: { + colorBackground: ['#ffffff', '#000000'], + }, + elements: { + button: { + backgroundColor: 'red', + }, + }, + }); + + const theme = themeFactory({ darkModeSelector: false }); + + expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value only + expect((theme as any).__internal_globalCss).toBeUndefined(); // No tuple CSS + expect(theme.elements).toEqual({ + button: { backgroundColor: 'red' }, // Static elements still work + }); + }); + + it('should automatically detect and support all non-deprecated color variables', () => { + // This test verifies that our automatic color variable detection + // stays in sync with the Variables type from @clerk/types + + // Test variables that should definitely work (known non-deprecated colors) + const knownColorVars = { + colorBackground: ['#fff', '#000'] as [string, string], + colorPrimary: ['#blue', '#darkblue'] as [string, string], + colorDanger: ['#red', '#darkred'] as [string, string], + colorSuccess: ['#green', '#darkgreen'] as [string, string], + colorNeutral: ['#gray', '#darkgray'] as [string, string], + }; + + // This should compile without errors due to automatic detection + const theme = experimental_createTheme({ + variables: knownColorVars, + })({ darkModeSelector: '.dark' }); + + // All should be converted to CSS variables + expect(theme.variables?.colorBackground).toBe('var(--clerk-color-background)'); + expect(theme.variables?.colorPrimary).toBe('var(--clerk-color-primary)'); + expect(theme.variables?.colorDanger).toBe('var(--clerk-color-danger)'); + expect(theme.variables?.colorSuccess).toBe('var(--clerk-color-success)'); + expect(theme.variables?.colorNeutral).toBe('var(--clerk-color-neutral)'); + + // Should generate CSS for all + const css = (theme as any).__internal_globalCss; + expect(css).toContain('--clerk-color-background: #fff;'); + expect(css).toContain('--clerk-color-background: #000;'); + }); + + it('should support tuple values for all non-deprecated color variables', () => { + const theme = experimental_createTheme({ + variables: { + // All non-deprecated color variables should support tuples + colorBackground: ['#ffffff', '#1a1a1a'], + colorForeground: ['#1a1a1a', '#ffffff'], + colorMuted: ['#f8f9fa', '#2d2d2d'], + colorMutedForeground: ['#6c757d', '#adb5bd'], + colorPrimary: ['#007bff', '#0d6efd'], + colorPrimaryForeground: ['#ffffff', '#000000'], + colorDanger: ['#dc3545', '#e74c3c'], + colorSuccess: ['#28a745', '#2ecc71'], + colorWarning: ['#ffc107', '#f39c12'], + colorNeutral: ['#6c757d', '#95a5a6'], + colorInput: ['#ffffff', '#2c2c2c'], + colorInputForeground: ['#1a1a1a', '#ffffff'], + colorShimmer: ['rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)'], + colorRing: ['rgba(0,123,255,0.25)', 'rgba(13,110,253,0.25)'], + colorShadow: ['rgba(0,0,0,0.175)', 'rgba(0,0,0,0.5)'], + colorBorder: ['#dee2e6', '#495057'], + colorModalBackdrop: ['rgba(0,0,0,0.5)', 'rgba(0,0,0,0.8)'], + }, + })({ darkModeSelector: '.dark' }); + + // All tuple variables should be converted to CSS variables + expect(theme.variables?.colorBackground).toBe('var(--clerk-color-background)'); + expect(theme.variables?.colorForeground).toBe('var(--clerk-color-foreground)'); + expect(theme.variables?.colorMuted).toBe('var(--clerk-color-muted)'); + expect(theme.variables?.colorMutedForeground).toBe('var(--clerk-color-muted-foreground)'); + expect(theme.variables?.colorPrimary).toBe('var(--clerk-color-primary)'); + expect(theme.variables?.colorPrimaryForeground).toBe('var(--clerk-color-primary-foreground)'); + expect(theme.variables?.colorDanger).toBe('var(--clerk-color-danger)'); + expect(theme.variables?.colorSuccess).toBe('var(--clerk-color-success)'); + expect(theme.variables?.colorWarning).toBe('var(--clerk-color-warning)'); + expect(theme.variables?.colorNeutral).toBe('var(--clerk-color-neutral)'); + expect(theme.variables?.colorInput).toBe('var(--clerk-color-input)'); + expect(theme.variables?.colorInputForeground).toBe('var(--clerk-color-input-foreground)'); + expect(theme.variables?.colorShimmer).toBe('var(--clerk-color-shimmer)'); + expect(theme.variables?.colorRing).toBe('var(--clerk-color-ring)'); + expect(theme.variables?.colorShadow).toBe('var(--clerk-color-shadow)'); + expect(theme.variables?.colorBorder).toBe('var(--clerk-color-border)'); + expect(theme.variables?.colorModalBackdrop).toBe('var(--clerk-color-modal-backdrop)'); + + // Should generate comprehensive CSS + const css = (theme as any).__internal_globalCss; + expect(css).toContain('--clerk-color-background: #ffffff;'); + expect(css).toContain('--clerk-color-background: #1a1a1a;'); + expect(css).toContain('--clerk-color-danger: #dc3545;'); + expect(css).toContain('--clerk-color-danger: #e74c3c;'); + expect(css).toContain('--clerk-color-modal-backdrop: rgba(0,0,0,0.5);'); + expect(css).toContain('--clerk-color-modal-backdrop: rgba(0,0,0,0.8);'); + }); + }); +}); diff --git a/packages/themes/src/createTheme.ts b/packages/themes/src/createTheme.ts index 2c5e86f844e..b3ca7be1e4c 100644 --- a/packages/themes/src/createTheme.ts +++ b/packages/themes/src/createTheme.ts @@ -1,20 +1,317 @@ -// Temp way to import the type. We will clean this up when we extract -// theming into its own package -import type { Appearance, BaseTheme, DeepPartial, Elements, Theme } from '@clerk/types'; +import type { Appearance, BaseTheme, Elements, Variables } from '@clerk/types'; -import type { InternalTheme } from '../../clerk-js/src/ui/foundations'; +export type DarkModeElementsFunction = (darkModeSelector: string) => Elements; -interface CreateClerkThemeParams extends DeepPartial { - /** - * {@link Theme.elements} - */ - elements?: Elements | ((params: { theme: InternalTheme }) => Elements); +// Support for tuple values in variables like ['#ffffff', '#212126'] (light, dark) +export type VariableValue = string | readonly [light: string, dark: string]; + +// Extract all color variable keys from Variables type +type ColorVariableKeys = { + [K in keyof Variables]: K extends `color${string}` ? K : never; +}[keyof Variables]; + +// Manually exclude deprecated color variables (marked with @deprecated in Variables type) +type DeprecatedColorVariables = + | 'colorTextOnPrimaryBackground' // @deprecated Use colorPrimaryForeground instead + | 'colorText' // @deprecated Use colorForeground instead + | 'colorTextSecondary' // @deprecated Use colorMutedForeground instead + | 'colorInputText' // @deprecated Use colorInputForeground instead + | 'colorInputBackground'; // @deprecated Use colorInput instead + +// Get all non-deprecated color variables that should support tuple values +type NonDeprecatedColorVariables = Exclude; + +// Extended Variables type that supports tuple values for all non-deprecated color properties +export type VariablesWithTuples = { + [K in keyof Variables]: K extends NonDeprecatedColorVariables ? string | [string, string] : Variables[K]; +}; + +type ProcessedVariables = { + cssString: string | null; + processedVariables: Record; +}; + +type ThemeConfiguration = { + variables?: VariablesWithTuples; + elements?: Elements | DarkModeElementsFunction; + baseTheme?: BaseTheme; +}; + +type ThemeOptions = { + darkModeSelector?: string | false; +}; + +const CAMEL_CASE_REGEX = /[A-Z]/g; +const LEADING_HYPHEN_REGEX = /^-/; +const CSS_VAR_PREFIX = '--clerk-'; +const THEME_TYPE = 'prebuilt_appearance'; +const MEDIA_QUERY_PREFIX = '@media'; +const CLASS_SELECTOR_PREFIX = '.'; +const ID_SELECTOR_PREFIX = '#'; +const INTERNAL_GLOBAL_CSS_KEY = '__internal_globalCss'; + +/** + * Converts camelCase to kebab-case for CSS variables + * @example toKebabCase('colorBackground') => 'color-background' + * @example toKebabCase('XMLParser') => 'x-m-l-parser' (no leading hyphen) + */ +export function toKebabCase(str: string): string { + return str.replace(CAMEL_CASE_REGEX, letter => `-${letter.toLowerCase()}`).replace(LEADING_HYPHEN_REGEX, ''); +} + +/** + * Normalizes darkModeSelector with support for opting out + * @param selector - The dark mode selector, false to opt out, or undefined for media query fallback + * @returns null if opted out, otherwise a valid selector string + */ +export function normalizeDarkModeSelector(selector?: string | false): string | null { + if (selector === false) { + return null; // Opt out of dark mode CSS generation + } + + // If no selector provided, opt out by default (light-only theme) + // Dark mode should be explicit opt-in behavior + if (selector === undefined) { + return null; + } + + // Use provided selector, trimmed + return selector.trim() || null; +} + +/** + * Converts tuple variables to CSS variables and generates theme CSS + * Transforms tuples like ['#ffffff', '#000000'] into CSS custom properties + * @param variables - Variables object that may contain tuples + * @param darkModeSelector - Dark mode selector string, or null to opt out of CSS generation + */ +export function convertTuplesToCssVariables( + variables: Record, + darkModeSelector?: string | null, +): ProcessedVariables { + const lightRules: string[] = []; + const darkRules: string[] = []; + const processedVariables: Record = {}; + + for (const [key, value] of Object.entries(variables)) { + if (Array.isArray(value) && value.length === 2) { + const [lightValue, darkValue] = value; + + if (darkModeSelector === null) { + processedVariables[key] = lightValue; + } else { + const cssVarName = `${CSS_VAR_PREFIX}${toKebabCase(key)}`; + + lightRules.push(` ${cssVarName}: ${lightValue};`); + darkRules.push(` ${cssVarName}: ${darkValue};`); + + processedVariables[key] = `var(${cssVarName})`; + } + } else { + processedVariables[key] = value; + } + } + + let cssString: string | null = null; + if (lightRules.length > 0 && darkModeSelector !== null) { + const normalizedSelector = darkModeSelector as string; + + if (normalizedSelector.startsWith(MEDIA_QUERY_PREFIX)) { + // Media query - wrap dark rules in :root + cssString = ` + :root { +${lightRules.join('\n')} + } + ${normalizedSelector} { + :root { +${darkRules.join('\n')} + } + } + `.trim(); + } else { + // Regular selector - use as-is + cssString = ` + :root { +${lightRules.join('\n')} + } + ${normalizedSelector} { +${darkRules.join('\n')} + } + `.trim(); + } + } + + return { cssString, processedVariables }; +} + +/** + * Transforms a CSS selector for proper nesting context + * Class selectors like '.dark' become '.dark &' for parent-child relationship + * ID selectors like '#theme' become '#theme &' for parent-child relationship + */ +export function transformSelectorForNesting(selector: string): string { + // If it's a class or ID selector, append & for proper CSS nesting + if ( + (selector.startsWith(CLASS_SELECTOR_PREFIX) || selector.startsWith(ID_SELECTOR_PREFIX)) && + !selector.includes('&') + ) { + return `${selector} &`; + } + // For media queries and other selectors, return as-is + return selector; +} + +/** + * Resolves elements configuration - handles both static elements and function-based elements + */ +export function resolveElementsConfiguration( + elements: Elements | DarkModeElementsFunction | undefined, + darkModeSelector: string | null, +): Elements | undefined { + if (!elements) { + return undefined; + } + + // If it's a function, call it with darkModeSelector + if (typeof elements === 'function') { + // If opted out of dark mode, don't call the function at all + // since it's designed to generate dark mode styles + if (darkModeSelector === null) { + return undefined; + } + + try { + // Use the explicit selector provided (no fallbacks) + const rawSelector = darkModeSelector; + const selector = transformSelectorForNesting(rawSelector); + return (elements as DarkModeElementsFunction)(selector); + } catch (error) { + console.warn('[Clerk Theme] Failed to call elements function with darkModeSelector:', error); + return undefined; + } + } + + return elements; +} + +/** + * Resolves a baseTheme, handling both static themes and factory functions + */ +export function resolveBaseTheme(baseTheme: BaseTheme | undefined, options?: ThemeOptions): BaseTheme | undefined { + if (!baseTheme) { + return undefined; + } + + // If baseTheme is a factory function, call it with the same options + if (typeof baseTheme === 'function') { + try { + return (baseTheme as any)(options); + } catch (error) { + console.warn('[Clerk Theme] Failed to call baseTheme factory function:', error); + return baseTheme as BaseTheme; // Fallback to treating it as static theme + } + } + + return baseTheme; } -export const experimental_createTheme = (appearance: Appearance): BaseTheme => { - // Placeholder method that might hande more transformations in the future - return { - ...appearance, - __type: 'prebuilt_appearance', +/** + * Merges theme configurations, with child theme taking precedence + */ +export function mergeThemeConfigurations( + baseTheme: BaseTheme | undefined, + childConfig: ThemeConfiguration, +): { + mergedVariables: Record | undefined; + mergedElements: Elements | undefined; + mergedConfig: Record; +} { + const resolvedBase = (baseTheme as any) || {}; + + // Merge variables and elements, avoiding empty objects + const mergedVariables = + resolvedBase.variables || childConfig.variables + ? { ...resolvedBase.variables, ...childConfig.variables } + : undefined; + + const mergedElements = + resolvedBase.elements || childConfig.elements ? { ...resolvedBase.elements, ...childConfig.elements } : undefined; + + const mergedConfig = { + ...resolvedBase, + ...childConfig, }; -}; + + return { mergedVariables, mergedElements, mergedConfig }; +} + +/** + * Creates the final theme object with proper typing and internal properties + */ +export function createThemeObject( + config: Record, + variables: Record | undefined, + elements: Elements | undefined, + globalCss: string | null, +): BaseTheme { + const result = { + ...config, + __type: THEME_TYPE, + } as BaseTheme; + + // Only set variables/elements if they have content + if (variables) (result as any).variables = variables; + if (elements) (result as any).elements = elements; + + // Include global CSS string if it exists (for media query styles) + if (globalCss) (result as any)[INTERNAL_GLOBAL_CSS_KEY] = globalCss; + + return result; +} + +/** + * Creates a theme factory function that can generate themed instances + */ +export function createThemeFactory(config: ThemeConfiguration): (options?: ThemeOptions) => BaseTheme { + return (options?: ThemeOptions) => { + const darkModeSelector = normalizeDarkModeSelector(options?.darkModeSelector); + + const resolvedBaseTheme = resolveBaseTheme(config.baseTheme, options); + + const processedElements = resolveElementsConfiguration(config.elements, darkModeSelector); + + const { mergedVariables, mergedElements, mergedConfig } = mergeThemeConfigurations(resolvedBaseTheme, { + ...config, + elements: processedElements, + }); + + let globalCssString: string | null = null; + let finalVariables = mergedVariables; + + if (mergedVariables) { + const { cssString, processedVariables } = convertTuplesToCssVariables(mergedVariables, darkModeSelector); + globalCssString = cssString; + finalVariables = processedVariables; + } + + return createThemeObject(mergedConfig, finalVariables, mergedElements, globalCssString); + }; +} + +// Overload for themes with function-based elements +export function experimental_createTheme< + T extends { variables?: VariablesWithTuples; elements: DarkModeElementsFunction; baseTheme?: BaseTheme }, +>(appearance: Appearance): BaseTheme & ((options?: ThemeOptions) => BaseTheme); +// Overload for themes with static elements or no elements +export function experimental_createTheme( + appearance: Appearance, +): BaseTheme & ((options?: ThemeOptions) => BaseTheme); +export function experimental_createTheme(appearance: any) { + const themeFactory = createThemeFactory(appearance); + + const defaultTheme = themeFactory(); + + const callableTheme = Object.assign(themeFactory, defaultTheme); + + return callableTheme as BaseTheme & typeof themeFactory; +} diff --git a/packages/themes/src/themes/clerk.ts b/packages/themes/src/themes/clerk.ts new file mode 100644 index 00000000000..c7fa234017f --- /dev/null +++ b/packages/themes/src/themes/clerk.ts @@ -0,0 +1,29 @@ +import { experimental_createTheme } from '../createTheme'; + +export const clerk = experimental_createTheme({ + variables: { + colorBackground: ['#ffffff', '#212126'], + colorNeutral: ['#000000', '#ffffff'], + colorPrimary: ['#000000', '#ffffff'], + colorPrimaryForeground: ['#ffffff', '#000000'], + colorForeground: ['#000000', '#ffffff'], + colorInputForeground: ['#000000', '#ffffff'], + colorInput: ['#ffffff', '#26262B'], + colorModalBackdrop: ['#000000', '#000000'], + }, + elements: darkModeSelector => { + return { + providerIcon__apple: { [darkModeSelector]: { filter: 'invert(1)' } }, + providerIcon__github: { [darkModeSelector]: { filter: 'invert(1)' } }, + providerIcon__okx_wallet: { [darkModeSelector]: { filter: 'invert(1)' } }, + activeDeviceIcon: { + [darkModeSelector]: { + '--cl-chassis-bottom': '#d2d2d2', + '--cl-chassis-back': '#e6e6e6', + '--cl-chassis-screen': '#e6e6e6', + '--cl-screen': '#111111', + }, + }, + }; + }, +}); diff --git a/packages/themes/src/themes/index.ts b/packages/themes/src/themes/index.ts index b57a2cb704d..6faebecc1ce 100644 --- a/packages/themes/src/themes/index.ts +++ b/packages/themes/src/themes/index.ts @@ -1,3 +1,4 @@ +export * from './clerk'; export * from './dark'; export * from './shadesOfPurple'; export * from './neobrutalism'; diff --git a/packages/themes/src/themes/neobrutalism.ts b/packages/themes/src/themes/neobrutalism.ts index d992eeb68d7..a60fa5e2965 100644 --- a/packages/themes/src/themes/neobrutalism.ts +++ b/packages/themes/src/themes/neobrutalism.ts @@ -20,7 +20,6 @@ const shadowStyle = { }; export const neobrutalism = experimental_createTheme({ - //@ts-expect-error not public api simpleStyles: true, variables: { colorPrimary: '#DF1B1B', diff --git a/packages/themes/tsup.config.ts b/packages/themes/tsup.config.ts index 4e91f53be8d..806b0ab3055 100644 --- a/packages/themes/tsup.config.ts +++ b/packages/themes/tsup.config.ts @@ -3,7 +3,7 @@ import { extname, join } from 'path'; import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['./src/**/*.{ts,tsx}'], + entry: ['./src/**/*.{ts,tsx}', '!./src/**/*.test.{ts,tsx}', '!./src/**/__tests__/**'], format: ['cjs', 'esm'], bundle: false, clean: true, diff --git a/packages/themes/vitest.config.ts b/packages/themes/vitest.config.ts new file mode 100644 index 00000000000..014f97ef76d --- /dev/null +++ b/packages/themes/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, +}); diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index e04f74b2598..f3ce69edaac 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -805,7 +805,10 @@ export type Variables = { spacing?: CssLengthUnit; }; -export type BaseThemeTaggedType = { __type: 'prebuilt_appearance' }; +export type BaseThemeTaggedType = { + __type: 'prebuilt_appearance'; + __internal_globalCss?: string; +}; export type BaseTheme = (BaseThemeTaggedType | 'clerk' | 'simple') & { cssLayerName?: string }; export type Theme = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c5d289f1f8..4a942f70e68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ catalogs: typescript: specifier: 5.8.3 version: 5.8.3 + vitest: + specifier: 3.0.5 + version: 3.0.5 zx: specifier: 8.7.1 version: 8.7.1 @@ -1022,6 +1025,9 @@ importers: tsup: specifier: catalog:repo version: 8.5.0(@swc/core@1.11.29(@swc/helpers@0.5.17))(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0) + vitest: + specifier: catalog:repo + version: 3.0.5(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@24.0.14)(jiti@2.4.2)(jsdom@24.1.3)(lightningcss@1.27.0)(msw@2.10.4(@types/node@24.0.14)(typescript@5.8.3))(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0) packages/types: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1aeee6b4c12..8cd61004ad3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,4 +19,5 @@ catalogs: tslib: 2.8.1 tsup: 8.5.0 typescript: 5.8.3 + vitest: 3.0.5 zx: 8.7.1