diff --git a/.changeset/mosaic-badge.md b/.changeset/mosaic-badge.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-badge.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/next.config.mjs b/packages/swingset/next.config.mjs index 039a919e74a..1a9591b88ec 100644 --- a/packages/swingset/next.config.mjs +++ b/packages/swingset/next.config.mjs @@ -61,15 +61,23 @@ const nextConfig = { // Swingset consumes Mosaic from source, so StyleX (`defineVars`/`create`/`props`) must be // compiled here — otherwise the calls hit the runtime and throw. The unplugin transforms the - // StyleX *JS only* (calls → static atom references), keeping SWC intact so `next/font` and the - // Emotion transform keep working. The CSS is emitted separately by `@stylexjs/postcss-plugin` - // (`@stylex` in `globals.css`), so this runs in extraction mode (no `runtimeInjection`); both - // share the same StyleX babel version/options so the atom hashes match, and the plugin's dev - // "no CSS asset" warning is expected and harmless. `useCSSLayers: true` matches the published - // build so atoms carry StyleX's `@layer priorityN` precedence. + // StyleX *JS only*, keeping SWC intact so `next/font` and the Emotion transform keep working. + // + // The `@stylexjs/postcss-plugin` (see `postcss.config.mjs`) is what extracts the CSS — the + // token `:root { --cl-* }` defaults and the atoms — in both dev and prod. This unplugin only + // transforms the StyleX *calls* in the JS. `runtimeInjection` forks by env: + // - Prod: `false`. Atoms are static class refs resolved against the extracted sheet. + // - Dev: `true`. On top of the extracted sheet, StyleX also injects each atom at runtime under + // its content hash, so editing a `.styles.ts` file hot-reloads a fresh atom (the extracted + // sheet goes stale because Next won't re-run the `globals.css` PostCSS pass on Mosaic-source + // edits). The `:root` token defaults come from the extraction and never change mid-session, + // so they stay correct — `runtimeInjection` can't emit them (`defineVars` is compile-only). + // Both passes share the same babel version/options so atom hashes match. + const isDev = process.env.NODE_ENV !== 'production'; config.plugins.push( stylexPlugin({ - dev: process.env.NODE_ENV !== 'production', + dev: isDev, + runtimeInjection: isDev, unstable_moduleResolution: { type: 'commonJS', rootDir: resolve(__dirname, '../ui') }, useCSSLayers: true, }), diff --git a/packages/swingset/postcss.config.mjs b/packages/swingset/postcss.config.mjs index b7bba3969ee..6a9d09bc734 100644 --- a/packages/swingset/postcss.config.mjs +++ b/packages/swingset/postcss.config.mjs @@ -5,34 +5,47 @@ import { fileURLToPath } from 'url'; const require = createRequire(import.meta.url); const __dirname = dirname(fileURLToPath(import.meta.url)); -// StyleX CSS extraction. The `@stylexjs/postcss-plugin` scans the Mosaic source, runs the -// StyleX babel transform itself, and replaces the `@stylex;` directive in `globals.css` with -// the generated CSS (token `:root` defaults + atoms). This is the CSS half of the setup; the -// JS half is the unplugin in `next.config.mjs`. Both must use the SAME StyleX babel version -// and options (`dev`, `rootDir`) so the atom class hashes line up. const uiRoot = resolve(__dirname, '../ui'); +const isDev = process.env.NODE_ENV !== 'production'; -export default { - plugins: { - '@stylexjs/postcss-plugin': { - useCSSLayers: true, - babelConfig: { - babelrc: false, - configFile: false, - presets: [require('@babel/preset-typescript')], - plugins: [ - require('@babel/plugin-syntax-jsx'), - [ - require('@stylexjs/babel-plugin'), - { - dev: process.env.NODE_ENV !== 'production', - runtimeInjection: false, - unstable_moduleResolution: { type: 'commonJS', rootDir: uiRoot }, - }, - ], +// StyleX CSS. `@stylexjs/postcss-plugin` scans the Mosaic source, runs the StyleX babel +// transform, and replaces the `@stylex;` directive in `globals.css` with the generated CSS: +// the token `:root { --cl-* }` defaults *and* the atoms. This runs in BOTH dev and prod +// because it is the only thing that emits the `:root` token defaults — StyleX's `defineVars` +// is compile-time-only (its runtime export throws), so `runtimeInjection` alone leaves every +// `var(--cl-*)` unresolved (unstyled). Its babel `dev`/`rootDir` must match the unplugin in +// `next.config.mjs` so atom hashes line up. +// +// In dev this sheet goes stale on `.styles.ts` edits (Next won't re-run the `globals.css` +// PostCSS pass for files outside the CSS import graph), but that's fine: the unplugin's +// `runtimeInjection` (see `next.config.mjs`) injects the *fresh* atom at runtime under a new +// content hash, which HMR tracks. The stale extracted atom is dead CSS; the `:root` token +// defaults never change mid-session, so they stay correct. +const stylexExtraction = { + '@stylexjs/postcss-plugin': { + useCSSLayers: true, + babelConfig: { + babelrc: false, + configFile: false, + presets: [require('@babel/preset-typescript')], + plugins: [ + require('@babel/plugin-syntax-jsx'), + [ + require('@stylexjs/babel-plugin'), + { + dev: isDev, + runtimeInjection: false, + unstable_moduleResolution: { type: 'commonJS', rootDir: uiRoot }, + }, ], - }, + ], }, + }, +}; + +export default { + plugins: { + ...stylexExtraction, '@tailwindcss/postcss': {}, }, }; diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 35bdb3e9da1..8ee60a0d29e 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -28,6 +28,7 @@ const docModules: Record> = { destructive: dynamic(() => import('../stories/destructive.mdx')), }, components: { + badge: dynamic(() => import('../stories/badge.mdx')), button: dynamic(() => import('../stories/button.mdx')), card: dynamic(() => import('../stories/card.component.mdx')), input: dynamic(() => import('../stories/input.mdx')), diff --git a/packages/swingset/src/components/PropTable.tsx b/packages/swingset/src/components/PropTable.tsx index dedf91462ba..784371d665f 100644 --- a/packages/swingset/src/components/PropTable.tsx +++ b/packages/swingset/src/components/PropTable.tsx @@ -15,11 +15,13 @@ interface ExtraProp { interface PropTableProps { meta: StoryMeta; extra?: ExtraProp[]; + /** Append the `sx` row. StyleX components (e.g. Badge) don't take `sx`, so pass `false`. */ + sx?: boolean; } const SX_ROW: ExtraProp = { name: 'sx', type: 'StyleRule | (theme) => StyleRule' }; -export function PropTable({ meta, extra = [] }: PropTableProps) { +export function PropTable({ meta, extra = [], sx = true }: PropTableProps) { const playground = usePlayground(); const variants = meta.styles?._variants ?? {}; const defaults = meta.styles?._defaultVariants ?? {}; @@ -35,7 +37,7 @@ export function PropTable({ meta, extra = [] }: PropTableProps) { return { name, type, default: defDisplay }; }), ...extra, - SX_ROW, + ...(sx ? [SX_ROW] : []), ]; return ( diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dee445b26f0..087108082ce 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,6 +1,12 @@ // Import stories explicitly to control order and avoid type casting through unknown. import { meta as accordionMeta } from '../stories/accordion.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; +import { + Colors as BadgeColors, + meta as badgeMeta, + Primary as BadgePrimary, + WithIcon as BadgeWithIcon, +} from '../stories/badge.stories'; import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories'; import { Centered as CardCentered, @@ -115,6 +121,13 @@ const organizationProfileMembersPanelModule: StoryModule = { const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered }; +const badgeModule: StoryModule = { + meta: badgeMeta, + Primary: BadgePrimary, + Colors: BadgeColors, + WithIcon: BadgeWithIcon, +}; + const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled }; const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid }; @@ -171,6 +184,7 @@ export const registry: StoryModule[] = [ // Blocks destructiveModule, // Components + badgeModule, buttonModule, cardComponentModule, inputModule, diff --git a/packages/swingset/src/stories/badge.mdx b/packages/swingset/src/stories/badge.mdx new file mode 100644 index 00000000000..ca33dfdf342 --- /dev/null +++ b/packages/swingset/src/stories/badge.mdx @@ -0,0 +1,47 @@ +import * as BadgeStories from './badge.stories'; + +# Badge + +Badge labels the status or category of the thing next to it. It renders a `span` by default and forwards a ref to the underlying element; use the `render` prop when the badge belongs in a different element, such as a link. Its `color` carries meaning, so keep the label itself self-describing for anyone who can't see it. + +## Playground + + + +## Props + + ReactNode' }]} + sx={false} +/> + +## Usage + + + Badge Label + + +--- + +## Examples + +### Colors + + + +### With an icon + + diff --git a/packages/swingset/src/stories/badge.stories.tsx b/packages/swingset/src/stories/badge.stories.tsx new file mode 100644 index 00000000000..57f4bce4cd4 --- /dev/null +++ b/packages/swingset/src/stories/badge.stories.tsx @@ -0,0 +1,95 @@ +import type { BadgeProps } from '@clerk/ui/mosaic/components/badge'; +import { Badge } from '@clerk/ui/mosaic/components/badge'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './badge.stories?raw'; + +// StyleX has no runtime recipe to derive knobs from, so the variant surface is described +// here to drive the playground + prop table. Keys mirror `BadgeProps`. +export const meta: StoryMeta = { + group: 'Components', + title: 'Badge', + source: 'packages/ui/src/mosaic/components/badge/badge.tsx', + styles: { + _variants: { + color: { primary: {}, neutral: {}, warning: {}, negative: {}, positive: {} }, + }, + _defaultVariants: { + color: 'primary', + }, + }, +}; + +// Story functions accept Record (knob values) and cast to BadgeProps. +// The cast is unavoidable: knobs are dynamically typed; Badge has a strict prop interface. +function knobsAsProps(props: Record) { + return props as unknown as BadgeProps; +} + +export function Primary(props: Record) { + return Badge Label; +} + +export function Colors(props: Record) { + return ( +
+ + Primary + + + Neutral + + + Warning + + + Negative + + + Positive + +
+ ); +} + +export function WithIcon(props: Record) { + return ( + + + + + Verified + + ); +} diff --git a/packages/ui/src/mosaic/components/badge/badge.styles.ts b/packages/ui/src/mosaic/components/badge/badge.styles.ts new file mode 100644 index 00000000000..f4bd9b8a6e3 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.styles.ts @@ -0,0 +1,46 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +// warning/negative/positive tint a faded fill and use the saturated token as text; +// primary/neutral fill with the solid token and use its `-foreground` for text. +export const styles = stylex.create({ + base: { + borderRadius: radiusVars['--cl-radius-full'], + gap: space['1'], + paddingInline: space['2'], + alignItems: 'center', + boxSizing: 'border-box', + display: 'inline-flex', + fontFamily: 'inherit', + fontSize: typeScaleVars['--cl-text-label-sm-size'], + fontWeight: typeScaleVars['--cl-text-label-sm-weight'], + justifyContent: 'center', + lineHeight: typeScaleVars['--cl-text-label-sm-leading'], + whiteSpace: 'nowrap', + height: space['5'], + }, +}); + +export const colors = stylex.create({ + primary: { + backgroundColor: colorVars['--cl-color-primary'], + color: colorVars['--cl-color-primary-foreground'], + }, + neutral: { + backgroundColor: colorVars['--cl-color-neutral'], + color: colorVars['--cl-color-neutral-foreground'], + }, + warning: { + backgroundColor: colorVars['--cl-color-warning-faded'], + color: colorVars['--cl-color-warning'], + }, + negative: { + backgroundColor: colorVars['--cl-color-negative-faded'], + color: colorVars['--cl-color-negative'], + }, + positive: { + backgroundColor: colorVars['--cl-color-positive-faded'], + color: colorVars['--cl-color-positive'], + }, +}); diff --git a/packages/ui/src/mosaic/components/badge/badge.test.tsx b/packages/ui/src/mosaic/components/badge/badge.test.tsx new file mode 100644 index 00000000000..fa664e5cae9 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Badge } from './badge'; + +describe('Mosaic Badge', () => { + it('renders its children', () => { + render(Active); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('applies the default color when none is passed', () => { + render(Active); + const badge = screen.getByText('Active'); + expect(badge).toHaveClass('cl-badge'); + expect(badge).toHaveAttribute('data-color', 'primary'); + }); + + it.each(['primary', 'neutral', 'warning', 'negative', 'positive'] as const)('reflects the %s color', color => { + render(Active); + expect(screen.getByText('Active')).toHaveAttribute('data-color', color); + }); + + it('lets the consumer className and style win', () => { + render( + + Active + , + ); + const badge = screen.getByText('Active'); + expect(badge).toHaveClass('cl-badge', 'my-badge'); + expect(badge).toHaveStyle({ marginTop: '8px' }); + }); + + it('forwards arbitrary span props and the ref', () => { + const ref = React.createRef(); + render( + + Active + , + ); + const badge = screen.getByText('Active'); + expect(ref.current).toBe(badge); + expect(badge).toHaveAttribute('id', 'status'); + expect(badge).toHaveAttribute('aria-label', 'Status'); + }); + + it('renders a custom element via render, keeping the styling contract', () => { + render( + } + > + Active + , + ); + const badge = screen.getByRole('link', { name: 'Active' }); + expect(badge.tagName).toBe('A'); + expect(badge).toHaveAttribute('href', '/status'); + expect(badge).toHaveClass('cl-badge'); + expect(badge).toHaveAttribute('data-color', 'positive'); + }); +}); diff --git a/packages/ui/src/mosaic/components/badge/badge.tsx b/packages/ui/src/mosaic/components/badge/badge.tsx new file mode 100644 index 00000000000..fced1156e66 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.tsx @@ -0,0 +1,44 @@ +import { type ComponentProps, type RenderProp, useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { mergeStyleProps, themeProps } from '../../props'; +import { colors, styles } from './badge.styles'; + +export type BadgeProps = Omit, 'render'> & { + color?: 'primary' | 'neutral' | 'warning' | 'negative' | 'positive'; + render?: RenderProp> | React.ReactElement; +}; + +/** + * A small label that annotates adjacent content with a status or category. Renders a + * `span` by default and forwards its ref; `color` sets the semantic color and `render` + * swaps the element for polymorphism (e.g. a link). + * + * @example + * // Default (primary) + * New + * + * @example + * // Semantic color + * Active + * Failed + * + * @example + * // Polymorphic: render as a link + * }>Upgrade + */ +export const Badge = React.forwardRef(function MosaicBadge( + { color = 'primary', render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps(themeProps('badge', { color }), stylex.props(styles.base, colors[color]), className, style), + ...rest, + }, + }); +}); diff --git a/packages/ui/src/mosaic/components/badge/index.ts b/packages/ui/src/mosaic/components/badge/index.ts new file mode 100644 index 00000000000..b8e71bd1b09 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/index.ts @@ -0,0 +1,2 @@ +export { Badge } from './badge'; +export type { BadgeProps } from './badge'; diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index a2fa84cfc32..34cc2a651d3 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -38,13 +38,13 @@ export const styles = stylex.create({ }, filledDestructive: { backgroundColor: { - default: colorVars['--cl-color-destructive'], - ':active': `color-mix(in oklab, ${colorVars['--cl-color-destructive']}, ${colorVars['--cl-color-destructive-foreground']} 24%)`, + default: colorVars['--cl-color-negative'], + ':active': `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 24%)`, '@media (hover: hover)': { - ':hover': `color-mix(in oklab, ${colorVars['--cl-color-destructive']}, ${colorVars['--cl-color-destructive-foreground']} 12%)`, + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 12%)`, }, }, - color: colorVars['--cl-color-destructive-foreground'], + color: colorVars['--cl-color-negative-foreground'], }, outlinePrimary: { borderColor: colorVars['--cl-color-border'], @@ -54,7 +54,7 @@ export const styles = stylex.create({ outlineDestructive: { borderColor: colorVars['--cl-color-border'], backgroundColor: 'transparent', - color: colorVars['--cl-color-destructive'], + color: colorVars['--cl-color-negative'], }, ghostPrimary: { backgroundColor: 'transparent', @@ -62,7 +62,7 @@ export const styles = stylex.create({ }, ghostDestructive: { backgroundColor: 'transparent', - color: colorVars['--cl-color-destructive'], + color: colorVars['--cl-color-negative'], }, // size — height-driven; padding sets only the inline axis diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index 62d0dffbe46..aa38245d477 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -12,6 +12,26 @@ export interface ButtonProps extends React.ComponentPropsWithRef<'button'> { fullWidth?: boolean; } +/** + * A clickable action styled by the Mosaic recipe. Renders a `button` and forwards its + * ref; `intent`, `variant`, `size`, and `shape` compose to cover the full set of styles. + * + * @example + * // Default (primary, filled, md) + * + * + * @example + * // Destructive intent with a non-filled variant + * + * + * @example + * // Icon-only, circular, small + * + * + * @example + * // Full-width ghost button + * + */ export const Button = React.forwardRef(function MosaicButton( { intent = 'primary', diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index f1716fae927..22839ba4829 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -4,6 +4,8 @@ // static `styles.css`. Keep it isolated from Emotion/un-migrated code — grow it // as components migrate. +export { Badge } from '../components/badge'; +export type { BadgeProps } from '../components/badge'; export { Button } from '../components/button'; export type { ButtonProps } from '../components/button'; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 859d98ae9ac..12d928d5da1 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -26,10 +26,23 @@ import * as stylex from '@stylexjs/stylex'; const colorDefaults = { '--cl-color-primary': 'light-dark(oklch(0.205 0 0), oklch(0.922 0 0))', '--cl-color-primary-foreground': 'light-dark(oklch(0.985 0 0), oklch(0.205 0 0))', - '--cl-color-destructive': 'light-dark(oklch(0.577 0.245 27.325), oklch(0.637 0.237 25.331))', - '--cl-color-destructive-foreground': 'oklch(0.985 0 0)', - '--cl-color-muted': 'light-dark(oklch(0.97 0 0), oklch(0.269 0 0))', - '--cl-color-muted-foreground': 'light-dark(oklch(0.556 0 0), oklch(0.708 0 0))', + '--cl-color-primary-faded': 'light-dark(oklch(0.9583 0.0214 291.74), oklch(0.3097 0.1008 285.05))', + + '--cl-color-neutral': 'light-dark(oklch(0.97 0 0), oklch(0.32 0 0))', + '--cl-color-neutral-foreground': 'light-dark(oklch(0.24 0 0), oklch(0.96 0 0))', + + '--cl-color-negative': 'light-dark(oklch(0.577 0.245 27.325), oklch(0.637 0.237 25.331))', + '--cl-color-negative-foreground': 'oklch(0.985 0 0)', + '--cl-color-negative-faded': 'light-dark(oklch(0.9757 0.0118 17.36), oklch(0.255 0.0604 22.31))', + + '--cl-color-positive': 'light-dark(oklch(0.548 0.153 152.535), oklch(0.696 0.17 162.48))', + '--cl-color-positive-foreground': 'oklch(0.985 0 0)', + '--cl-color-positive-faded': 'light-dark(oklch(0.9859 0.0164 156.92), oklch(0.3297 0.052 152.31))', + + '--cl-color-warning': 'light-dark(oklch(0.646 0.222 41.116), oklch(0.75 0.183 55.934))', + '--cl-color-warning-foreground': 'oklch(0.985 0 0)', + '--cl-color-warning-faded': 'light-dark(oklch(0.9799 0.0147 70.89), oklch(0.2725 0.0547 55.7))', + '--cl-color-card': 'light-dark(oklch(1 0 0), oklch(0.205 0 0))', '--cl-color-card-foreground': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))', '--cl-color-border': 'light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%))', @@ -63,13 +76,15 @@ const spacingDefaults = { export const spacingVars = stylex.defineVars(spacingDefaults); -// The scale is `defineConsts`, not `defineVars`: each step is inlined at build -// time as `calc(var(--cl-spacing) * n)`, so it carries no custom property of its -// own. `space['2']` reads like Tailwind's `space-2` and stays a shared token -// (StyleX inlines it cross-module; a plain helper function cannot be). +// The scale is `defineVars` (like astryx's `spacingVars`): each step is a StyleX +// var whose default is `calc(var(--cl-spacing) * n)`, so overriding `--cl-spacing` +// still rescales the whole scale. `defineConsts` was tried here but emits no CSS +// across module boundaries — consumers got dangling `var(--hash)` refs. StyleX +// hashes these var names (they aren't `--cl-*`), so only `--cl-spacing` stays a +// stable, targetable custom property. `space['2']` reads like Tailwind's `space-2`. const step = (multiple: number): string => `calc(var(--cl-spacing) * ${multiple})`; -export const space = stylex.defineConsts({ +export const space = stylex.defineVars({ '0': '0px', '0.5': step(0.5), '1': step(1), diff --git a/packages/ui/tsdown.mosaic.config.mts b/packages/ui/tsdown.mosaic.config.mts index 6241344067b..3bc6a1bca22 100644 --- a/packages/ui/tsdown.mosaic.config.mts +++ b/packages/ui/tsdown.mosaic.config.mts @@ -37,7 +37,7 @@ export default defineConfig({ minify: false, // Use the standard React JSX runtime, not Emotion's — the Mosaic build must be Emotion-free. tsconfig: './tsconfig.mosaic.json', - external: ['react', 'react-dom', '@stylexjs/stylex'], + external: ['react', 'react-dom', '@stylexjs/stylex', '@clerk/headless', '@clerk/headless/utils'], plugins: [ stylexPlugin({ fileName: 'styles.css',