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-badge.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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This introduces a public Badge component and props API, but the empty changeset produces no package bump or changelog entry. Add an appropriate minor @clerk/ui changeset.

Based on learnings, empty changesets are acceptable only for documentation-only or non-published work.

🤖 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-badge.md around lines 1 - 2, Replace the empty frontmatter
in mosaic-badge.md with a changeset entry for `@clerk/ui` using a minor release
level, preserving the required changeset format so the new public Badge
component and props API generate a package bump and changelog entry.

Sources: Coding guidelines, Learnings

1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
destructive: dynamic(() => import('../stories/destructive.mdx')),
},
components: {
badge: dynamic(() => import('../stories/badge.mdx')),
button: dynamic(() => import('../stories/button.mdx')),
card: dynamic(() => import('../stories/card.component.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
Expand Down
14 changes: 14 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// Import stories explicitly to control order and avoid type casting through unknown.
import { meta as accordionMeta } from '../stories/accordion.stories';
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
import {
Intents as BadgeIntents,
meta as badgeMeta,
Primary as BadgePrimary,
WithIcon as BadgeWithIcon,
} from '../stories/badge.stories';
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
import {
Centered as CardCentered,
Expand Down Expand Up @@ -115,6 +121,13 @@ const organizationProfileMembersPanelModule: StoryModule = {

const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered };

const badgeModule: StoryModule = {
meta: badgeMeta,
Primary: BadgePrimary,
Intents: BadgeIntents,
WithIcon: BadgeWithIcon,
};

const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };

const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid };
Expand Down Expand Up @@ -171,6 +184,7 @@ export const registry: StoryModule[] = [
// Blocks
destructiveModule,
// Components
badgeModule,
buttonModule,
cardComponentModule,
inputModule,
Expand Down
43 changes: 43 additions & 0 deletions packages/swingset/src/stories/badge.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as BadgeStories from './badge.stories';

# Badge

Badge labels the status or category of the thing next to it. It is presentational only — it renders a `span`, has no interactive behavior, and its `intent` carries meaning through color, so keep the label itself self-describing for anyone who can't see it.

## Playground

<Preview
name='Primary'
storyModule={BadgeStories}
/>

## Props

<PropTable meta={BadgeStories.meta} />

## Usage

<Usage
component='Badge'
module='@clerk/ui/mosaic/components/badge'
>
Badge Label
</Usage>

---

## Examples

### Intents

<Story
name='Intents'
storyModule={BadgeStories}
/>

### With an icon

<Story
name='WithIcon'
storyModule={BadgeStories}
/>
95 changes: 95 additions & 0 deletions packages/swingset/src/stories/badge.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { BadgeProps } from '@clerk/ui/mosaic/components/badge';
import { Badge } from '@clerk/ui/mosaic/components/badge';
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 | 🟡 Minor | ⚡ Quick win

Add the required Emotion JSX pragma.

This story renders a styled Mosaic component but lacks the required top-level @jsxImportSource pragma.

+/** `@jsxImportSource` `@emotion/react` */
+
 import type { BadgeProps } from '`@clerk/ui/mosaic/components/badge`';

As per coding guidelines, “Use Emotion pragma /** @jsxImportSource @emotion/react */ at the top of story files that render styled Mosaic components.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import type { BadgeProps } from '@clerk/ui/mosaic/components/badge';
import { Badge } from '@clerk/ui/mosaic/components/badge';
/** `@jsxImportSource` `@emotion/react` */
import type { BadgeProps } from '`@clerk/ui/mosaic/components/badge`';
import { Badge } from '`@clerk/ui/mosaic/components/badge`';
🤖 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/swingset/src/stories/badge.stories.tsx` around lines 1 - 2, Add the
required top-level Emotion JSX import-source pragma to the badge story before
its imports, while preserving the existing BadgeProps and Badge imports.

Source: Coding guidelines


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

// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
// renders a code footer with its function's source. See `StoryModule.__source`.
export { default as __source } from './badge.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 `BadgeProps`.
export const meta: StoryMeta = {
group: 'Components',
title: 'Badge',
source: 'packages/ui/src/mosaic/components/badge/badge.tsx',
styles: {
_variants: {
intent: { primary: {}, secondary: {}, warning: {}, destructive: {}, success: {} },
},
_defaultVariants: {
intent: 'primary',
},
},
};

// Story functions accept Record<string,unknown> (knob values) and cast to BadgeProps.
// The cast is unavoidable: knobs are dynamically typed; Badge has a strict prop interface.
function knobsAsProps(props: Record<string, unknown>) {
return props as unknown as BadgeProps;
}

export function Primary(props: Record<string, unknown>) {
return <Badge {...knobsAsProps(props)}>Badge Label</Badge>;
}

export function Intents(props: Record<string, unknown>) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<Badge
{...knobsAsProps(props)}
intent='primary'
>
Primary
</Badge>
<Badge
{...knobsAsProps(props)}
intent='secondary'
>
Secondary
</Badge>
<Badge
{...knobsAsProps(props)}
intent='warning'
>
Warning
</Badge>
<Badge
{...knobsAsProps(props)}
intent='destructive'
>
Destructive
</Badge>
<Badge
{...knobsAsProps(props)}
intent='success'
>
Success
</Badge>
</div>
);
}

export function WithIcon(props: Record<string, unknown>) {
return (
<Badge
{...knobsAsProps(props)}
intent='success'
>
<svg
width='10'
height='10'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='3'
strokeLinecap='round'
strokeLinejoin='round'
style={{ flexShrink: 0 }}
>
<path d='M20 6 9 17l-5-5' />
</svg>
Verified
</Badge>
);
}
53 changes: 53 additions & 0 deletions packages/ui/src/mosaic/components/badge/badge.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as stylex from '@stylexjs/stylex';

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

// Every intent is the same recipe applied to one token: the token itself is the
// text color, a 12% mix is the fill and a 24% mix the border. Mixing toward
// `transparent` rather than a surface token keeps the badge legible on any
// background it's dropped onto.
export const styles = stylex.create({
base: {
borderRadius: radiusVars['--cl-radius-inner'],
borderStyle: 'solid',
borderWidth: '1px',
gap: space['1'],
paddingInline: space['1.5'],
alignItems: 'center',
boxSizing: 'border-box',
display: 'inline-flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-label-sm-size'],
fontWeight: typeScaleVars['--cl-text-label-sm-weight'],
justifyContent: 'center',
lineHeight: typeScaleVars['--cl-text-label-sm-leading'],
whiteSpace: 'nowrap',
height: space['5'],
},

primary: {
borderColor: `color-mix(in oklab, ${colorVars['--cl-color-primary']} 24%, transparent)`,
backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-primary']} 12%, transparent)`,
color: colorVars['--cl-color-primary'],
},
secondary: {
borderColor: `color-mix(in oklab, ${colorVars['--cl-color-muted-foreground']} 24%, transparent)`,
backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-muted-foreground']} 12%, transparent)`,
color: colorVars['--cl-color-muted-foreground'],
},
warning: {
borderColor: `color-mix(in oklab, ${colorVars['--cl-color-warning']} 24%, transparent)`,
backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-warning']} 12%, transparent)`,
color: colorVars['--cl-color-warning'],
},
destructive: {
borderColor: `color-mix(in oklab, ${colorVars['--cl-color-destructive']} 24%, transparent)`,
backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-destructive']} 12%, transparent)`,
color: colorVars['--cl-color-destructive'],
},
success: {
borderColor: `color-mix(in oklab, ${colorVars['--cl-color-success']} 24%, transparent)`,
backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-success']} 12%, transparent)`,
color: colorVars['--cl-color-success'],
},
});
55 changes: 55 additions & 0 deletions packages/ui/src/mosaic/components/badge/badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';

import { Badge } from './badge';

describe('Mosaic Badge', () => {
it('renders its children', () => {
render(<Badge>Active</Badge>);
expect(screen.getByText('Active')).toBeInTheDocument();
});

it('applies the default intent when none is passed', () => {
render(<Badge>Active</Badge>);
const badge = screen.getByText('Active');
expect(badge).toHaveClass('cl-badge');
expect(badge).toHaveAttribute('data-intent', 'primary');
});

it.each(['primary', 'secondary', 'warning', 'destructive', 'success'] as const)('reflects the %s intent', intent => {
render(<Badge intent={intent}>Active</Badge>);
expect(screen.getByText('Active')).toHaveAttribute('data-intent', intent);
});

it('lets the consumer className and style win', () => {
render(
<Badge
className='my-badge'
style={{ marginTop: '8px' }}
>
Active
</Badge>,
);
const badge = screen.getByText('Active');
expect(badge).toHaveClass('cl-badge', 'my-badge');
expect(badge).toHaveStyle({ marginTop: '8px' });
});

it('forwards arbitrary span props and the ref', () => {
const ref = React.createRef<HTMLSpanElement>();
render(
<Badge
ref={ref}
id='status'
aria-label='Status'
>
Active
</Badge>,
);
const badge = screen.getByText('Active');
expect(ref.current).toBe(badge);
expect(badge).toHaveAttribute('id', 'status');
expect(badge).toHaveAttribute('aria-label', 'Status');
});
});
24 changes: 24 additions & 0 deletions packages/ui/src/mosaic/components/badge/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as stylex from '@stylexjs/stylex';
import React from 'react';

import { mergeProps, themeProps } from '../../props';
import { styles } from './badge.styles';

export interface BadgeProps extends React.ComponentPropsWithRef<'span'> {
intent?: 'primary' | 'secondary' | 'warning' | 'destructive' | 'success';
}

export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(function MosaicBadge(
{ intent = 'primary', className, style, children, ...rest },
ref,
) {
return (
<span
ref={ref}
{...mergeProps(themeProps('badge', { intent }), stylex.props(styles.base, styles[intent]), className, style)}
{...rest}
Comment on lines +18 to +19

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

Prevent consumers from overriding the resolved intent.

...rest can overwrite themeProps('badge', { intent }); e.g. intent='success' data-intent='primary' produces mismatched styling and state metadata. Spread rest before the component-owned props and add a regression test.

Proposed fix
     <span
       ref={ref}
+      {...rest}
       {...mergeProps(themeProps('badge', { intent }), stylex.props(styles.base, styles[intent]), className, style)}
-      {...rest}
     >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{...mergeProps(themeProps('badge', { intent }), stylex.props(styles.base, styles[intent]), className, style)}
{...rest}
<span
ref={ref}
{...rest}
{...mergeProps(themeProps('badge', { intent }), stylex.props(styles.base, styles[intent]), className, style)}
- {...rest}
>
🤖 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/badge/badge.tsx` around lines 18 - 19,
Update the Badge component’s prop spread order so rest props are applied before
the component-owned themeProps result, preventing consumers from overriding the
resolved intent metadata. Preserve the existing style and className resolution,
and add a regression test covering an intent such as success with conflicting
rest metadata to verify the resolved intent wins.

>
{children}
</span>
);
});
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/badge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Badge } from './badge';
export type { BadgeProps } from './badge';
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// static `styles.css`. Keep it isolated from Emotion/un-migrated code — grow it
// as components migrate.

export { Badge } from '../components/badge';
export type { BadgeProps } from '../components/badge';
export { Button } from '../components/button';
export type { ButtonProps } from '../components/button';

Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/mosaic/tokens.stylex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const colorDefaults = {
'--cl-color-primary-foreground': 'light-dark(oklch(0.985 0 0), oklch(0.205 0 0))',
'--cl-color-destructive': 'light-dark(oklch(0.577 0.245 27.325), oklch(0.637 0.237 25.331))',
'--cl-color-destructive-foreground': 'oklch(0.985 0 0)',
'--cl-color-success': 'light-dark(oklch(0.548 0.153 152.535), oklch(0.696 0.17 162.48))',
'--cl-color-success-foreground': 'oklch(0.985 0 0)',
'--cl-color-warning': 'light-dark(oklch(0.646 0.222 41.116), oklch(0.75 0.183 55.934))',
'--cl-color-warning-foreground': 'oklch(0.985 0 0)',
'--cl-color-muted': 'light-dark(oklch(0.97 0 0), oklch(0.269 0 0))',
'--cl-color-muted-foreground': 'light-dark(oklch(0.556 0 0), oklch(0.708 0 0))',
'--cl-color-card': 'light-dark(oklch(1 0 0), oklch(0.205 0 0))',
Expand Down
Loading