Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-xstyle-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 2 additions & 1 deletion .claude/skills/mosaic/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ Two things live under Mosaic, and this skill covers the how-to for both:
- **Styled components** are authored with **StyleX** — `stylex.create` declares
the styles, `themeProps` emits the part's public identity (the `.cl-<slot>`
class plus `data-<axis>` attrs), and `mergeStyleProps` fuses the two with the
consumer's `className`/`style`.
props the part was called with. A part takes `xstyle` (StyleX atoms for its
root), never `className`/`style`.
- **Flows** follow a **model → controller → view** split — _where the data comes
from_ → _what the user is doing to it_ → _what that looks like_. What crosses
each boundary is plain data: no Clerk resource reaches the controller, no
Expand Down
82 changes: 62 additions & 20 deletions .claude/skills/mosaic/references/stylex.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ conventions to follow by hand, not guarantees the toolchain makes for you.
| `utils/` | everything shared across components — styles and non-style helpers alike |
| `<comp>/<comp>.markers.stylex.ts` | `stylex.defineMarker()` results for scoped ancestor states |
| `<comp>/<comp>.tsx` | component; spreads `stylex.props(...)` via `mergeStyleProps` |
| `props.ts` | `themeProps` (`.cl-<slot>` + `data-<axis>`) + `mergeStyleProps` |
| `props.ts` | `themeProps` (`.cl-<slot>` + `data-<axis>`), `mergeStyleProps`, the `Mosaic*Props` types |
| `styles/index.ts` | isolated-build barrel; derives `*VarName` types |

The `@stylexjs` eslint rules run on `src/mosaic/**`. The `enforce-extension`
Expand Down Expand Up @@ -577,28 +577,70 @@ The element carries three things, and nothing else is a contract:
plus a kebab-cased `data-<axis>` reflection of the visual props, so consumers
target stable data-attribute selectors, not collision-prone class names.

`mergeStyleProps` fuses everything in precedence order — **theme props → StyleX atoms →
consumer `className`/`style`** — so the consumer always wins. It concatenates
className left-to-right and merges `style` with the consumer object spread last:
`mergeStyleProps` fuses its bags left to right — **theme props → StyleX atoms →
the props the part was called with** — concatenating `className`, shallow-merging
`style` with the later bag winning, and letting the later bag overwrite anything else:

```tsx
<button
{...mergeStyleProps(
themeProps('button', { intent, variant }),
stylex.props(styles.base, variants[variant], xstyle),
className,
style,
)}
/>
function Button({ intent, variant, xstyle, ...rest }: ButtonProps) {
return (
<button
{...mergeStyleProps(
themeProps('button', { intent, variant }),
stylex.props(styles.base, variants[variant], xstyle),
rest,
)}
/>
);
}
```

- **DO** put consumer `xstyle` **last** inside `stylex.props(...)` (so their atoms
win the cascade) and consumer `className`/`style` last inside `mergeStyleProps` (so
their raw CSS wins).
- **DO** put the caller's `xstyle` **last** inside `stylex.props(...)` so its atoms
win the cascade, and pass `rest` as the **last** bag to `mergeStyleProps` so the
caller's other props (`id`, handlers, `aria-*`) land as usual.
- **DON'T** forward `xstyle` down to internal slot elements — it targets the slot
the consumer named, not your private structure.
- **DON'T** call `stylex.props` twice on one element or spread `{...props}` after
the merge result — fuse everything through the one `mergeStyleProps` call.
the caller named, not your private structure.
- **DON'T** call `stylex.props` twice on one element or spread `{...rest}` after
the merge result — fuse everything through the one `mergeStyleProps` call. A
trailing `{...rest}` would clobber the part's own `className` when the part is
the target of another part's `render`.

### `xstyle`, not `className`/`style`

A Mosaic part has no `className` or `style` prop. `MosaicComponentProps` and
`MosaicElementProps` (`props.ts`) omit the pair and add `xstyle?: XStyle`, so
every part inherits the contract by typing its props off one of them. A lint
rule in the `packages/ui/mosaic` eslint block names the replacement when either
attribute shows up on a capitalized element.

- **Inside `packages/ui`** a flow author who needs to nudge a part declares the
atoms in the view's own `stylex.create` and passes them as `xstyle`:

```tsx
const styles = stylex.create({ helpText: { textAlign: 'center', marginBlockStart: space['1'] } });

<Text
size='xs'
xstyle={styles.helpText}
>
{messages.helpText}
</Text>;
```

- **Outside `packages/ui`** a theme targets the `.cl-<slot>` class, `data-<axis>`
attrs, and `--cl-*` vars in CSS. No prop is involved.
- **`render` composition still delivers the pair at runtime.** `Dialog.Title
render={<Heading />}` clones the title's merged `className`/`style` onto the
`Heading`, which is why a part passes its `rest` bag through `mergeStyleProps`:
the helper merges the incoming pair instead of letting it overwrite the part's
own class. The public prop types stay closed; only the merge is tolerant.
- `xstyle` is typed as `XStyle`: whatever `stylex.props(...)` accepts, since that
is where the part passes it on. StyleX's narrower `StyleXStyles` only admits
property names it knows and rejects real atoms (the scroll area's
`::-webkit-scrollbar` rules), so parts do not use it.
- `{...stylex.props(atoms)}` on a part is the same pair by another route and the
lint rule flags it too. Pass the atoms as `xstyle`; only native elements spread
`stylex.props`.

### Type every part with `MosaicComponentProps`

Expand Down Expand Up @@ -664,8 +706,8 @@ token colors aren't down-leveled into an invalid polyfill.
- Avoid manual `@layer` / `@property` inside `create` (StyleX owns layering;
`@property` compiles but emits invalid output).
- No need for `stylex.firstThatWorks` or `stylex.attrs` — a proven full library
ships without either; reach for conditional-value objects and `mergeStyleProps`
instead.
ships without either; reach for conditional-value objects, `xstyle`, and
`mergeStyleProps` instead.
- Dynamic functions-in-`create` are allowed but exceptional — see "Dynamic styles"
above. Default to static atoms, variant maps, and conditional-value objects; use
a dynamic function only for a continuous runtime value, and prefer writing a
Expand Down
24 changes: 24 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,30 @@ export default tseslint.config([
message:
"Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.",
},
// A Mosaic part takes `xstyle`, not `className`/`style`; its prop types already reject the pair,
// this names the replacement. Native elements (lowercase) are unaffected.
{
selector:
"JSXOpeningElement[name.type='JSXIdentifier'][name.name=/^[A-Z]/] > JSXAttribute[name.name=/^(className|style)$/]",
message: 'Mosaic parts take `xstyle` (StyleX atoms) instead of `className`/`style`.',
},
{
selector:
"JSXOpeningElement[name.type='JSXMemberExpression'] > JSXAttribute[name.name=/^(className|style)$/]",
message: 'Mosaic parts take `xstyle` (StyleX atoms) instead of `className`/`style`.',
},
// `{...stylex.props(x)}` is the same pair by another route; the types miss it because a
// spread's optional keys skip excess-property checks.
{
selector:
"JSXOpeningElement[name.type='JSXIdentifier'][name.name=/^[A-Z]/] > JSXSpreadAttribute > CallExpression[callee.object.name='stylex'][callee.property.name='props']",
message: 'Mosaic parts take the atoms as `xstyle`, not a `stylex.props(...)` spread.',
},
{
selector:
"JSXOpeningElement[name.type='JSXMemberExpression'] > JSXSpreadAttribute > CallExpression[callee.object.name='stylex'][callee.property.name='props']",
message: 'Mosaic parts take the atoms as `xstyle`, not a `stylex.props(...)` spread.',
},
],
},
},
Expand Down
2 changes: 2 additions & 0 deletions packages/swingset/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ const nextConfig = {
dev: isDev,
runtimeInjection: isDev,
unstable_moduleResolution: { type: 'commonJS', rootDir: resolve(__dirname, '../ui') },
// Stories reach `tokens.stylex.ts` through the same alias; StyleX resolves it itself.
aliases: { '@clerk/ui/mosaic/*': [resolve(__dirname, '../ui/src/mosaic/*')] },
useCSSLayers: true,
lightningcssOptions: { targets: mosaicLightningCssTargets },
}),
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/postcss.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const stylexExtraction = {
dev: isDev,
runtimeInjection: false,
unstable_moduleResolution: { type: 'commonJS', rootDir: uiRoot },
aliases: { '@clerk/ui/mosaic/*': [resolve(uiRoot, 'src/mosaic/*')] },
},
],
],
Expand Down
9 changes: 7 additions & 2 deletions packages/swingset/src/stories/combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import { Combobox } from '@clerk/ui/mosaic/components/combobox';
import { Field } from '@clerk/ui/mosaic/components/field';
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { InputGroup } from '@clerk/ui/mosaic/components/input-group';
import * as stylex from '@stylexjs/stylex';

import type { StoryMeta } from '@/lib/types';

const styles = stylex.create({
fieldWidth: { width: 320 },
});

export { default as __source } from './combobox.stories?raw';

export const meta: StoryMeta = {
Expand All @@ -22,7 +27,7 @@ export function Default() {

return (
<Combobox.Root>
<Field.Root style={{ width: 320 }}>
<Field.Root xstyle={styles.fieldWidth}>
<Field.Label>Fruit</Field.Label>
<InputGroup.Root>
<Combobox.Input placeholder='Search fruit…' />
Expand Down Expand Up @@ -67,7 +72,7 @@ export function Scrolling() {

return (
<Combobox.Root>
<Field.Root style={{ width: 320 }}>
<Field.Root xstyle={styles.fieldWidth}>
<Field.Label>Fruit</Field.Label>
<InputGroup.Root>
<Combobox.Input placeholder='Search fruit…' />
Expand Down
17 changes: 10 additions & 7 deletions packages/swingset/src/stories/field.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Field } from '@clerk/ui/mosaic/components/field';
import { Input } from '@clerk/ui/mosaic/components/input';
import * as stylex from '@stylexjs/stylex';

import type { StoryMeta } from '@/lib/types';

Expand All @@ -14,15 +15,17 @@ export const meta: StoryMeta = {
source: 'packages/ui/src/mosaic/components/field/field.tsx',
};

const stackStyles = {
display: 'grid',
gap: 8,
maxWidth: 384,
} as const;
const styles = stylex.create({
stack: {
display: 'grid',
gap: 8,
maxWidth: 384,
},
});

export function Default() {
return (
<Field.Root style={stackStyles}>
<Field.Root xstyle={styles.stack}>
<Field.Label>Email address</Field.Label>
<Input
name='email'
Expand All @@ -36,7 +39,7 @@ export function Default() {

export function VisuallyHiddenLabel() {
return (
<Field.Root style={stackStyles}>
<Field.Root xstyle={styles.stack}>
<Field.Label visuallyHidden>Search members</Field.Label>
<Input
name='search'
Expand Down
17 changes: 10 additions & 7 deletions packages/swingset/src/stories/icon-frame.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { IconFrameProps } from '@clerk/ui/mosaic/components/icon';
import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon';
import { colorVars, space } from '@clerk/ui/mosaic/styles';
import { colorVars, space } from '@clerk/ui/mosaic/tokens.stylex';
import * as stylex from '@stylexjs/stylex';

import type { StoryMeta } from '@/lib/types';

export { default as __source } from './icon-frame.stories?raw';

const styles = stylex.create({
customSurface: {
backgroundColor: colorVars['--cl-color-primary'],
color: colorVars['--cl-color-primary-foreground'],
},
});

const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`;

function ProviderLogo({ provider }: { provider: string }) {
Expand Down Expand Up @@ -111,12 +119,7 @@ export function Treatments() {

export function CustomSurface() {
return (
<IconFrame
style={{
backgroundColor: colorVars['--cl-color-primary'],
color: colorVars['--cl-color-primary-foreground'],
}}
>
<IconFrame xstyle={styles.customSurface}>
<Icon
name='check'
size='lg'
Expand Down
43 changes: 22 additions & 21 deletions packages/swingset/src/stories/input-group.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { Field } from '@clerk/ui/mosaic/components/field';
import { Icon } from '@clerk/ui/mosaic/components/icon';
import type { InputGroupRootProps } from '@clerk/ui/mosaic/components/input-group';
import { InputGroup } from '@clerk/ui/mosaic/components/input-group';
import { colorVars, space } from '@clerk/ui/mosaic/styles';
import { colorVars, space } from '@clerk/ui/mosaic/tokens.stylex';
import * as stylex from '@stylexjs/stylex';
import { useState } from 'react';

import type { StoryMeta } from '@/lib/types';
Expand All @@ -25,6 +26,20 @@ export const meta: StoryMeta = {
},
};

const styles = stylex.create({
fieldWidth: {
width: 320,
},
tintedStart: {
backgroundColor: `color-mix(in srgb, ${colorVars['--cl-color-neutral']} 8%, transparent)`,
paddingInlineEnd: space['3'],
},
tintedEnd: {
backgroundColor: `color-mix(in srgb, ${colorVars['--cl-color-neutral']} 8%, transparent)`,
paddingInlineStart: space['3'],
},
});

function knobsAsProps(props: Record<string, unknown>) {
return props as unknown as InputGroupRootProps;
}
Expand All @@ -34,7 +49,7 @@ export function Default(props: Record<string, unknown>) {
const groupProps = knobsAsProps(props);

return (
<Field.Root style={{ width: 320 }}>
<Field.Root xstyle={styles.fieldWidth}>
<Field.Label>Password</Field.Label>
<InputGroup.Root {...groupProps}>
<InputGroup.Input
Expand Down Expand Up @@ -78,26 +93,12 @@ export function Sizes(props: Record<string, unknown>) {

export function TextAddons(props: Record<string, unknown>) {
return (
<Field.Root style={{ width: 320 }}>
<Field.Root xstyle={styles.fieldWidth}>
<Field.Label>Website</Field.Label>
<InputGroup.Root {...knobsAsProps(props)}>
<InputGroup.Start
style={{
backgroundColor: `color-mix(in srgb, ${colorVars['--cl-color-neutral']} 8%, transparent)`,
paddingInlineEnd: space['3'],
}}
>
https://
</InputGroup.Start>
<InputGroup.Start xstyle={styles.tintedStart}>https://</InputGroup.Start>
<InputGroup.Input placeholder='example' />
<InputGroup.End
style={{
backgroundColor: `color-mix(in srgb, ${colorVars['--cl-color-neutral']} 8%, transparent)`,
paddingInlineStart: space['3'],
}}
>
.com
</InputGroup.End>
<InputGroup.End xstyle={styles.tintedEnd}>.com</InputGroup.End>
</InputGroup.Root>
</Field.Root>
);
Expand All @@ -107,7 +108,7 @@ export function Disabled(props: Record<string, unknown>) {
return (
<Field.Root
disabled
style={{ width: 320 }}
xstyle={styles.fieldWidth}
>
<Field.Label>Email address</Field.Label>
<InputGroup.Root {...knobsAsProps(props)}>
Expand All @@ -125,7 +126,7 @@ export function Invalid(props: Record<string, unknown>) {
return (
<Field.Root
invalid
style={{ width: 320 }}
xstyle={styles.fieldWidth}
>
<Field.Label>Password</Field.Label>
<InputGroup.Root {...groupProps}>
Expand Down
2 changes: 1 addition & 1 deletion packages/swingset/src/stories/item.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import * as stylex from '@stylexjs/stylex';
{...stylex.props(scrollAreaRoot)}
style={{ height: 260 }}
>
<Item.Group {...stylex.props(...scrollAreaViewport())}>{organizations}</Item.Group>
<Item.Group xstyle={scrollAreaViewport()}>{organizations}</Item.Group>
</div>;
```

Expand Down
Loading
Loading