Skip to content

Commit e8e9be2

Browse files
authored
Merge branch 'main' into rob/fix-js-issue-9210
2 parents fb46521 + 68ae308 commit e8e9be2

36 files changed

Lines changed: 1031 additions & 292 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

.claude/skills/mosaic/SKILL.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,16 @@ this skill is the _how-to_.
4242

4343
## Which reference to read
4444

45-
| You are… | Read |
46-
| ------------------------------------------------------------------ | ------------------------------------------------------ |
47-
| Styling a component (slot recipes, `useRecipe`, variants, slots) | `references/styling.md` |
48-
| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` |
49-
| Writing the controller (Clerk adapter, permissions, revalidate) | `references/controllers.md` |
50-
| Writing the view (rendering a snapshot, sending events) | `references/views.md` |
51-
| Testing a machine, controller, or view | `references/testing.md` |
52-
| Migrating a legacy component into Mosaic (the end-to-end workflow) | `references/migration.md` |
53-
| Running the parity audit that guards a migration | `references/parity-audit.md` |
45+
| You are… | Read |
46+
| -------------------------------------------------------------------- | ------------------------------------------------------ |
47+
| Styling a component with StyleX (tokens, `stylex.create`, CSS build) | `references/stylex.md` |
48+
| Styling a component the legacy way (slot recipes, `useRecipe`) | `references/styling.md` |
49+
| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` |
50+
| Writing the controller (Clerk adapter, permissions, revalidate) | `references/controllers.md` |
51+
| Writing the view (rendering a snapshot, sending events) | `references/views.md` |
52+
| Testing a machine, controller, or view | `references/testing.md` |
53+
| Migrating a legacy component into Mosaic (the end-to-end workflow) | `references/migration.md` |
54+
| Running the parity audit that guards a migration | `references/parity-audit.md` |
5455

5556
The migration workflow (`migration.md`) ties the flow references together: it
5657
treats the legacy component as the spec and drives you through the machine,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Styling a component with StyleX
2+
3+
Mosaic styling is migrating off the Emotion slot-recipe engine (`styling.md`)
4+
onto **StyleX** (`@stylexjs/stylex` 0.19). StyleX is compile-time atomic CSS: the
5+
style objects become hashed atom classes plus one static stylesheet, with zero
6+
runtime. This file is the authoring model for the StyleX layer; read it against
7+
the pilot, `packages/ui/src/mosaic/components/button/`.
8+
9+
The public contract is unchanged from the recipe era — consumers still target
10+
`--cl-*` vars, the `.cl-<slot>` class, and `data-<axis>` attrs, never StyleX's
11+
hashed `x…` atoms. What changes is how a component is authored internally.
12+
13+
## File layout & the `.stylex.ts` rule
14+
15+
| File | Holds |
16+
| ------------------------- | ---------------------------------------------------------- |
17+
| `tokens.stylex.ts` | `defineVars` / `defineConsts` only (the tokens) |
18+
| `<comp>/<comp>.styles.ts` | `stylex.create({...})` — the atoms |
19+
| `<comp>/<comp>.tsx` | component; spreads `stylex.props(...)` via `mergeProps` |
20+
| `props.ts` | `themeProps` (`.cl-<slot>` + `data-<axis>`) + `mergeProps` |
21+
| `styles/index.ts` | isolated-build barrel; derives `*VarName` types |
22+
23+
All 9 `@stylexjs` eslint rules run on `src/mosaic/**`. The `enforce-extension`
24+
rule reserves the `.stylex.ts` extension for token files: **a `.stylex.ts` file
25+
may export nothing but `defineVars`/`defineConsts` results.** So:
26+
27+
- `stylex.create(...)` lives in a plain `.styles.ts` file, never `.stylex.ts`.
28+
- Derived types (`type ColorVarName = keyof typeof colorVars`) live in
29+
`styles/index.ts`, not in `tokens.stylex.ts` (they'd be a disallowed export).
30+
31+
## Tokens (`tokens.stylex.ts`)
32+
33+
**`defineVars` keys that start with `--` emit verbatim** as real custom
34+
properties; other keys get hashed. That verbatim behavior is the whole point —
35+
it gives consumers stable `--cl-*` vars to override in plain CSS:
36+
37+
```ts
38+
const colorDefaults = {
39+
// one value carries light + dark; resolves against the in-scope `color-scheme`,
40+
// so dark mode lives in the token, no `@media (prefers-color-scheme)` copy.
41+
'--cl-color-primary': 'light-dark(oklch(0.205 0 0), oklch(0.922 0 0))',
42+
} as const;
43+
export const colorVars = stylex.defineVars(colorDefaults);
44+
```
45+
46+
**Spacing is one exposed var plus a `defineConsts` scale.** Only `--cl-spacing`
47+
is a custom property; every step is inlined at build as `calc(var(--cl-spacing) *
48+
n)` and carries no var of its own:
49+
50+
```ts
51+
export const spacingVars = stylex.defineVars({ '--cl-spacing': '0.25rem' });
52+
53+
const step = (multiple: number): string => `calc(var(--cl-spacing) * ${multiple})`;
54+
export const space = stylex.defineConsts({ '0': '0px', '2': step(2), '8': step(8) /**/ });
55+
// usage: gap: space['2']
56+
```
57+
58+
Why `defineConsts` and not a `space(n)` helper: StyleX evaluates
59+
`stylex.create` **statically at build time** and cannot inline a helper imported
60+
from a non-`.stylex` module (it errors "Could not resolve the path to the
61+
imported file"). `defineConsts` is StyleX's shareable inlined-value primitive, so
62+
it's the only way to get a scale that is both shared across components and free
63+
of per-step vars.
64+
65+
## Atoms (`<comp>.styles.ts`)
66+
67+
`stylex.create` values must be statically resolvable. Allowed: literals,
68+
**same-file** locals, and references to `.stylex` tokens (`colorVars[...]`,
69+
`space['2']`). Not allowed: a value from a helper imported across modules. For
70+
arithmetic, inline a `calc()` template literal with a token:
71+
`` `calc(-1 * ${space['2']})` ``.
72+
73+
**Conditions.** Use StyleX's conditional-value objects (a `default` plus
74+
pseudo / at-rule keys), and nest to guard hover so it never sticks on touch —
75+
this is the StyleX form of the old `_hover` condition:
76+
77+
```ts
78+
backgroundColor: {
79+
default: colorVars['--cl-color-primary'],
80+
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
81+
'@media (hover: hover)': {
82+
// only the top-level value needs `default`; the nested block just adds `:hover`
83+
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
84+
},
85+
},
86+
```
87+
88+
Guard `:hover` behind `@media (hover: hover)`; leave `:active`, `:focus-visible`,
89+
`:disabled` unguarded. Runtime conditions the component owns (disabled, etc.) are
90+
also reflected as `data-<axis>` attrs via `themeProps` so they stay overridable.
91+
92+
Write the guard **raw**, as above — there is no `hover()` helper to import,
93+
because StyleX can't inline a cross-module helper into `create` (the Emotion
94+
engine's `hover()`/`motionSafe()` utils don't translate). `*.styles.ts` files are
95+
therefore exempted from the repo's `no-restricted-syntax` media-query rule
96+
(`eslint.config.mjs`); the `@stylexjs/*` rules still apply.
97+
98+
## Public contract (`props.ts`)
99+
100+
The element carries three things, and nothing else is a contract:
101+
102+
1. `--cl-*` vars (from tokens), 2. `.cl-<slot>` class, 3. `data-<axis>` attrs.
103+
104+
`mergeProps` fuses them in precedence order — **theme props → StyleX atoms →
105+
consumer `className`/`style`** — so the consumer always wins:
106+
107+
```tsx
108+
<button {...mergeProps(themeProps('button', { intent, variant }), stylex.props(styles.base, ...), className, style)} />
109+
```
110+
111+
## Build & CSS delivery (two contexts, same babel)
112+
113+
- **Published** (`build:mosaic``@stylexjs/rollup-plugin`): compiles the
114+
`styles/index.ts` barrel into `dist-mosaic/styles.css`, exported as
115+
`@clerk/ui/styles.css`. Consumers choose the cascade layer at import:
116+
`@import '@clerk/ui/styles.css' layer(components)`.
117+
- **Swingset** (source-consumed): `@stylexjs/unplugin/webpack` in `next.config`
118+
transforms StyleX **JS only** (calls → static atoms; SWC/Emotion untouched);
119+
`@stylexjs/postcss-plugin` extracts the **CSS** by replacing `@stylex;` in
120+
`globals.css`. Both must share the same StyleX babel version + options so atom
121+
hashes line up.
122+
123+
Both set **`useCSSLayers: true`** (StyleX emits `@layer priorityN` for its own
124+
precedence; the consumer's `@import … layer()` picks the outer layer). Both pin
125+
**lightningcss targets** to browsers with native `light-dark()`/`oklch()` so the
126+
token colors aren't down-leveled into an invalid polyfill.
127+
128+
## CSS features we lean on / caveats
129+
130+
- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
131+
`:hover`/`:active`/`:focus-visible`/`:disabled`, `::before`/`::after`.
132+
- Prefer CSS-native solutions over JS workarounds for anything StyleX supports.
133+
- Avoid manual `@layer` / `@property` inside `create` (StyleX owns layering;
134+
`@property` compiles but emits invalid output).
135+
- We do not use functions-in-`create` (dynamic runtime values); Mosaic styling is
136+
fully static.

.claude/skills/mosaic/references/styling.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Styling a component with slot recipes
22

3+
> **Migration in progress:** Mosaic styling is moving off this Emotion slot-recipe
4+
> engine onto **StyleX** (compile-time atomic CSS). New/migrated components are
5+
> authored with StyleX — see **`references/stylex.md`**. This file documents the
6+
> recipe system that StyleX is replacing; both coexist until the migration lands.
7+
> The public `--cl-*` / `.cl-<slot>` / `data-<axis>` contract is identical across
8+
> both, so it is preserved regardless of the internal engine.
9+
310
A styled Mosaic component is authored with one **slot recipe**. The recipe owns
411
everything about how the part looks and is targeted: its slot identity
512
(`data-cl-slot`), base styles, variants, and the appearance cascade.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
# compiled output
44
dist
5+
dist-mosaic
56
tmp
67
out-tsc
78
out

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
build
1414
coverage
1515
dist
16+
dist-mosaic
1617
/packages/nextjs/examples
1718
node_modules
1819
package-lock.json

eslint.config.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import pluginJsxA11y from 'eslint-plugin-jsx-a11y';
77
import pluginPlaywright from 'eslint-plugin-playwright';
88
import pluginReact from 'eslint-plugin-react';
99
import pluginReactHooks from 'eslint-plugin-react-hooks';
10+
import pluginStylex from '@stylexjs/eslint-plugin';
1011
import pluginSimpleImportSort from 'eslint-plugin-simple-import-sort';
1112
import pluginTurbo from 'eslint-plugin-turbo';
1213
import pluginUnusedImports from 'eslint-plugin-unused-imports';
@@ -539,7 +540,19 @@ export default tseslint.config([
539540
name: 'packages/ui/mosaic',
540541
files: ['packages/ui/src/mosaic/**/*'],
541542
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
543+
plugins: {
544+
'@stylexjs': pluginStylex,
545+
},
542546
rules: {
547+
'@stylexjs/enforce-extension': 'error',
548+
'@stylexjs/no-legacy-contextual-styles': 'error',
549+
'@stylexjs/no-lookahead-selectors': 'error',
550+
'@stylexjs/no-nonstandard-styles': 'error',
551+
'@stylexjs/no-conflicting-props': 'error',
552+
'@stylexjs/no-unused': 'error',
553+
'@stylexjs/sort-keys': 'error',
554+
'@stylexjs/valid-shorthands': 'error',
555+
'@stylexjs/valid-styles': 'error',
543556
// Mosaic renders elements through `render={p => <el {...p} />}`, so children and controls sit on
544557
// the outer component. Both rules only see the empty inner element and always report.
545558
'jsx-a11y/heading-has-content': 'off',
@@ -562,6 +575,17 @@ export default tseslint.config([
562575
],
563576
},
564577
},
578+
{
579+
// StyleX `create()` files author conditions raw (`@media (hover: hover)`, `:hover`) — StyleX
580+
// is compile-time and cannot inline a `hover()`/`motionSafe()` helper imported into `create`,
581+
// so the media-query restrictions above (an Emotion-runtime convention) can't apply here. The
582+
// `@stylexjs/*` rules from the mosaic block still cover these files.
583+
name: 'packages/ui/mosaic - stylex styles',
584+
files: ['packages/ui/src/mosaic/**/*.styles.ts'],
585+
rules: {
586+
'no-restricted-syntax': 'off',
587+
},
588+
},
565589
{
566590
name: 'packages - vitest',
567591
files: ['packages/*/src/**/*.test.{ts,tsx}'],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"@faker-js/faker": "^9.9.0",
8888
"@octokit/rest": "^20.1.2",
8989
"@playwright/test": "^1.56.1",
90+
"@stylexjs/eslint-plugin": "0.19.0",
9091
"@testing-library/dom": "^10.1.0",
9192
"@testing-library/jest-dom": "^6.4.6",
9293
"@testing-library/react": "^16.0.0",

packages/swingset/next.config.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import stylexPlugin from '@stylexjs/unplugin/webpack';
12
import createMDX from '@next/mdx';
23
import remarkGfm from 'remark-gfm';
34
import rehypeRaw from 'rehype-raw';
@@ -58,6 +59,22 @@ const nextConfig = {
5859
excludeRawQuery(config.module.rules);
5960
config.module.rules.push({ resourceQuery: /raw/, type: 'asset/source' });
6061

62+
// Swingset consumes Mosaic from source, so StyleX (`defineVars`/`create`/`props`) must be
63+
// compiled here — otherwise the calls hit the runtime and throw. The unplugin transforms the
64+
// StyleX *JS only* (calls → static atom references), keeping SWC intact so `next/font` and the
65+
// Emotion transform keep working. The CSS is emitted separately by `@stylexjs/postcss-plugin`
66+
// (`@stylex` in `globals.css`), so this runs in extraction mode (no `runtimeInjection`); both
67+
// share the same StyleX babel version/options so the atom hashes match, and the plugin's dev
68+
// "no CSS asset" warning is expected and harmless. `useCSSLayers: true` matches the published
69+
// build so atoms carry StyleX's `@layer priorityN` precedence.
70+
config.plugins.push(
71+
stylexPlugin({
72+
dev: process.env.NODE_ENV !== 'production',
73+
unstable_moduleResolution: { type: 'commonJS', rootDir: resolve(__dirname, '../ui') },
74+
useCSSLayers: true,
75+
}),
76+
);
77+
6178
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
6279
// Consume @clerk/headless primitives from source (no dist build needed), mirroring Mosaic.
6380
// `/hooks` and `/utils` live outside `primitives/`, so alias them first (more specific wins).

packages/swingset/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@clerk/headless": "workspace:*",
1515
"@clerk/ui": "workspace:*",
1616
"@emotion/react": "^11.11.1",
17+
"@stylexjs/stylex": "0.19.0",
1718
"@tailwindcss/typography": "^0.5.19",
1819
"class-variance-authority": "^0.7.1",
1920
"clsx": "^2.1.1",
@@ -28,16 +29,22 @@
2829
"tw-animate-css": "^1.4.0"
2930
},
3031
"devDependencies": {
32+
"@babel/plugin-syntax-jsx": "^7.27.1",
33+
"@babel/preset-typescript": "^7.28.5",
3134
"@mdx-js/loader": "^3.1.1",
3235
"@mdx-js/react": "^3.1.0",
3336
"@next/mdx": "^15.0.0",
37+
"@stylexjs/babel-plugin": "0.19.0",
38+
"@stylexjs/postcss-plugin": "0.19.0",
39+
"@stylexjs/unplugin": "0.19.0",
3440
"@tailwindcss/postcss": "^4.0.0",
3541
"@types/react": "catalog:react",
3642
"@types/react-dom": "catalog:react",
3743
"remark-gfm": "^4.0.0",
3844
"shadcn": "^4.10.0",
3945
"shiki": "^1.0.0",
4046
"tailwindcss": "^4.0.0",
41-
"typescript": "catalog:repo"
47+
"typescript": "catalog:repo",
48+
"unplugin": "^2.3.11"
4249
}
4350
}

0 commit comments

Comments
 (0)