From d70595fd903a4c131e5f2b55f47f421bfe143e4f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 21 Jul 2026 17:05:47 -0400 Subject: [PATCH 01/11] feat(ui): migrate Mosaic Button to StyleX --- .changeset/mosaic-stylex-migration.md | 2 + .gitignore | 1 + eslint.config.mjs | 13 ++ package.json | 1 + packages/swingset/next.config.mjs | 33 ++++ packages/swingset/package.json | 9 +- packages/swingset/postcss.config.mjs | 36 ++++ packages/swingset/src/app/globals.css | 4 + .../swingset/src/stories/button.stories.tsx | 21 ++- packages/ui/package.json | 14 ++ .../ui/src/mosaic/__tests__/props.test.ts | 74 ++++++++ .../components/__tests__/button.test.tsx | 101 ----------- packages/ui/src/mosaic/components/button.tsx | 164 ------------------ .../mosaic/components/button/button.styles.ts | 93 ++++++++++ .../mosaic/components/button/button.test.tsx | 70 ++++++++ .../src/mosaic/components/button/button.tsx | 61 +++++++ .../ui/src/mosaic/components/button/index.ts | 2 + ...ganization-profile-delete-section.view.tsx | 4 +- ...anization-profile-domains-section.view.tsx | 4 +- ...rganization-profile-leave-section.view.tsx | 4 +- ...rganization-profile-members-panel.view.tsx | 2 +- ...anization-profile-profile-section.view.tsx | 2 +- packages/ui/src/mosaic/props.ts | 102 +++++++++++ packages/ui/src/mosaic/styles/index.ts | 22 +++ packages/ui/src/mosaic/tokens.stylex.ts | 108 ++++++++++++ packages/ui/styles.css.d.ts | 3 + packages/ui/tsconfig.mosaic.json | 6 + packages/ui/tsdown.mosaic.config.mts | 48 +++++ packages/ui/vitest.config.mts | 10 +- pnpm-lock.yaml | 150 +++++++++++++++- 30 files changed, 880 insertions(+), 284 deletions(-) create mode 100644 .changeset/mosaic-stylex-migration.md create mode 100644 packages/ui/src/mosaic/__tests__/props.test.ts delete mode 100644 packages/ui/src/mosaic/components/__tests__/button.test.tsx delete mode 100644 packages/ui/src/mosaic/components/button.tsx create mode 100644 packages/ui/src/mosaic/components/button/button.styles.ts create mode 100644 packages/ui/src/mosaic/components/button/button.test.tsx create mode 100644 packages/ui/src/mosaic/components/button/button.tsx create mode 100644 packages/ui/src/mosaic/components/button/index.ts create mode 100644 packages/ui/src/mosaic/props.ts create mode 100644 packages/ui/src/mosaic/styles/index.ts create mode 100644 packages/ui/src/mosaic/tokens.stylex.ts create mode 100644 packages/ui/styles.css.d.ts create mode 100644 packages/ui/tsconfig.mosaic.json create mode 100644 packages/ui/tsdown.mosaic.config.mts diff --git a/.changeset/mosaic-stylex-migration.md b/.changeset/mosaic-stylex-migration.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-stylex-migration.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.gitignore b/.gitignore index 6fef03ab1a8..e78ca9edf28 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # compiled output dist +dist-mosaic tmp out-tsc out diff --git a/eslint.config.mjs b/eslint.config.mjs index 5598e5426cf..6f4b8a3c6f0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,7 @@ import pluginJsxA11y from 'eslint-plugin-jsx-a11y'; import pluginPlaywright from 'eslint-plugin-playwright'; import pluginReact from 'eslint-plugin-react'; import pluginReactHooks from 'eslint-plugin-react-hooks'; +import pluginStylex from '@stylexjs/eslint-plugin'; import pluginSimpleImportSort from 'eslint-plugin-simple-import-sort'; import pluginTurbo from 'eslint-plugin-turbo'; import pluginUnusedImports from 'eslint-plugin-unused-imports'; @@ -542,7 +543,19 @@ export default tseslint.config([ name: 'packages/ui/mosaic', files: ['packages/ui/src/mosaic/**/*'], ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'], + plugins: { + '@stylexjs': pluginStylex, + }, rules: { + '@stylexjs/enforce-extension': 'error', + '@stylexjs/no-legacy-contextual-styles': 'error', + '@stylexjs/no-lookahead-selectors': 'error', + '@stylexjs/no-nonstandard-styles': 'error', + '@stylexjs/no-conflicting-props': 'error', + '@stylexjs/no-unused': 'error', + '@stylexjs/sort-keys': 'error', + '@stylexjs/valid-shorthands': 'error', + '@stylexjs/valid-styles': 'error', 'no-restricted-syntax': [ 'error', { diff --git a/package.json b/package.json index 6e85e476fe3..ae0ce1cb7e5 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@faker-js/faker": "^9.9.0", "@octokit/rest": "^20.1.2", "@playwright/test": "^1.56.1", + "@stylexjs/eslint-plugin": "0.19.0", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", diff --git a/packages/swingset/next.config.mjs b/packages/swingset/next.config.mjs index b928fd48cf4..35f68dfde8d 100644 --- a/packages/swingset/next.config.mjs +++ b/packages/swingset/next.config.mjs @@ -1,3 +1,4 @@ +import stylexPlugin from '@stylexjs/unplugin/webpack'; import createMDX from '@next/mdx'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -6,6 +7,18 @@ import { fileURLToPath } from 'url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); +// Match the isolated `@clerk/ui` mosaic build: pin browsers that natively support +// `light-dark()`/`oklch()` so lightningcss keeps the token colors verbatim instead of +// down-leveling them into an invalid polyfill. Encoding is (major << 16) | (minor << 8). +const version = (major, minor = 0) => (major << 16) | (minor << 8); +const stylexTargets = { + chrome: version(123), + edge: version(123), + firefox: version(120), + safari: version(17, 5), + ios_saf: version(17, 5), +}; + const withMDX = createMDX({ options: { remarkPlugins: [remarkGfm], @@ -58,6 +71,26 @@ const nextConfig = { excludeRawQuery(config.module.rules); config.module.rules.push({ resourceQuery: /raw/, type: 'asset/source' }); + // Swingset consumes Mosaic from source, so StyleX (`defineVars`/`create`/`props`) must be + // compiled here — otherwise the calls hit the runtime and throw. The plugin transforms the + // aliased `../ui/src/mosaic` files and appends the collected CSS into `globals.css`. Plain + // rules (`useCSSLayers: false`) so they coexist with swingset's own unlayered Tailwind CSS. + // The unplugin only compiles the StyleX *JS* here (turns `defineVars`/`create`/`props` + // into static atom references so nothing hits the runtime). It keeps SWC intact, so + // `next/font` and the Emotion transform keep working. The actual CSS is emitted by the + // `@stylexjs/postcss-plugin` into `globals.css` (`@stylex`), which is why this runs in + // extraction mode (no `runtimeInjection`) — the two share the same StyleX babel version + // and options, so the atom hashes match. The plugin's dev "no CSS asset" warning is + // expected and harmless: CSS delivery is postcss's job, not the bundler's. + config.plugins.push( + stylexPlugin({ + dev: process.env.NODE_ENV !== 'production', + unstable_moduleResolution: { type: 'commonJS', rootDir: resolve(__dirname, '../ui') }, + useCSSLayers: false, + lightningcssOptions: { targets: stylexTargets }, + }), + ); + config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic'); // Consume @clerk/headless primitives from source (no dist build needed), mirroring Mosaic. // `/hooks` and `/utils` live outside `primitives/`, so alias them first (more specific wins). diff --git a/packages/swingset/package.json b/packages/swingset/package.json index da93e068b9e..3e005fd2bc1 100644 --- a/packages/swingset/package.json +++ b/packages/swingset/package.json @@ -14,6 +14,7 @@ "@clerk/headless": "workspace:*", "@clerk/ui": "workspace:*", "@emotion/react": "^11.11.1", + "@stylexjs/stylex": "0.19.0", "@tailwindcss/typography": "^0.5.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -28,9 +29,14 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/preset-typescript": "^7.28.5", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.0", "@next/mdx": "^15.0.0", + "@stylexjs/babel-plugin": "0.19.0", + "@stylexjs/postcss-plugin": "0.19.0", + "@stylexjs/unplugin": "0.19.0", "@tailwindcss/postcss": "^4.0.0", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", @@ -38,6 +44,7 @@ "shadcn": "^4.10.0", "shiki": "^1.0.0", "tailwindcss": "^4.0.0", - "typescript": "catalog:repo" + "typescript": "catalog:repo", + "unplugin": "^2.3.11" } } diff --git a/packages/swingset/postcss.config.mjs b/packages/swingset/postcss.config.mjs index a34a3d560dc..536b75b9825 100644 --- a/packages/swingset/postcss.config.mjs +++ b/packages/swingset/postcss.config.mjs @@ -1,5 +1,41 @@ +import { createRequire } from 'module'; +import { dirname, resolve } from 'path'; +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'); + export default { plugins: { + '@stylexjs/postcss-plugin': { + // `../ui/src/mosaic` is consumed from source (aliased), so point the scanner at it + // explicitly — auto-discovery only walks the package's own tree and direct deps. + include: [resolve(uiRoot, 'src/mosaic/**/*.{ts,tsx}')], + useCSSLayers: false, + 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 }, + }, + ], + ], + }, + }, '@tailwindcss/postcss': {}, }, }; diff --git a/packages/swingset/src/app/globals.css b/packages/swingset/src/app/globals.css index b9419f556fd..46f118a65a2 100644 --- a/packages/swingset/src/app/globals.css +++ b/packages/swingset/src/app/globals.css @@ -5,6 +5,10 @@ @custom-variant dark (&:is(.dark *)); +/* Replaced at build time by @stylexjs/postcss-plugin with the compiled Mosaic CSS + (token :root defaults + atoms). See postcss.config.mjs. */ +@stylex; + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index 120634993b9..cf68547f05c 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @emotion/react */ import type { ButtonProps } from '@clerk/ui/mosaic/components/button'; -import { Button, buttonRecipe } from '@clerk/ui/mosaic/components/button'; +import { Button } from '@clerk/ui/mosaic/components/button'; import type { StoryMeta } from '@/lib/types'; @@ -8,11 +8,28 @@ import type { StoryMeta } from '@/lib/types'; // renders a code footer with its function's source. See `StoryModule.__source`. export { default as __source } from './button.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 `ButtonProps`. export const meta: StoryMeta = { group: 'Components', title: 'Button', source: 'packages/ui/src/mosaic/components/button.tsx', - styles: buttonRecipe, + styles: { + _variants: { + intent: { primary: {}, destructive: {} }, + variant: { filled: {}, outline: {}, ghost: {} }, + size: { sm: {}, md: {} }, + shape: { default: {}, square: {}, circle: {} }, + fullWidth: { true: {}, false: {} }, + }, + _defaultVariants: { + intent: 'primary', + variant: 'filled', + size: 'md', + shape: 'default', + fullWidth: false, + }, + }, }; // Story functions accept Record (knob values) and cast to ButtonProps. diff --git a/packages/ui/package.json b/packages/ui/package.json index ebbc7f6e00d..477ac33865d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,8 @@ "author": "Clerk", "sideEffects": [ "./src/utils/setWebpackChunkPublicPath.ts", + "./src/mosaic/**/*.stylex.ts", + "./dist-mosaic/styles.css", "./register/*" ], "type": "module", @@ -51,6 +53,10 @@ "default": "./dist/themes/experimental.js" }, "./themes/shadcn.css": "./dist/themes/shadcn.css", + "./styles.css": { + "types": "./styles.css.d.ts", + "default": "./dist-mosaic/styles.css" + }, "./register": { "import": { "types": "./register/index.d.ts", @@ -66,12 +72,15 @@ "types": "dist/index.d.ts", "files": [ "dist", + "dist-mosaic", + "styles.css.d.ts", "register" ], "scripts": { "build": "pnpm build:umd && pnpm build:esm && pnpm check:no-rhc && pnpm type-check", "build:analyze": "rspack build --config rspack.config.js --env production --env analyze", "build:esm": "tsdown", + "build:mosaic": "tsdown --config tsdown.mosaic.config.mts", "build:rsdoctor": "RSDOCTOR=true rspack build --config rspack.config.js --env production", "build:umd": "rspack build --config rspack.config.js --env production", "bundlewatch": "FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json", @@ -103,6 +112,7 @@ "@solana/wallet-adapter-base": "catalog:module-manager", "@solana/wallet-adapter-react": "catalog:module-manager", "@solana/wallet-standard": "catalog:module-manager", + "@stylexjs/stylex": "0.19.0", "@swc/helpers": "catalog:repo", "copy-to-clipboard": "3.3.3", "core-js": "catalog:repo", @@ -119,6 +129,9 @@ "@rspack/core": "catalog:rspack", "@rspack/dev-server": "catalog:rspack", "@rspack/plugin-react-refresh": "catalog:rspack", + "@stylexjs/eslint-plugin": "0.19.0", + "@stylexjs/rollup-plugin": "0.19.0", + "@stylexjs/unplugin": "0.19.0", "@svgr/rollup": "^8.1.0", "@svgr/webpack": "^6.5.1", "@types/webpack-env": "^1.18.8", @@ -126,6 +139,7 @@ "cross-fetch": "^4.1.0", "minimatch": "^10.2.5", "tsdown": "catalog:repo", + "unplugin": "^2.3.11", "webpack-merge": "^5.10.0" }, "peerDependencies": { diff --git a/packages/ui/src/mosaic/__tests__/props.test.ts b/packages/ui/src/mosaic/__tests__/props.test.ts new file mode 100644 index 00000000000..54761210c46 --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/props.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeProps, themeProps } from '../props'; + +// The public styling contract lives here, not in any one component: the stable +// `.cl-` class, `data-` variant reflection, and the class/style merge +// order that lets a consumer's `className`/`style` win. Components just wire their +// props into these helpers, so this is where the contract is exhaustively pinned. + +describe('themeProps', () => { + it('returns the stable slot class', () => { + expect(themeProps('button')).toEqual({ className: 'cl-button' }); + }); + + it('reflects each variant axis as a data- attribute', () => { + expect(themeProps('button', { intent: 'destructive', variant: 'outline' })).toEqual({ + className: 'cl-button', + 'data-intent': 'destructive', + 'data-variant': 'outline', + }); + }); + + it('kebab-cases camelCase axis names', () => { + expect(themeProps('button', { fullWidth: 'yes' })).toHaveProperty('data-full-width', 'yes'); + }); + + it('reflects a true boolean as an empty-string presence attribute', () => { + expect(themeProps('button', { fullWidth: true })).toHaveProperty('data-full-width', ''); + }); + + it('skips false, null, and undefined axes', () => { + expect(themeProps('button', { a: false, b: null, c: undefined })).toEqual({ className: 'cl-button' }); + }); + + it('stringifies numeric axis values', () => { + expect(themeProps('button', { size: 0 })).toHaveProperty('data-size', '0'); + }); +}); + +describe('mergeProps', () => { + it('concatenates className across bags, base first', () => { + const merged = mergeProps({ className: 'cl-button' }, { className: 'x1 x2' }); + expect(merged.className).toBe('cl-button x1 x2'); + }); + + it('accepts a trailing string as an appended className', () => { + const merged = mergeProps({ className: 'cl-button' }, { className: 'x1' }, 'consumer'); + expect(merged.className).toBe('cl-button x1 consumer'); + }); + + it('accepts a trailing object as a style and lets it win', () => { + const merged = mergeProps({ style: { marginTop: '2px', color: 'red' } }, undefined, { marginTop: '8px' }); + expect(merged.style).toEqual({ marginTop: '8px', color: 'red' }); + }); + + it('disambiguates the trailing className and style pair by position', () => { + const merged = mergeProps({ className: 'cl-button' }, undefined, 'consumer', { marginTop: '8px' }); + expect(merged.className).toBe('cl-button consumer'); + expect(merged.style).toEqual({ marginTop: '8px' }); + }); + + it('treats a string second argument as a className', () => { + expect(mergeProps({ className: 'cl-button' }, 'consumer').className).toBe('cl-button consumer'); + }); + + it('drops className entirely when nothing contributes one', () => { + expect(mergeProps({ 'data-intent': 'primary' }, {})).not.toHaveProperty('className'); + }); + + it('preserves non-class/style props from both bags', () => { + const merged = mergeProps({ 'data-intent': 'primary' }, { role: 'button' }); + expect(merged).toMatchObject({ 'data-intent': 'primary', role: 'button' }); + }); +}); diff --git a/packages/ui/src/mosaic/components/__tests__/button.test.tsx b/packages/ui/src/mosaic/components/__tests__/button.test.tsx deleted file mode 100644 index beada857bc8..00000000000 --- a/packages/ui/src/mosaic/components/__tests__/button.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -import type { MosaicAppearance } from '../../appearance'; -import { MosaicProvider } from '../../MosaicProvider'; -import { Button } from '../button'; - -/** Concatenates every inserted Emotion `