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-typography-stylex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add a non-empty changeset for the Mosaic package change.

This suppresses release metadata despite changes to @clerk/ui component behavior and customization. Add the appropriate package bump and a user-facing summary; reserve empty changesets for tooling-only work. As per coding guidelines, “use pnpm changeset for package changes” and use an empty changeset only when there is no user-facing impact.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/mosaic-typography-stylex.md around lines 1 - 2, Replace the empty
changeset frontmatter with a non-empty changeset for the Mosaic package,
specifying the appropriate version bump for the affected package and adding a
concise user-facing summary of the `@clerk/ui` component behavior and
customization changes.

Source: Coding guidelines

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
6 changes: 4 additions & 2 deletions packages/swingset/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,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 @@ -79,12 +79,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 @@ -155,7 +157,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
8 changes: 6 additions & 2 deletions packages/swingset/src/components/PropTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ interface PropTableProps {
}

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 = [] }: PropTableProps) {
const playground = usePlayground();
Expand All @@ -35,7 +39,7 @@ export function PropTable({ meta, extra = [] }: PropTableProps) {
return { name, type, default: defDisplay };
}),
...extra,
SX_ROW,
...(meta.styleEngine === 'stylex' ? STYLEX_ROWS : [SX_ROW]),
];

return (
Expand All @@ -51,7 +55,7 @@ export function PropTable({ meta, extra = [] }: 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
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
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
23 changes: 20 additions & 3 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,25 @@ 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': {} },
intent: {
primary: {},
primaryForeground: {},
destructive: {},
destructiveForeground: {},
muted: {},
mutedForeground: {},
},
},
_defaultVariants: {
size: 'base',
intent: 'primary',
},
},
};

// Story functions accept Record<string,unknown> (knob values) and cast to HeadingProps.
Expand Down
23 changes: 20 additions & 3 deletions packages/swingset/src/stories/text.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @jsxImportSource @emotion/react */
import type { TextProps } from '@clerk/ui/mosaic/components/text';
import { Text, textRecipe } from '@clerk/ui/mosaic/components/text';
import { Text } from '@clerk/ui/mosaic/components/text';

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

Expand All @@ -11,8 +11,25 @@ export { default as __source } from './text.stories?raw';
export const meta: StoryMeta = {
group: 'Components',
title: 'Text',
source: 'packages/ui/src/mosaic/components/text.tsx',
styles: textRecipe,
source: 'packages/ui/src/mosaic/components/text/text.tsx',
styleEngine: 'stylex',
styles: {
_variants: {
size: { xs: {}, sm: {}, base: {}, lg: {}, xl: {}, '2xl': {} },
intent: {
primary: {},
primaryForeground: {},
destructive: {},
destructiveForeground: {},
muted: {},
mutedForeground: {},
},
},
_defaultVariants: {
size: 'sm',
intent: 'primary',
},
},
};

// Story functions accept Record<string,unknown> (knob values) and cast to TextProps.
Expand Down
12 changes: 6 additions & 6 deletions packages/ui/src/mosaic/components/button/button.styles.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';
import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
base: {
Expand All @@ -18,7 +18,7 @@ export const styles = stylex.create({
cursor: 'pointer',
display: 'inline-flex',
fontFamily: 'inherit',
fontWeight: typeScaleVars['--cl-text-label-weight'],
fontWeight: fontWeightVars['--cl-font-medium'],
justifyContent: 'center',
outlineOffset: '2px',
transitionDuration: '150ms',
Expand Down Expand Up @@ -68,14 +68,14 @@ export const styles = stylex.create({
// size — height-driven; padding sets only the inline axis
sizeSm: {
paddingInline: space['2'],
fontSize: typeScaleVars['--cl-text-label-sm-size'],
lineHeight: typeScaleVars['--cl-text-label-sm-leading'],
fontSize: typeScaleVars['--cl-text-xs-size'],
lineHeight: typeScaleVars['--cl-text-xs-leading'],
height: space['7'],
},
sizeMd: {
paddingInline: space['3'],
fontSize: typeScaleVars['--cl-text-label-size'],
lineHeight: typeScaleVars['--cl-text-label-leading'],
fontSize: typeScaleVars['--cl-text-sm-size'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
height: space['8'],
},

Expand Down
81 changes: 0 additions & 81 deletions packages/ui/src/mosaic/components/heading.tsx

This file was deleted.

7 changes: 7 additions & 0 deletions packages/ui/src/mosaic/components/heading/heading.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as stylex from '@stylexjs/stylex';

import { fontWeightVars } from '../../tokens.stylex';

export const styles = stylex.create({
base: { fontWeight: fontWeightVars['--cl-font-semibold'] },
});
71 changes: 71 additions & 0 deletions packages/ui/src/mosaic/components/heading/heading.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';

import { Heading, HeadingContext } from './heading';

describe('Mosaic Heading', () => {
it('renders an h2 with its children', () => {
render(<Heading>Title</Heading>);
expect(screen.getByRole('heading', { level: 2, name: 'Title' })).toBeInTheDocument();
});

it('applies default variants when none are passed', () => {
render(<Heading>Title</Heading>);
const heading = screen.getByRole('heading');
expect(heading).toHaveClass('cl-heading');
expect(heading).toHaveAttribute('data-size', 'base');
expect(heading).toHaveAttribute('data-intent', 'primary');
});

it('wires variant props and consumer className/style through to the element', () => {
render(
<Heading
size='2xl'
intent='destructive'
className='my-heading'
style={{ marginTop: '8px' }}
>
Title
</Heading>,
);
const heading = screen.getByRole('heading');
expect(heading).toHaveAttribute('data-size', '2xl');
expect(heading).toHaveAttribute('data-intent', 'destructive');
expect(heading).toHaveClass('cl-heading', 'my-heading');
expect(heading).toHaveStyle({ marginTop: '8px' });
});

it('renders a different element through the render prop, keeping the slot props', () => {
render(<Heading render={p => <h3 {...p} />}>Title</Heading>);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveClass('cl-heading');
expect(heading).toHaveAttribute('data-size', 'base');
});
Comment on lines +39 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover the React-element render branch.

HeadingProps accepts both a callback and a ReactElement, but this only tests the callback form. Add a <Heading render={<h3 />}>…</Heading> case that verifies children and merged slot attributes reach the cloned element. As per coding guidelines, “Unit tests are required for all new functionality.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/mosaic/components/heading/heading.test.tsx` around lines 39 -
44, Add a test alongside the existing callback case in the heading test suite
that passes a React element directly to Heading’s render prop, then verify the
cloned element receives the heading children and merged slot attributes such as
the cl-heading class and data-size value. Use the existing Heading test patterns
and keep the callback coverage unchanged.

Source: Coding guidelines


it('reads defaults from HeadingContext, with own props winning', () => {
render(
<HeadingContext.Provider value={{ size: 'xl', intent: 'mutedForeground' }}>
<Heading intent='destructive'>Title</Heading>
</HeadingContext.Provider>,
);
const heading = screen.getByRole('heading');
expect(heading).toHaveAttribute('data-size', 'xl');
expect(heading).toHaveAttribute('data-intent', 'destructive');
});

it('forwards arbitrary props and the ref', () => {
const ref = React.createRef<HTMLHeadingElement>();
render(
<Heading
ref={ref}
id='section-title'
>
Title
</Heading>,
);
const heading = screen.getByRole('heading');
expect(ref.current).toBe(heading);
expect(heading).toHaveAttribute('id', 'section-title');
});
});
Loading
Loading