diff --git a/.changeset/mosaic-icon-stylex.md b/.changeset/mosaic-icon-stylex.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-icon-stylex.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/mosaic-typography-stylex.md b/.changeset/mosaic-typography-stylex.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-typography-stylex.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/clerk-monorepo/SKILL.md b/.claude/skills/clerk-monorepo/SKILL.md index 6868bb72cbf..e94e35f3480 100644 --- a/.claude/skills/clerk-monorepo/SKILL.md +++ b/.claude/skills/clerk-monorepo/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 65565c4662b..99e9b9bfbbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/headless/src/primitives/dialog/dialog.test.tsx b/packages/headless/src/primitives/dialog/dialog.test.tsx index d0c1be1124e..06011b50342 100644 --- a/packages/headless/src/primitives/dialog/dialog.test.tsx +++ b/packages/headless/src/primitives/dialog/dialog.test.tsx @@ -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

{children}

; + } + + render( + + + }>Element title + + , + ); + + const title = screen.getByRole('heading', { name: 'Element title' }); + expect(title.tagName).toBe('H3'); + expect(title).toHaveAttribute('id'); + }); }); describe('trigger state attributes', () => { diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index 47abab801d6..f2a2d4c17c4 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -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'; diff --git a/packages/headless/src/utils/use-render.test-d.ts b/packages/headless/src/utils/use-render.test-d.ts index 5da4b4a387d..91701264962 100644 --- a/packages/headless/src/utils/use-render.test-d.ts +++ b/packages/headless/src/utils/use-render.test-d.ts @@ -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', () => { type Props = ComponentProps<'button'>; - type RenderArg = NonNullable extends (props: infer P) => React.ReactElement ? P : never; + type RenderFn = Extract, (...args: never[]) => unknown>; + type RenderArg = RenderFn extends (props: infer P) => React.ReactElement ? P : never; expectTypeOf().toEqualTypeOf>(); }); + + test('render also accepts an element to clone', () => { + expectTypeOf().toExtend['render']>>(); + }); }); diff --git a/packages/headless/src/utils/use-render.tsx b/packages/headless/src/utils/use-render.tsx index 2c7ba5293c8..8a58fd224d1 100644 --- a/packages/headless/src/utils/use-render.tsx +++ b/packages/headless/src/utils/use-render.tsx @@ -10,12 +10,22 @@ import * as React from 'react'; */ export type RenderProp> = (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={}`). 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 = + | RenderProp> + | 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 = React.ComponentPropsWithRef & { - render?: RenderProp>; + render?: RenderPropOrElement; }; /** @@ -93,14 +103,6 @@ export function mergeProps(a: Record, b: Record}`). - */ -type RenderPropOrElement = - | RenderProp> - | 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. diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index b78e4b0699b..fc6aba1bfc0 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -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 ``) 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 ``) renders a single static variation with default knob values and no controls. -- **The prop table is the knob surface.** `PropTable` (MDX ``) 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 ``) 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. @@ -80,12 +80,14 @@ export const meta: StoryMeta = { title: 'Button', // drives slug + the page

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 `` 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 ``). 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 *`. @@ -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 `` re-renders `` above it and regenerates the `` snippet below it. - The story file exports a primary demo (rendered by ``) plus one named export per variation under **Examples**. Each story takes `props: Record` and casts through a local `knobsAsProps` helper — knobs are dynamically typed, the component isn't. -- Use ``'s `extra` for documenting non-variant props; `sx` is appended for you. +- Use ``'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 `` to pin static, non-knob props in the generated snippet. - `` 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. diff --git a/packages/swingset/src/components/PropTable.tsx b/packages/swingset/src/components/PropTable.tsx index 784371d665f..b7084751802 100644 --- a/packages/swingset/src/components/PropTable.tsx +++ b/packages/swingset/src/components/PropTable.tsx @@ -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 ?? {}; @@ -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 ( @@ -53,7 +55,7 @@ export function PropTable({ meta, extra = [], sx = true }: PropTableProps) { {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 ( diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 687c3aa59cf..bfa22e2ee2b 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -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'; @@ -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'; @@ -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, diff --git a/packages/swingset/src/lib/types.ts b/packages/swingset/src/lib/types.ts index 4ff6cb8a6df..de1faedc8ad 100644 --- a/packages/swingset/src/lib/types.ts +++ b/packages/swingset/src/lib/types.ts @@ -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>; _defaultVariants?: Record; diff --git a/packages/swingset/src/stories/badge.mdx b/packages/swingset/src/stories/badge.mdx index ca33dfdf342..bae65956b86 100644 --- a/packages/swingset/src/stories/badge.mdx +++ b/packages/swingset/src/stories/badge.mdx @@ -16,7 +16,6 @@ Badge labels the status or category of the thing next to it. It renders a `span` ReactNode' }]} - sx={false} /> ## Usage diff --git a/packages/swingset/src/stories/badge.stories.tsx b/packages/swingset/src/stories/badge.stories.tsx index 57f4bce4cd4..6cf390eac50 100644 --- a/packages/swingset/src/stories/badge.stories.tsx +++ b/packages/swingset/src/stories/badge.stories.tsx @@ -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: {} }, diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index f982c17cbff..8a347b791ec 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -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: {} }, diff --git a/packages/swingset/src/stories/heading.mdx b/packages/swingset/src/stories/heading.mdx index 14fa3b5944f..9872af11898 100644 --- a/packages/swingset/src/stories/heading.mdx +++ b/packages/swingset/src/stories/heading.mdx @@ -2,7 +2,7 @@ import * as HeadingStories from './heading.stories'; # Heading -Heading titles a section or page in Mosaic. It renders as an `

` 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 `

` 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 @@ -38,9 +38,9 @@ Heading titles a section or page in Mosaic. It renders as an `

` by default, storyModule={HeadingStories} /> -### Intents +### Colors diff --git a/packages/swingset/src/stories/heading.stories.tsx b/packages/swingset/src/stories/heading.stories.tsx index 16e64a381bd..ad4a9de91f3 100644 --- a/packages/swingset/src/stories/heading.stories.tsx +++ b/packages/swingset/src/stories/heading.stories.tsx @@ -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'; @@ -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 (knob values) and cast to HeadingProps. @@ -68,26 +78,38 @@ export function Sizes(props: Record) { ); } -export function Intents(props: Record) { +export function Colors(props: Record) { return (
Primary heading - Muted foreground heading + Neutral heading - Destructive heading + Warning heading + + + Negative heading + + + Positive heading
); diff --git a/packages/swingset/src/stories/icon.mdx b/packages/swingset/src/stories/icon.mdx index 6352bd262d7..96ac21d0b92 100644 --- a/packages/swingset/src/stories/icon.mdx +++ b/packages/swingset/src/stories/icon.mdx @@ -2,7 +2,9 @@ import * as IconStories from './icon.stories'; # Icon -Icon renders a named glyph from Mosaic's icon set. It sizes through the `size` variant and inherits color from `currentColor`. Any glyph can be swapped per name through `appearance.icons` on `MosaicProvider`. Mosaic's styling applies to an override just like the built-in glyph — so swapped icons stay visually consistent — and the override also receives `data-cl-slot="icon"` for targeting. +Icon renders a named glyph from Mosaic's icon set. It sizes through the `size` variant and inherits color from `currentColor`. Any glyph can be swapped per name through `appearance.icons` on `MosaicProvider`. Mosaic's styling applies to an override just like the built-in glyph — so swapped icons stay visually consistent — and the override also carries the `.cl-icon` class and `data-size` attribute for targeting. + +Set `placement` when the icon sits beside text inside a container. It adds no styling of its own; it reflects `data-icon="inline-start"` or `data-icon="inline-end"` so the container can react — a `Button`, for example, tightens its padding on the side the icon sits with `:has([data-icon='inline-end'])`. The values are the CSS logical directions, so they follow the writing mode rather than naming a physical edge. Leave it unset for a standalone icon. ## Playground @@ -15,7 +17,10 @@ Icon renders a named glyph from Mosaic's icon set. It sizes through the `size` v ## Usage @@ -46,7 +51,7 @@ Icon renders a named glyph from Mosaic's icon set. It sizes through the `size` v ### Overriding a glyph -Pass `appearance.icons` to `MosaicProvider` to replace a glyph by name. The override is a function receiving the resolved props — Mosaic's sizing/color `className` and `data-cl-slot` — which you spread onto your own element. Because Mosaic's styling applies to the override too, the replacement only supplies its `viewBox` and paths. +Pass `appearance.icons` to `MosaicProvider` to replace a glyph by name. The override is a plain element of any type, not just an `svg`; Mosaic clones it with the resolved sizing `className` and `data-size`, keeping any class the element already had. The replacement only supplies its own content. (knob values) and cast to IconProps. @@ -78,10 +86,10 @@ export function Override() { return ( ` by default, forwards a ref to the underlying element, and themes its font-size and color through the `size` and `intent` variants. Use the `render` prop when the copy belongs in a different element, such as a `` inline. +Text renders body copy in Mosaic. It renders as a `

` by default, forwards a ref to the underlying element, and themes its font-size and color through the `size` and `color` variants. Use the `render` prop when the copy belongs in a different element, such as a `` inline. ## Playground @@ -38,9 +38,9 @@ Text renders body copy in Mosaic. It renders as a `

` by default, forwards a r storyModule={TextStories} /> -### Intents +### Colors diff --git a/packages/swingset/src/stories/text.stories.tsx b/packages/swingset/src/stories/text.stories.tsx index ccff156c6aa..0b5a4860917 100644 --- a/packages/swingset/src/stories/text.stories.tsx +++ b/packages/swingset/src/stories/text.stories.tsx @@ -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'; @@ -11,8 +11,18 @@ 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': {} }, + color: { primary: {}, neutral: {}, warning: {}, negative: {}, positive: {} }, + }, + _defaultVariants: { + size: 'sm', + color: 'primary', + }, + }, }; // Story functions accept Record (knob values) and cast to TextProps. @@ -68,26 +78,38 @@ export function Sizes(props: Record) { ); } -export function Intents(props: Record) { +export function Colors(props: Record) { return (

Primary text - Muted foreground text + Neutral text - Destructive text + Warning text + + + Negative text + + + Positive text
); diff --git a/packages/ui/src/mosaic/__tests__/icon.test.tsx b/packages/ui/src/mosaic/__tests__/icon.test.tsx deleted file mode 100644 index 072158d9ab8..00000000000 --- a/packages/ui/src/mosaic/__tests__/icon.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { render } from '@testing-library/react'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -import type { MosaicAppearance } from '../appearance'; -import { Icon } from '../components/icon'; -import { MosaicProvider } from '../MosaicProvider'; - -const wrap = (ui: React.ReactElement, appearance?: MosaicAppearance) => - render({ui}); - -/** Concatenates every inserted Emotion `