Skip to content
Merged
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-icon-stylex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
2 changes: 2 additions & 0 deletions .changeset/mosaic-typography-stylex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
4 changes: 3 additions & 1 deletion .claude/skills/clerk-monorepo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ Each rule below restates `AGENTS.md`; the parenthetical is how it is enforced.
4. Verify locally: `pnpm build`, `pnpm test` (or the filtered forms above), `pnpm lint`,
`pnpm format:check`.
5. Open the PR; the title must be a valid conventional commit (it becomes the squash commit). Fill in
the PR template.
the PR template and add nothing beyond its sections — in particular, never write a "Testing" /
"Test plan" / "How to test" section listing the tests added or the checks run. The Checklist
covers that and reviewers read the diff. Describe the change, not the work done on it.

Release _policy_ (when/how things ship, canary, snapshot, backports) is in `docs/PUBLISH.md`. This
skill stops at opening the PR.
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Clerk's JavaScript SDK and library monorepo.
- Use `pnpm` only. `npm` and `yarn` are blocked by `preinstall`. Node `>=24.15`, pnpm `>=10.33`.
- Every PR needs a changeset. `pnpm changeset` for package changes, `pnpm changeset:empty` for tooling/repo-only. Empty changesets are two `---` delimiters with no body. A changeset is a changelog entry for users upgrading the package, not a summary of the work done in the PR. Describe the user-facing change (what changed for someone consuming the library and how it affects them) rather than the implementation details of the diff. If a change has no user-facing impact, use an empty changeset.
- Commits must be conventional: `type(scope):` (commitlint enforces, on the PR title). `scope` is the package name without `@clerk/`, or `repo` / `release` / `e2e` / `ci` / `*`. `clerk-js` uses scope `js` (`clerk-js` is also accepted). Scope is mandatory; `docs` is a type, not a scope.
- PR descriptions follow `.github/PULL_REQUEST_TEMPLATE.md` and add no sections of their own. Never add a "Testing" (or "Test plan" / "How to test") section summarizing the tests written or the checks run; the Checklist covers that and reviewers read the diff. Describe the change, not the work done on it.
- Keep code comments minimal. Do not add a comment unless it is critical to explain WHY a non-obvious change was made; never restate what the code does. When one is warranted, keep it to a single terse line, not a verbose multi-line block.

## References
Expand Down
18 changes: 18 additions & 0 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,24 @@ describe('Dialog', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Popup content')).toBeInTheDocument();
});

it('renders a part into an element passed to `render`', () => {
function Title({ children, ...props }: { children?: React.ReactNode }) {
return <h3 {...props}>{children}</h3>;
}

render(
<Dialog.Root defaultOpen>
<Dialog.Popup>
<Dialog.Title render={<Title />}>Element title</Dialog.Title>
</Dialog.Popup>
</Dialog.Root>,
);

const title = screen.getByRole('heading', { name: 'Element title' });
expect(title.tagName).toBe('H3');
expect(title).toHaveAttribute('id');
});
});

describe('trigger state attributes', () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/headless/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
export { cssVars } from './css-vars';
export { resetLayoutStyles } from './reset-layout-styles';
export { type ComponentProps, type DefaultProps, mergeProps, type RenderProp, useRender } from './use-render';
export {
type ComponentProps,
type DefaultProps,
mergeProps,
type RenderProp,
type RenderPropOrElement,
useRender,
} from './use-render';
7 changes: 6 additions & 1 deletion packages/headless/src/utils/use-render.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import type { ComponentProps } from './use-render';
describe('use-render', () => {
test('render prop arg is narrowed to the element tag props, not the generic HTMLAttributes<HTMLElement>', () => {
type Props = ComponentProps<'button'>;
type RenderArg = NonNullable<Props['render']> extends (props: infer P) => React.ReactElement ? P : never;
type RenderFn = Extract<NonNullable<Props['render']>, (...args: never[]) => unknown>;
type RenderArg = RenderFn extends (props: infer P) => React.ReactElement ? P : never;
expectTypeOf<RenderArg>().toEqualTypeOf<React.ComponentPropsWithRef<'button'>>();
});

test('render also accepts an element to clone', () => {
expectTypeOf<React.ReactElement>().toExtend<NonNullable<ComponentProps<'button'>['render']>>();
});
});
20 changes: 11 additions & 9 deletions packages/headless/src/utils/use-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,22 @@ import * as React from 'react';
*/
export type RenderProp<Props = React.HTMLAttributes<HTMLElement>> = (props: Props) => React.ReactElement;

/**
* A `render` prop: a render function receiving the part's computed props, or a
* React element to clone with them (`render={<Link/>}`). The element form lets a
* part render a component whose own props diverge from the tag's, which a render
* function cannot express — it is typed to receive the tag's props verbatim.
*/
export type RenderPropOrElement<Tag extends keyof React.JSX.IntrinsicElements> =
| RenderProp<React.ComponentPropsWithRef<Tag>>
| React.ReactElement;

/**
* Props accepted by any primitive part. Extends the native props for `Tag`
* and adds the optional `render` escape hatch, narrowed to that tag's props.
*/
export type ComponentProps<Tag extends keyof React.JSX.IntrinsicElements> = React.ComponentPropsWithRef<Tag> & {
render?: RenderProp<React.ComponentPropsWithRef<Tag>>;
render?: RenderPropOrElement<Tag>;
};

/**
Expand Down Expand Up @@ -93,14 +103,6 @@ export function mergeProps(a: Record<string, unknown>, b: Record<string, unknown
// useRender
// ---------------------------------------------------------------------------

/**
* A `render` prop: a render function, or a React element to clone with the
* part's computed props (`render={<Link/>}`).
*/
type RenderPropOrElement<Tag extends keyof React.JSX.IntrinsicElements> =
| RenderProp<React.ComponentPropsWithRef<Tag>>
| React.ReactElement;

/**
* Reads the ref off a React element passed to `render`. React 19 exposes it on
* `props.ref`; React <=18 keeps it on the element itself.
Expand Down
6 changes: 4 additions & 2 deletions packages/swingset/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ These require reading several files together; the `README.md` covers the step-by

- **Every story renders inside `MosaicProvider`.** `StoryPreview` (the MDX `<Preview>`) renders a named story with the playground's knob values as props, applies the variable overrides, and exposes a Reset button plus a collapsible `VariablesPanel` attached to the preview. `StoryEmbed` (the MDX `<Story>`) renders a single static variation with default knob values and no controls.

- **The prop table is the knob surface.** `PropTable` (MDX `<PropTable>`) derives rows from `meta.styles._variants`/`_defaultVariants` (always appends `sx`); each variant row renders a `KnobControl` in its **Value** column, seeded with the prop's default and bound to the playground context. Non-variant rows (`sx`, `extra`) stay static.
- **The prop table is the knob surface.** `PropTable` (MDX `<PropTable>`) derives rows from `meta.styles._variants`/`_defaultVariants`, then appends the escape-hatch rows for the component's styling engine (`meta.styleEngine`): Emotion components get `sx`, StyleX components get `className` + `style`. Each variant row renders a `KnobControl` in its **Value** column, seeded with the prop's default and bound to the playground context. The engine rows and `extra` stay static.

- **Variables live in the preview.** The `VariablesPanel` is a collapsible attached to `StoryPreview` (toggled from the preview's header), bound to the shared playground context so editing a Mosaic token override immediately re-themes the story rendered above it.

Expand Down Expand Up @@ -80,12 +80,14 @@ export const meta: StoryMeta = {
title: 'Button', // drives slug + the page <h1>
label: 'Delete Org', // optional friendlier sidebar text
source: 'packages/ui/src/mosaic/components/button.tsx', // repo-root path → "View source"
styleEngine: 'stylex', // set on migrated components; defaults to 'emotion'
styles: buttonRecipe, // CVA recipe — archetype A · simple only
};
```

- `title` is the component's export name; it produces the slug and is what readers match against code. Set `label` only when the sidebar should read differently (the slug and page heading still come from `title`).
- `source` is always a path **relative to the monorepo root**, pointing at the file that exports the documented component. Always set it — it powers the "View source" link.
- `styleEngine` names the styling engine behind the component. It only affects which escape-hatch row the `<PropTable>` appends (`sx` vs `className` + `style`), so it matters for archetype A. Set `'stylex'` on migrated components; leave it off for Emotion ones.
- `styles` is the component's CVA recipe/style object and is **required for archetype A's simple (knob-driven) form** (it generates the knobs and the `<PropTable>`). Omit it for compound A components, and for B and C.

Story files that render styled Mosaic components must start with the Emotion pragma `/** @jsxImportSource @emotion/react */`. Headless-primitive demos render raw and don't need it. Always import the component and its recipe explicitly — never `import *`.
Expand Down Expand Up @@ -156,7 +158,7 @@ import * as ButtonStories from './button.stories';

- **Playground / Props / Usage are mandatory and always in this order.** The three share one playground state: editing a row in `<PropTable>` re-renders `<Preview>` above it and regenerates the `<Usage>` snippet below it.
- The story file exports a primary demo (rendered by `<Preview>`) plus one named export per variation under **Examples**. Each story takes `props: Record<string, unknown>` and casts through a local `knobsAsProps` helper — knobs are dynamically typed, the component isn't.
- Use `<PropTable>`'s `extra` for documenting non-variant props; `sx` is appended for you.
- Use `<PropTable>`'s `extra` for documenting non-variant props; the styling escape hatch is appended for you (`sx`, or `className` + `style` when `meta.styleEngine` is `'stylex'`).
- Use `<Usage props={{…}}>` to pin static, non-knob props in the generated snippet.
- `<PropTable>` renders `Prop | Type | Default | Value`: the **Default** column is filled automatically from the recipe's `_defaultVariants`, and the **Value** column is the live knob seeded with that default. No manual default annotation is needed; see _Document the default value_ under Archetype B.

Expand Down
12 changes: 7 additions & 5 deletions packages/swingset/src/components/PropTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ 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' };
const STYLEX_ROWS: ExtraProp[] = [
{ name: 'className', type: 'string' },
{ name: 'style', type: 'CSSProperties' },
];

export function PropTable({ meta, extra = [], sx = true }: PropTableProps) {
export function PropTable({ meta, extra = [] }: PropTableProps) {
const playground = usePlayground();
const variants = meta.styles?._variants ?? {};
const defaults = meta.styles?._defaultVariants ?? {};
Expand All @@ -37,7 +39,7 @@ export function PropTable({ meta, extra = [], sx = true }: PropTableProps) {
return { name, type, default: defDisplay };
}),
...extra,
...(sx ? [SX_ROW] : []),
...(meta.styleEngine === 'stylex' ? STYLEX_ROWS : [SX_ROW]),
];

return (
Expand All @@ -53,7 +55,7 @@ export function PropTable({ meta, extra = [], sx = true }: PropTableProps) {
<tbody>
{rows.map(row => {
// The default is a static cell; the Value column is the live control. Variant
// props get a knob there; non-variant rows (sx, extra) have no control.
// props get a knob there; non-variant rows (the engine rows, extra) have no control.
const knob = playground?.knobs[row.name];
return (
<tr key={row.name}>
Expand Down
8 changes: 4 additions & 4 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import { meta as dialogMeta } from '../stories/dialog.stories';
import { meta as drawerMeta } from '../stories/drawer.stories';
import { meta as fileUploadMeta } from '../stories/file-upload.stories';
import {
Colors as HeadingColors,
Default as HeadingDefault,
Intents as HeadingIntents,
meta as headingMeta,
Sizes as HeadingSizes,
} from '../stories/heading.stories';
Expand Down Expand Up @@ -85,8 +85,8 @@ import { meta as selectMeta } from '../stories/select.stories';
import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories';
import { meta as tabsMeta } from '../stories/tabs.stories';
import {
Colors as TextColors,
Default as TextDefault,
Intents as TextIntents,
meta as textMeta,
Sizes as TextSizes,
} from '../stories/text.stories';
Expand Down Expand Up @@ -153,12 +153,12 @@ const headingModule: StoryModule = {
meta: headingMeta,
Default: HeadingDefault,
Sizes: HeadingSizes,
Intents: HeadingIntents,
Colors: HeadingColors,
};

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Intents: TextIntents };
const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };

const iconModule: StoryModule = {
meta: iconMeta,
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export interface StoryMeta {
* source" link to the file on GitHub. See `lib/source.ts`.
*/
source?: string;
/**
* Which styling engine the documented component is built on. Drives the prop rows that
* are engine-specific: Emotion components take `sx`, StyleX components take `className`
* and `style`. Defaults to `'emotion'` — set `'stylex'` once a component is migrated.
*/
styleEngine?: 'emotion' | 'stylex';
styles?: {
_variants: Record<string, Record<string, unknown>>;
_defaultVariants?: Record<string, unknown>;
Expand Down
1 change: 0 additions & 1 deletion packages/swingset/src/stories/badge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ Badge labels the status or category of the thing next to it. It renders a `span`
<PropTable
meta={BadgeStories.meta}
extra={[{ name: 'render', type: '(props) => ReactNode' }]}
sx={false}
/>

## Usage
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/src/stories/badge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const meta: StoryMeta = {
group: 'Components',
title: 'Badge',
source: 'packages/ui/src/mosaic/components/badge/badge.tsx',
styleEngine: 'stylex',
styles: {
_variants: {
color: { primary: {}, neutral: {}, warning: {}, negative: {}, positive: {} },
Expand Down
3 changes: 1 addition & 2 deletions packages/swingset/src/stories/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ 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/button.tsx',
styleEngine: 'stylex',
styles: {
_variants: {
intent: { primary: {}, destructive: {} },
Expand Down
6 changes: 3 additions & 3 deletions packages/swingset/src/stories/heading.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as HeadingStories from './heading.stories';

# Heading

Heading titles a section or page in Mosaic. It renders as an `<h2>` by default, forwards a ref to the underlying element, and themes its font-size and color through the `size` and `intent` variants. Reach for the `render` prop when you need a different heading level for the document outline.
Heading titles a section or page in Mosaic. It renders as an `<h2>` by default, forwards a ref to the underlying element, and themes its font-size and color through the `size` and `color` variants. Reach for the `render` prop when you need a different heading level for the document outline.

## Playground

Expand Down Expand Up @@ -38,9 +38,9 @@ Heading titles a section or page in Mosaic. It renders as an `<h2>` by default,
storyModule={HeadingStories}
/>

### Intents
### Colors

<Story
name='Intents'
name='Colors'
storyModule={HeadingStories}
/>
40 changes: 31 additions & 9 deletions packages/swingset/src/stories/heading.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @jsxImportSource @emotion/react */
import type { HeadingProps } from '@clerk/ui/mosaic/components/heading';
import { Heading, headingRecipe } from '@clerk/ui/mosaic/components/heading';
import { Heading } from '@clerk/ui/mosaic/components/heading';

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

Expand All @@ -11,8 +11,18 @@ export { default as __source } from './heading.stories?raw';
export const meta: StoryMeta = {
group: 'Components',
title: 'Heading',
source: 'packages/ui/src/mosaic/components/heading.tsx',
styles: headingRecipe,
source: 'packages/ui/src/mosaic/components/heading/heading.tsx',
styleEngine: 'stylex',
styles: {
_variants: {
size: { xs: {}, sm: {}, base: {}, lg: {}, xl: {}, '2xl': {} },
color: { primary: {}, neutral: {}, warning: {}, negative: {}, positive: {} },
},
_defaultVariants: {
size: 'base',
color: 'primary',
},
},
};

// Story functions accept Record<string,unknown> (knob values) and cast to HeadingProps.
Expand Down Expand Up @@ -68,26 +78,38 @@ export function Sizes(props: Record<string, unknown>) {
);
}

export function Intents(props: Record<string, unknown>) {
export function Colors(props: Record<string, unknown>) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Heading
{...knobsAsProps(props)}
intent='primary'
color='primary'
>
Primary heading
</Heading>
<Heading
{...knobsAsProps(props)}
intent='mutedForeground'
color='neutral'
>
Muted foreground heading
Neutral heading
</Heading>
<Heading
{...knobsAsProps(props)}
intent='destructive'
color='warning'
>
Destructive heading
Warning heading
</Heading>
<Heading
{...knobsAsProps(props)}
color='negative'
>
Negative heading
</Heading>
<Heading
{...knobsAsProps(props)}
color='positive'
>
Positive heading
</Heading>
</div>
);
Expand Down
Loading
Loading