From 8154f9a50f576bb86eca2a7e918031efda8a4066 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Feb 2026 15:29:00 -0500 Subject: [PATCH 1/6] init --- .../devPrompts/KeylessPrompt/index.tsx | 115 +++++++++++------- 1 file changed, 70 insertions(+), 45 deletions(-) diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx index 77be3ce743f..b1e427f6a46 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx @@ -5,7 +5,6 @@ import React, { useMemo, useState } from 'react'; import { Portal } from '../../../elements/Portal'; import { MosaicThemeProvider, useMosaicTheme } from '../../../mosaic/theme-provider'; -import { handleDashboardUrlParsing } from '../shared'; import { useRevalidateEnvironment } from './use-revalidate-environment'; type KeylessPromptProps = { @@ -14,16 +13,7 @@ type KeylessPromptProps = { onDismiss: (() => Promise) | undefined | null; }; -/** - * If we cannot reconstruct the url properly, then simply fallback to Clerk Dashboard - */ -function withLastActiveFallback(cb: () => string): string { - try { - return cb(); - } catch { - return 'https://dashboard.clerk.com/last-active'; - } -} +const LIST_ITEMS = ['Add SSO connections (eg. GitHub)', 'Setup B2B Auth.', 'Enable MFA'] as const; function KeylessPromptInternal(props: KeylessPromptProps) { const { isSignedIn } = useUser(); @@ -33,7 +23,7 @@ function KeylessPromptInternal(props: KeylessPromptProps) { const appName = environment.displayConfig.applicationName; const isLocked = claimed || success; - const [isOpen, setIsOpen] = useState(isSignedIn || isLocked); + const [isOpen, setIsOpen] = useState(true); const [hasMounted, setHasMounted] = useState(false); const id = React.useId(); const containerRef = React.useRef(null); @@ -66,24 +56,14 @@ function KeylessPromptInternal(props: KeylessPromptProps) { return url.href; }, [claimed, props.copyKeysUrl, props.claimUrl]); - const instanceUrlToDashboard = useMemo(() => { - return withLastActiveFallback(() => { - const redirectUrlParts = handleDashboardUrlParsing(props.copyKeysUrl); - const url = new URL( - `${redirectUrlParts.baseDomain}/apps/${redirectUrlParts.appId}/instances/${redirectUrlParts.instanceId}/user-authentication/email-phone-username`, - ); - return url.href; - }); - }, [props.copyKeysUrl]); - function getStatusText() { if (success) { - return 'Claim completed'; + return 'Your app is ready'; } if (claimed) { return 'Missing environment keys'; } - return 'Clerk is in keyless mode'; + return 'Configure your application'; } React.useEffect(() => { @@ -127,7 +107,7 @@ function KeylessPromptInternal(props: KeylessPromptProps) { --accent: ${theme.colors.purple[700]}; --offset: ${theme.spacing[5]}; --width-opened: 18rem; - --width-closed: 13rem; + --width-closed: 14rem; -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; position: fixed; @@ -412,17 +392,7 @@ function KeylessPromptInternal(props: KeylessPromptProps) { > {appName} {' '} - has been claimed. Configure settings from the{' '} - - Clerk Dashboard - + has been configured. Customize your settings in the Clerk dashboard.

) : claimed ? (

@@ -430,17 +400,72 @@ function KeylessPromptInternal(props: KeylessPromptProps) { Dashboard.

) : isSignedIn ? ( -

- You've created your first user! Link this application to your Clerk account to explore the - Dashboard. -

- ) : ( <> -

Temporary API keys are enabled so you can get started immediately.

- Claim this application to access the Clerk Dashboard where you can manage auth settings and - explore more Clerk features. + Head to the dashboard to customize authentication settings, view user info, and explore more + features.

+
    + {LIST_ITEMS.map(item => ( +
  • + {item} +
  • + ))} +
+ + ) : ( + <> +

Temporary API keys are enabled so you can get started immediately.

+
    + {LIST_ITEMS.map(item => ( +
  • + {item} +
  • + ))} +
+

Access the dashboard to customize auth settings and explore Clerk features.

)} @@ -558,7 +583,7 @@ function KeylessPromptInternal(props: KeylessPromptProps) { z-index: 1; `} > - {claimed ? 'Get API keys' : 'Claim application'} + {claimed ? 'Get API keys' : 'Configure your application'} Date: Mon, 2 Feb 2026 17:03:46 -0500 Subject: [PATCH 2/6] extract buttonStyles --- .../devPrompts/KeylessPrompt/index.tsx | 105 +----------------- packages/ui/src/mosaic/button.ts | 73 ++++++++++++ 2 files changed, 78 insertions(+), 100 deletions(-) create mode 100644 packages/ui/src/mosaic/button.ts diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx index b1e427f6a46..49228a9e84d 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx @@ -4,6 +4,7 @@ import { css } from '@emotion/react'; import React, { useMemo, useState } from 'react'; import { Portal } from '../../../elements/Portal'; +import { buttonStyles } from '../../../mosaic/button'; import { MosaicThemeProvider, useMosaicTheme } from '../../../mosaic/theme-provider'; import { useRevalidateEnvironment } from './use-revalidate-environment'; @@ -477,114 +478,18 @@ function KeylessPromptInternal(props: KeylessPromptProps) { window.location.reload(); }); }} - css={css` - box-sizing: border-box; - position: relative; - display: flex; - align-items: center; - justify-content: center; - gap: ${theme.spacing[1]}; - width: 100%; - height: ${theme.spacing[7]}; - border-radius: ${theme.spacing[1.5]}; - font-size: ${theme.typography.label[3].fontSize}; - font-weight: ${theme.fontWeights.medium}; - color: var(--foreground); - background: var(--accent); - box-shadow: - ${theme.colors.white} 0px 0px 0px 0px, - var(--accent) 0px 0px 0px 1px, - ${theme.alpha(theme.colors.white, 7)} 0px 1px 0px 0px inset, - ${theme.alpha(theme.colors.gray[1300], 20)} 0px 1px 3px 0px; - outline: none; - border: none; - margin: 0; - padding: 0; - cursor: pointer; - &::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - 180deg, - ${theme.alpha(theme.colors.white, 16)} 46%, - ${theme.alpha(theme.colors.white, 0)} 54% - ); - mix-blend-mode: overlay; - border-radius: inherit; - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - &:hover { - background: ${theme.colors.gray[1200]}; - transition: all 120ms ease-in-out; - } - `} + css={buttonStyles()} > - - Dismiss - + Dismiss ) : ( - - {claimed ? 'Get API keys' : 'Configure your application'} - + {claimed ? 'Get API keys' : 'Configure your application'} { + const { variant = 'primary', fullWidth = false } = options; + + const styleFunction = (theme: MosaicTheme) => { + return { + boxSizing: 'border-box', + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing[1], + width: fullWidth ? '100%' : 'auto', + height: theme.spacing[7], + borderRadius: theme.spacing[1.5], + fontSize: theme.typography.label[3].fontSize, + fontWeight: theme.fontWeights.medium, + fontFamily: theme.fontFamilies.sans, + border: 'none', + outline: 'none', + margin: 0, + paddingBlock: 0, + paddingInline: theme.spacing[3], + cursor: 'pointer', + textDecoration: 'none', + '&::before': { + content: '""', + position: 'absolute', + inset: 0, + background: `linear-gradient(180deg, ${theme.alpha(theme.colors.white, 16)} 46%, ${theme.alpha(theme.colors.white, 0)} 54%)`, + mixBlendMode: 'overlay', + borderRadius: 'inherit', + pointerEvents: 'none', + }, + + '&:focus-visible': { + outline: `2px solid ${theme.colors.purple[700]}`, + outlineOffset: '2px', + }, + + ...(variant === 'primary' && { + background: theme.colors.purple[700], + color: theme.colors.white, + boxShadow: `${theme.colors.white} 0px 0px 0px 0px, ${theme.colors.purple[700]} 0px 0px 0px 1px, ${theme.alpha(theme.colors.white, 7)} 0px 1px 0px 0px inset, ${theme.alpha(theme.colors.gray[1300], 20)} 0px 1px 3px 0px`, + }), + }; + }; + + // Type assertion: safe because MosaicThemeProvider ensures theme is MosaicTheme at runtime + return styleFunction as unknown as Interpolation; +} From f4f510cf75e733be74af85925f19d2cccc3ec2cd Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Feb 2026 17:46:05 -0500 Subject: [PATCH 3/6] wip --- packages/ui/src/mosaic/button.ts | 119 ++++++++----------- packages/ui/src/mosaic/index.ts | 4 + packages/ui/src/mosaic/variants.ts | 177 +++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 69 deletions(-) create mode 100644 packages/ui/src/mosaic/index.ts create mode 100644 packages/ui/src/mosaic/variants.ts diff --git a/packages/ui/src/mosaic/button.ts b/packages/ui/src/mosaic/button.ts index c1d8a5745ee..b6598676f8c 100644 --- a/packages/ui/src/mosaic/button.ts +++ b/packages/ui/src/mosaic/button.ts @@ -1,73 +1,54 @@ -// eslint-disable-next-line no-restricted-imports -import type { Interpolation, Theme } from '@emotion/react'; +import { style, variants } from './variants'; -import { type MosaicTheme } from './theme'; - -type ButtonStyleOptions = { - /** - * @default primary - */ - variant?: 'primary'; - /** - * @default false - */ - fullWidth?: boolean; -}; - -// Returns a function that Emotion calls with theme from context -// When used within MosaicThemeProvider, theme will be MosaicTheme at runtime -// -// Type note: Emotion's Theme type (InternalTheme) is incompatible with MosaicTheme, -// but at runtime within MosaicThemeProvider, the theme is guaranteed to be MosaicTheme. -// We use a type assertion to bridge this gap, which is safe because MosaicThemeProvider -// ensures the correct theme type at runtime. -export function buttonStyles(options: ButtonStyleOptions = {}): Interpolation { - const { variant = 'primary', fullWidth = false } = options; - - const styleFunction = (theme: MosaicTheme) => { - return { - boxSizing: 'border-box', - position: 'relative', - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - gap: theme.spacing[1], - width: fullWidth ? '100%' : 'auto', - height: theme.spacing[7], - borderRadius: theme.spacing[1.5], - fontSize: theme.typography.label[3].fontSize, - fontWeight: theme.fontWeights.medium, - fontFamily: theme.fontFamilies.sans, - border: 'none', - outline: 'none', - margin: 0, - paddingBlock: 0, - paddingInline: theme.spacing[3], - cursor: 'pointer', - textDecoration: 'none', - '&::before': { - content: '""', - position: 'absolute', - inset: 0, - background: `linear-gradient(180deg, ${theme.alpha(theme.colors.white, 16)} 46%, ${theme.alpha(theme.colors.white, 0)} 54%)`, - mixBlendMode: 'overlay', - borderRadius: 'inherit', - pointerEvents: 'none', - }, - - '&:focus-visible': { - outline: `2px solid ${theme.colors.purple[700]}`, - outlineOffset: '2px', - }, - - ...(variant === 'primary' && { +export const buttonStyles = variants({ + base: style(theme => ({ + boxSizing: 'border-box', + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing[1], + height: theme.spacing[7], + borderRadius: theme.spacing[1.5], + fontSize: theme.typography.label[3].fontSize, + fontWeight: theme.fontWeights.medium, + fontFamily: theme.fontFamilies.sans, + border: 'none', + outline: 'none', + margin: 0, + paddingBlock: 0, + paddingInline: theme.spacing[3], + cursor: 'pointer', + textDecoration: 'none', + '&::before': { + content: '""', + position: 'absolute', + inset: 0, + background: `linear-gradient(180deg, ${theme.alpha(theme.colors.white, 16)} 46%, ${theme.alpha(theme.colors.white, 0)} 54%)`, + mixBlendMode: 'overlay', + borderRadius: 'inherit', + pointerEvents: 'none', + }, + '&:focus-visible': { + outline: `2px solid ${theme.colors.purple[700]}`, + outlineOffset: '2px', + }, + })), + variants: { + variant: { + primary: style(theme => ({ background: theme.colors.purple[700], color: theme.colors.white, boxShadow: `${theme.colors.white} 0px 0px 0px 0px, ${theme.colors.purple[700]} 0px 0px 0px 1px, ${theme.alpha(theme.colors.white, 7)} 0px 1px 0px 0px inset, ${theme.alpha(theme.colors.gray[1300], 20)} 0px 1px 3px 0px`, - }), - }; - }; - - // Type assertion: safe because MosaicThemeProvider ensures theme is MosaicTheme at runtime - return styleFunction as unknown as Interpolation; -} + })), + }, + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + variant: 'primary', + fullWidth: false, + }, +}); diff --git a/packages/ui/src/mosaic/index.ts b/packages/ui/src/mosaic/index.ts new file mode 100644 index 00000000000..4f4209f7c55 --- /dev/null +++ b/packages/ui/src/mosaic/index.ts @@ -0,0 +1,4 @@ +export { variants } from './variants'; +export { buttonStyles } from './button'; +export { MosaicThemeProvider, useMosaicTheme } from './theme-provider'; +export { mosaicTheme, type MosaicTheme } from './theme'; diff --git a/packages/ui/src/mosaic/variants.ts b/packages/ui/src/mosaic/variants.ts new file mode 100644 index 00000000000..3d3a4f2fce0 --- /dev/null +++ b/packages/ui/src/mosaic/variants.ts @@ -0,0 +1,177 @@ +import { fastDeepMergeAndReplace } from '@clerk/shared/utils'; +// eslint-disable-next-line no-restricted-imports +import type { Interpolation, Theme } from '@emotion/react'; + +import { type MosaicTheme } from './theme'; + +type CSSObject = Record; +// StyleFunction uses MosaicTheme to provide proper typing for theme parameter +type StyleFunction = (theme: MosaicTheme) => CSSObject; +type StyleRule = CSSObject | StyleFunction; + +// Convert string literal "true" | "false" to boolean (CVA's StringToBoolean) +type StringToBoolean = T extends 'true' | 'false' ? boolean : T; + +// Maps variant names to their allowed values (with boolean conversion) +// This is the key type that allows boolean values for variants with true/false keys +type ConfigVariants = { + [K in keyof T]?: StringToBoolean | null | undefined; +}; + +// Config type - no constraint on T to preserve literal types +// NoInfer prevents TypeScript from using defaultVariants/compoundVariants for T inference +type VariantsConfig = { + base?: StyleRule; + variants?: T; + defaultVariants?: ConfigVariants>; + compoundVariants?: Array<{ + condition: ConfigVariants>; + styles?: StyleRule; + }>; +}; + +/** + * Identity function that provides MosaicTheme typing for style functions. + * Use this to get autocomplete and type checking for theme properties. + * + * @example + * ```ts + * const buttonStyles = variants({ + * base: style(theme => ({ padding: theme.spacing[2] })), + * variants: { + * variant: { + * primary: style(theme => ({ background: theme.colors.purple[700] })), + * }, + * }, + * }); + * ``` + */ +export const style = (fn: StyleFunction): StyleFunction => fn; + +// Resolves a StyleRule (either a CSS object or a theme function) to a CSS object +const resolveStyleRule = (rule: StyleRule | undefined, theme: MosaicTheme): CSSObject => { + if (!rule) { + return {}; + } + if (typeof rule === 'function') { + return rule(theme); + } + return rule; +}; + +// Converts a variant value to its string key (handles boolean -> 'true'/'false') +const normalizeVariantValue = (value: any): string => { + if (typeof value === 'boolean') { + return String(value); + } + return String(value); +}; + +// Calculates which variants should be applied based on props and defaults +const calculateVariantsToBeApplied = ( + variants: Record>, + props: Record, + defaultVariants: Record, +) => { + const variantsToApply: Record = {}; + for (const key in variants) { + if (key in props && props[key] !== null && props[key] !== undefined) { + variantsToApply[key] = props[key]; + } else if (key in defaultVariants && defaultVariants[key] !== null && defaultVariants[key] !== undefined) { + variantsToApply[key] = defaultVariants[key]; + } + } + return variantsToApply; +}; + +// Checks if a compound variant condition matches the applied variants +const conditionMatches = ( + compoundVariant: { condition: Record; styles?: StyleRule }, + variantsToApply: Record, +) => { + const { condition } = compoundVariant; + for (const key in condition) { + if (condition[key] !== variantsToApply[key]) { + return false; + } + } + return true; +}; + +/** + * Creates a variant-based style function for Emotion CSS objects. + * Similar to CVA but works with CSS-in-JS instead of class names. + * + * @example + * ```ts + * const buttonStyles = variants({ + * base: (theme) => ({ padding: theme.spacing[2] }), + * variants: { + * variant: { + * primary: (theme) => ({ background: theme.colors.purple[700] }), + * secondary: (theme) => ({ background: theme.colors.gray[1200] }), + * }, + * size: { + * sm: { fontSize: '0.75rem' }, + * md: { fontSize: '1rem' }, + * }, + * }, + * defaultVariants: { + * variant: 'primary', + * size: 'md', + * }, + * }); + * + * // Usage: + * + * ``` + */ +export function variants(config: VariantsConfig) { + const { base, variants: variantDefinitions = {} as T, defaultVariants = {}, compoundVariants = [] } = config; + + return (props: ConfigVariants = {}): Interpolation => { + return ((theme: Theme) => { + // At runtime, theme is MosaicTheme when used within MosaicThemeProvider + const mosaicTheme = theme as unknown as MosaicTheme; + + // Start with an empty object that will accumulate all styles + const computedStyles: CSSObject = {}; + + // Apply base styles + const baseStyles = resolveStyleRule(base, mosaicTheme); + if (baseStyles && typeof baseStyles === 'object') { + fastDeepMergeAndReplace(baseStyles, computedStyles); + } + + // Calculate which variants to apply (cast to runtime type) + const variantsToApply = calculateVariantsToBeApplied( + variantDefinitions as Record>, + props as Record, + defaultVariants as Record, + ); + + // Apply variant styles + const variantDefs = variantDefinitions as Record>; + for (const variantKey in variantsToApply) { + const variantValue = variantsToApply[variantKey]; + const variantDef = variantDefs[variantKey]; + // Convert boolean values to string keys for lookup + const normalizedValue = normalizeVariantValue(variantValue); + if (variantDef && normalizedValue in variantDef) { + const variantStyle = resolveStyleRule(variantDef[normalizedValue], mosaicTheme); + fastDeepMergeAndReplace(variantStyle, computedStyles); + } + } + + // Apply compound variant styles + for (const compoundVariant of compoundVariants) { + if (conditionMatches(compoundVariant, variantsToApply)) { + const compoundStyles = resolveStyleRule(compoundVariant.styles, mosaicTheme); + fastDeepMergeAndReplace(compoundStyles, computedStyles); + } + } + + return computedStyles; + }) as unknown as Interpolation; + }; +} From dbaf0054b415b0e3ff6c1f81bda4d48bd68e0b2e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Feb 2026 17:53:07 -0500 Subject: [PATCH 4/6] fix variants and add tests --- .../ui/src/mosaic/__tests__/variants.test.ts | 380 ++++++++++++++++++ .../mosaic/__tests__/variants.type.test.ts | 253 ++++++++++++ packages/ui/src/mosaic/variants.ts | 60 +-- 3 files changed, 655 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/mosaic/__tests__/variants.test.ts create mode 100644 packages/ui/src/mosaic/__tests__/variants.type.test.ts diff --git a/packages/ui/src/mosaic/__tests__/variants.test.ts b/packages/ui/src/mosaic/__tests__/variants.test.ts new file mode 100644 index 00000000000..ccbede7083b --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/variants.test.ts @@ -0,0 +1,380 @@ +// eslint-disable-next-line no-restricted-imports +import type { Interpolation, Theme } from '@emotion/react'; +import { describe, expect, it } from 'vitest'; + +import { mosaicTheme } from '../theme'; +import { style, variants } from '../variants'; + +// Helper to extract the CSS object from Interpolation +function getStyles(interpolation: Interpolation): Record { + if (typeof interpolation === 'function') { + return interpolation(mosaicTheme as unknown as Theme) as Record; + } + if (interpolation == null) { + return {}; + } + return interpolation as Record; +} + +describe('variants', () => { + it('applies base styles', () => { + const buttonStyles = variants({ + base: { padding: '1rem', margin: '0.5rem' }, + variants: {}, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ padding: '1rem', margin: '0.5rem' }); + }); + + it('applies base styles from function', () => { + const buttonStyles = variants({ + base: style(theme => ({ + padding: theme.spacing[4], + color: theme.colors.white, + })), + variants: {}, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ + padding: mosaicTheme.spacing[4], + color: mosaicTheme.colors.white, + }); + }); + + it('applies variant styles based on props', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem', padding: '0.5rem' }, + md: { fontSize: '1rem', padding: '1rem' }, + lg: { fontSize: '1.25rem', padding: '1.5rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ fontSize: '0.75rem', padding: '0.5rem' }); + }); + + it('applies variant styles from function', () => { + const buttonStyles = variants({ + variants: { + variant: { + primary: style(theme => ({ + background: theme.colors.purple[700], + color: theme.colors.white, + })), + secondary: style(theme => ({ + background: theme.colors.gray[200], + color: theme.colors.gray[1200], + })), + }, + }, + }); + + const result = getStyles(buttonStyles({ variant: 'primary' })); + expect(result).toEqual({ + background: mosaicTheme.colors.purple[700], + color: mosaicTheme.colors.white, + }); + }); + + it('applies multiple variants', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' })); + expect(result).toEqual({ fontSize: '0.75rem', background: 'blue' }); + }); + + it('applies default variants when props are not provided', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + defaultVariants: { + size: 'md', + variant: 'primary', + }, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ fontSize: '1rem', background: 'blue' }); + }); + + it('overrides default variants with props', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + defaultVariants: { + size: 'md', + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ fontSize: '0.75rem' }); + }); + + it('applies boolean variants with true/false keys', () => { + const buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + disabled: { + true: { opacity: 0.5, cursor: 'not-allowed' }, + false: { opacity: 1, cursor: 'pointer' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ fullWidth: true, disabled: false })); + expect(result).toEqual({ width: '100%', opacity: 1, cursor: 'pointer' }); + }); + + it('applies boolean variants from default variants', () => { + const buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + fullWidth: false, + }, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ width: 'fit-content' }); + }); + + it('applies compound variants when conditions match', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: { borderRadius: '0.25rem' }, + }, + { + condition: { size: 'md', variant: 'secondary' }, + styles: { borderRadius: '0.5rem' }, + }, + ], + }); + + const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' })); + expect(result).toEqual({ + fontSize: '0.75rem', + background: 'blue', + borderRadius: '0.25rem', + }); + }); + + it('applies compound variants with function styles', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: style(theme => ({ + border: `1px solid ${theme.colors.purple[700]}`, + })), + }, + ], + }); + + const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' })); + expect(result).toEqual({ + fontSize: '0.75rem', + background: 'blue', + border: `1px solid ${mosaicTheme.colors.purple[700]}`, + }); + }); + + it('does not apply compound variants when conditions do not match', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: { borderRadius: '0.25rem' }, + }, + ], + }); + + const result = getStyles(buttonStyles({ size: 'md', variant: 'primary' })); + expect(result).toEqual({ + fontSize: '1rem', + background: 'blue', + }); + }); + + it('merges base styles with variant styles', () => { + const buttonStyles = variants({ + base: { padding: '1rem', margin: '0.5rem' }, + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ + padding: '1rem', + margin: '0.5rem', + fontSize: '0.75rem', + }); + }); + + it('merges styles with later variants overriding earlier ones', () => { + const buttonStyles = variants({ + base: { fontSize: '1rem' }, + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ fontSize: '0.75rem' }); + }); + + it('handles nested pseudo-selectors', () => { + const buttonStyles = variants({ + base: { + '&:hover': { opacity: 0.8 }, + '&:active': { opacity: 0.6 }, + }, + variants: { + variant: { + primary: { + '&:hover': { background: 'darkblue' }, + }, + }, + }, + }); + + const result = getStyles(buttonStyles({ variant: 'primary' })); + expect(result).toEqual({ + '&:hover': { opacity: 0.8, background: 'darkblue' }, + '&:active': { opacity: 0.6 }, + }); + }); + + it('handles null and undefined props', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + defaultVariants: { + size: 'md', + }, + }); + + const result1 = getStyles(buttonStyles({ size: null as any })); + const result2 = getStyles(buttonStyles({ size: undefined as any })); + expect(result1).toEqual({ fontSize: '1rem' }); + expect(result2).toEqual({ fontSize: '1rem' }); + }); + + it('handles empty variants object', () => { + const buttonStyles = variants({ + base: { padding: '1rem' }, + variants: {}, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ padding: '1rem' }); + }); + + it('handles missing variant definition gracefully', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'nonexistent' as any })); + expect(result).toEqual({}); + }); +}); + +describe('style', () => { + it('returns the function unchanged', () => { + const fn = (theme: typeof mosaicTheme) => ({ color: theme.colors.white }); + const result = style(fn); + expect(result).toBe(fn); + }); + + it('provides MosaicTheme typing', () => { + const fn = style(theme => ({ + color: theme.colors.white, + padding: theme.spacing[4], + })); + + const result = fn(mosaicTheme); + expect(result).toEqual({ + color: mosaicTheme.colors.white, + padding: mosaicTheme.spacing[4], + }); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/variants.type.test.ts b/packages/ui/src/mosaic/__tests__/variants.type.test.ts new file mode 100644 index 00000000000..6db8bb23b2a --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/variants.type.test.ts @@ -0,0 +1,253 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { MosaicTheme } from '../theme'; +import { style, variants } from '../variants'; + +describe('variants type tests', () => { + describe('boolean variants', () => { + it('allows boolean values in defaultVariants for true/false keys', () => { + const _buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + fullWidth: false, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ fullWidth: false }>().toMatchTypeOf(); + expectTypeOf<{ fullWidth: true }>().toMatchTypeOf(); + }); + + it('allows boolean values in props for true/false keys', () => { + const _buttonStyles = variants({ + variants: { + disabled: { + true: { opacity: 0.5 }, + false: { opacity: 1 }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ disabled: true }>().toMatchTypeOf(); + expectTypeOf<{ disabled: false }>().toMatchTypeOf(); + }); + + it('does not allow string values for boolean variants', () => { + const _buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ fullWidth: 'true' }>().not.toMatchTypeOf(); + }); + }); + + describe('string variants', () => { + it('allows string literal values in defaultVariants', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + lg: { fontSize: '1.25rem' }, + }, + }, + defaultVariants: { + size: 'md', + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: 'sm' }>().toMatchTypeOf(); + expectTypeOf<{ size: 'md' }>().toMatchTypeOf(); + expectTypeOf<{ size: 'lg' }>().toMatchTypeOf(); + }); + + it('does not allow invalid string values', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: 'xl' }>().not.toMatchTypeOf(); + }); + }); + + describe('mixed variants', () => { + it('allows mixing boolean and string variants', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + size: 'sm', + fullWidth: false, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: 'md'; fullWidth: true }>().toMatchTypeOf(); + }); + }); + + describe('style helper', () => { + it('provides MosaicTheme typing for theme parameter', () => { + const fn = style(theme => { + // Theme parameter should be typed as MosaicTheme + return { color: theme.colors.white }; + }); + + expectTypeOf(fn).toBeFunction(); + expectTypeOf[0]>().toEqualTypeOf(); + }); + + it('returns StyleFunction type', () => { + const _fn = style(theme => ({ color: theme.colors.white })); + type Return = ReturnType; + expectTypeOf().toMatchTypeOf>(); + }); + }); + + describe('props type inference', () => { + it('infers correct props type from variants', () => { + const _buttonStyles = variants({ + variants: { + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + // Props should allow optional variant and size + expectTypeOf<{ variant?: 'primary' | 'secondary'; size?: 'sm' | 'md' }>().toMatchTypeOf(); + }); + + it('allows partial props', () => { + const _buttonStyles = variants({ + variants: { + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ variant: 'primary' }>().toMatchTypeOf(); + expectTypeOf<{ size: 'md' }>().toMatchTypeOf(); + expectTypeOf>().toMatchTypeOf(); + }); + + it('allows null and undefined in props', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: null }>().toMatchTypeOf(); + expectTypeOf<{ size: undefined }>().toMatchTypeOf(); + }); + }); + + describe('compound variants', () => { + it('allows compound variants with correct condition types', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: { borderRadius: '0.25rem' }, + }, + ], + }); + + // Type should compile without errors + expectTypeOf(_buttonStyles).toBeFunction(); + }); + + it('allows boolean values in compound variant conditions', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', fullWidth: true }, + styles: { padding: '0.5rem' }, + }, + ], + }); + + // Type should compile without errors + expectTypeOf(_buttonStyles).toBeFunction(); + }); + }); + + describe('return type', () => { + it('returns Interpolation', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + }, + }); + + // Verify the function can be called with props + expectTypeOf(_buttonStyles({ size: 'sm' })).toMatchTypeOf(); + }); + }); +}); diff --git a/packages/ui/src/mosaic/variants.ts b/packages/ui/src/mosaic/variants.ts index 3d3a4f2fce0..6cfae35f40f 100644 --- a/packages/ui/src/mosaic/variants.ts +++ b/packages/ui/src/mosaic/variants.ts @@ -48,8 +48,7 @@ type VariantsConfig = { */ export const style = (fn: StyleFunction): StyleFunction => fn; -// Resolves a StyleRule (either a CSS object or a theme function) to a CSS object -const resolveStyleRule = (rule: StyleRule | undefined, theme: MosaicTheme): CSSObject => { +function resolveStyleRule(rule: StyleRule | undefined, theme: MosaicTheme): CSSObject { if (!rule) { return {}; } @@ -57,38 +56,31 @@ const resolveStyleRule = (rule: StyleRule | undefined, theme: MosaicTheme): CSSO return rule(theme); } return rule; -}; +} -// Converts a variant value to its string key (handles boolean -> 'true'/'false') -const normalizeVariantValue = (value: any): string => { - if (typeof value === 'boolean') { - return String(value); - } +function normalizeVariantValue(value: unknown): string { return String(value); -}; +} -// Calculates which variants should be applied based on props and defaults -const calculateVariantsToBeApplied = ( +function calculateVariantsToBeApplied( variants: Record>, - props: Record, - defaultVariants: Record, -) => { - const variantsToApply: Record = {}; + props: Record, + defaultVariants: Record, +): Record { + const variantsToApply: Record = {}; for (const key in variants) { - if (key in props && props[key] !== null && props[key] !== undefined) { - variantsToApply[key] = props[key]; - } else if (key in defaultVariants && defaultVariants[key] !== null && defaultVariants[key] !== undefined) { - variantsToApply[key] = defaultVariants[key]; + const value = props[key] ?? defaultVariants[key]; + if (value != null) { + variantsToApply[key] = value; } } return variantsToApply; -}; +} -// Checks if a compound variant condition matches the applied variants -const conditionMatches = ( - compoundVariant: { condition: Record; styles?: StyleRule }, - variantsToApply: Record, -) => { +function conditionMatches( + compoundVariant: { condition: Record; styles?: StyleRule }, + variantsToApply: Record, +): boolean { const { condition } = compoundVariant; for (const key in condition) { if (condition[key] !== variantsToApply[key]) { @@ -96,7 +88,7 @@ const conditionMatches = ( } } return true; -}; +} /** * Creates a variant-based style function for Emotion CSS objects. @@ -131,31 +123,24 @@ export function variants(config: VariantsConfig) { return (props: ConfigVariants = {}): Interpolation => { return ((theme: Theme) => { - // At runtime, theme is MosaicTheme when used within MosaicThemeProvider const mosaicTheme = theme as unknown as MosaicTheme; - - // Start with an empty object that will accumulate all styles const computedStyles: CSSObject = {}; + const variantDefs = variantDefinitions as Record>; - // Apply base styles const baseStyles = resolveStyleRule(base, mosaicTheme); if (baseStyles && typeof baseStyles === 'object') { fastDeepMergeAndReplace(baseStyles, computedStyles); } - // Calculate which variants to apply (cast to runtime type) const variantsToApply = calculateVariantsToBeApplied( - variantDefinitions as Record>, - props as Record, - defaultVariants as Record, + variantDefs, + props as Record, + defaultVariants as Record, ); - // Apply variant styles - const variantDefs = variantDefinitions as Record>; for (const variantKey in variantsToApply) { const variantValue = variantsToApply[variantKey]; const variantDef = variantDefs[variantKey]; - // Convert boolean values to string keys for lookup const normalizedValue = normalizeVariantValue(variantValue); if (variantDef && normalizedValue in variantDef) { const variantStyle = resolveStyleRule(variantDef[normalizedValue], mosaicTheme); @@ -163,7 +148,6 @@ export function variants(config: VariantsConfig) { } } - // Apply compound variant styles for (const compoundVariant of compoundVariants) { if (conditionMatches(compoundVariant, variantsToApply)) { const compoundStyles = resolveStyleRule(compoundVariant.styles, mosaicTheme); From ffa2e45e7b41bfcc7ffcc66439f89aaa9fb6f862 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Feb 2026 17:55:49 -0500 Subject: [PATCH 5/6] make full width --- .../ui/src/components/devPrompts/KeylessPrompt/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx index 49228a9e84d..813d6cee6d6 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx @@ -478,7 +478,9 @@ function KeylessPromptInternal(props: KeylessPromptProps) { window.location.reload(); }); }} - css={buttonStyles()} + css={buttonStyles({ + fullWidth: true, + })} > Dismiss @@ -487,7 +489,9 @@ function KeylessPromptInternal(props: KeylessPromptProps) { href={claimUrlToDashboard} target='_blank' rel='noopener noreferrer' - css={buttonStyles()} + css={buttonStyles({ + fullWidth: true, + })} > {claimed ? 'Get API keys' : 'Configure your application'} Date: Tue, 3 Feb 2026 15:27:04 -0500 Subject: [PATCH 6/6] wip --- .../EnableOrganizationsPrompt/index.tsx | 980 ++++++++++-------- .../devPrompts/KeylessPrompt/index.tsx | 128 +-- packages/ui/src/mosaic/button.ts | 35 +- packages/ui/src/mosaic/text.ts | 48 + packages/ui/src/mosaic/theme.ts | 28 +- packages/ui/src/mosaic/variants.ts | 22 + 6 files changed, 728 insertions(+), 513 deletions(-) create mode 100644 packages/ui/src/mosaic/text.ts diff --git a/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx b/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx index 13cd609898c..7382ede8a21 100644 --- a/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx @@ -1,8 +1,6 @@ import { createContextAndHook, useClerk } from '@clerk/shared/react'; import type { __internal_EnableOrganizationsPromptProps, EnableEnvironmentSettingParams } from '@clerk/shared/types'; // eslint-disable-next-line no-restricted-imports -import type { SerializedStyles } from '@emotion/react'; -// eslint-disable-next-line no-restricted-imports import { css } from '@emotion/react'; import React, { forwardRef, useId, useLayoutEffect, useRef, useState } from 'react'; @@ -10,13 +8,14 @@ import { useEnvironment } from '@/ui/contexts'; import { Modal } from '@/ui/elements/Modal'; import { InternalThemeProvider } from '@/ui/styledSystem'; -import { Flex } from '../../../customizables'; import { Portal } from '../../../elements/Portal'; -import { basePromptElementStyles, ClerkLogoIcon, PromptContainer, PromptSuccessIcon } from '../shared'; +import { buttonStyles } from '../../../mosaic/button'; +import { textStyles } from '../../../mosaic/text'; +import { MosaicThemeProvider, useMosaicTheme } from '../../../mosaic/theme-provider'; const organizationsDashboardUrl = 'https://dashboard.clerk.com/~/organizations-settings'; -const EnableOrganizationsPromptInternal = ({ +const EnableOrganizationsPromptContent = ({ caller, onSuccess, onClose, @@ -30,6 +29,7 @@ const EnableOrganizationsPromptInternal = ({ const initialFocusRef = useRef(null); const environment = useEnvironment(); const radioGroupLabelId = useId(); + const theme = useMosaicTheme(); const isComponent = !caller.startsWith('use'); @@ -64,220 +64,327 @@ const EnableOrganizationsPromptInternal = ({ }; return ( - - ({ alignItems: 'center' })} - initialFocusRef={initialFocusRef} +
+
- ({ - display: 'flex', - flexDirection: 'column', - width: '30rem', - maxWidth: 'calc(100vw - 2rem)', - })} +
+ + + + + +

Enable Organizations

+
+
- ({ - padding: `${t.sizes.$4} ${t.sizes.$6}`, - paddingBottom: t.sizes.$4, - gap: t.sizes.$2, +

- ({ - gap: t.sizes.$2, - })} - > - - -

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Nisi, earum? +

+

+
+
+ + +
+
+ ); + + return ( +
+
+
+ + +

+ {isEnabled ? 'Organizations feature enabled' : 'Organizations feature required'} +

+
+ +
+ {isEnabled ? ( +

+ {clerk.user && defaultOrganizationName + ? `The Organizations feature has been enabled for your application. A default organization named "${defaultOrganizationName}" was created automatically. You can manage or rename it in your` + : `The Organizations feature has been enabled for your application. You can manage it in your`}{' '} + - {isEnabled ? 'Organizations feature enabled' : 'Organizations feature required'} - - - - ({ - gap: t.sizes.$0x5, - })} - > - {isEnabled ? ( -

- {clerk.user && defaultOrganizationName - ? `The Organizations feature has been enabled for your application. A default organization named "${defaultOrganizationName}" was created automatically. You can manage or rename it in your` - : `The Organizations feature has been enabled for your application. You can manage it in your`}{' '} - - dashboard - - . -

- ) : ( - <> -

- Enable Organizations to use{' '} - - {isComponent ? `<${caller} />` : caller} - {' '} -

- - - Learn more - - - )} - - - {hasPersonalAccountsEnabled && !isEnabled && ( - ({ marginTop: t.sizes.$2 })} - direction='col' + dashboard + + . +

+ ) : ( + <> +

+ Enable Organizations to use{' '} + + {isComponent ? `<${caller} />` : caller} + {' '} +

+ + - setAllowPersonalAccount(value === 'optional')} - labelledBy={radioGroupLabelId} - > - ({ columnGap: t.sizes.$2, rowGap: t.sizes.$1 })} - > - Membership required - Standard -
- } - description={ - <> - Users need to belong to at least one organization. - Common for most B2B SaaS applications - - } - /> - - - - )} - - - + + )} +
+ + {hasPersonalAccountsEnabled && !isEnabled && ( +
+ > + setAllowPersonalAccount(value === 'optional')} + labelledBy={radioGroupLabelId} + > + + Membership required + Standard + + } + description={ + <> + + Users need to belong to at least one organization. + + Common for most B2B SaaS applications + + } + /> + + +
+ )} +
- ({ - padding: `${t.sizes.$4} ${t.sizes.$6}`, - gap: t.sizes.$3, - justifyContent: 'flex-end', - })} +
+ {isEnabled ? ( + + ) : ( + <> + + + + + )} +
+
+ ); +}; + +const EnableOrganizationsPromptInternal = (props: __internal_EnableOrganizationsPromptProps): JSX.Element => { + const initialFocusRef = useRef(null); + + return ( + + ({ alignItems: 'center' })} + initialFocusRef={initialFocusRef} + > + + + ); @@ -295,112 +402,27 @@ export const EnableOrganizationsPrompt = (props: __internal_EnableOrganizationsP ); }; -const baseButtonStyles = css` - ${basePromptElementStyles}; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - height: 1.75rem; - padding: 0.375rem 0.625rem; - border-radius: 0.375rem; - font-size: 0.75rem; - font-weight: 500; - letter-spacing: 0.12px; - color: white; - text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.32); - white-space: nowrap; - user-select: none; - color: white; - outline: none; - - &:not(:disabled) { - transition: 120ms ease-in-out; - transition-property: background-color, border-color, box-shadow, color; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &:focus-visible:not(:disabled) { - outline: 2px solid white; - outline-offset: 2px; - } -`; - -const buttonSolidStyles = css` - background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30.5%, rgba(0, 0, 0, 0.05) 100%), #454545; - box-shadow: - 0 0 3px 0 rgba(253, 224, 71, 0) inset, - 0 0 0 1px rgba(255, 255, 255, 0.04) inset, - 0 1px 0 0 rgba(255, 255, 255, 0.04) inset, - 0 0 0 1px rgba(0, 0, 0, 0.12), - 0 1.5px 2px 0 rgba(0, 0, 0, 0.48); - - &:hover:not(:disabled) { - background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30.5%, rgba(0, 0, 0, 0.15) 100%), #5f5f5f; - box-shadow: - 0 0 3px 0 rgba(253, 224, 71, 0) inset, - 0 0 0 1px rgba(255, 255, 255, 0.04) inset, - 0 1px 0 0 rgba(255, 255, 255, 0.04) inset, - 0 0 0 1px rgba(0, 0, 0, 0.12), - 0 1.5px 2px 0 rgba(0, 0, 0, 0.48); - } -`; - -const buttonOutlineStyles = css` - border: 1px solid rgba(118, 118, 132, 0.25); - background: rgba(69, 69, 69, 0.1); - - &:hover:not(:disabled) { - border-color: rgba(118, 118, 132, 0.5); - } -`; - -const buttonVariantStyles = { - solid: buttonSolidStyles, - outline: buttonOutlineStyles, -} as const; - -type PromptButtonVariant = keyof typeof buttonVariantStyles; - -type PromptButtonProps = Pick, 'onClick' | 'children' | 'disabled'> & { - variant?: PromptButtonVariant; -}; - -const PromptButton = forwardRef(({ variant = 'solid', ...props }, ref) => { - return ( -