From 2f24dfe57ec97b9848754b0ae16263e3dc10261b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 22 Jul 2026 16:35:40 -0400 Subject: [PATCH 01/13] feat(ui): add Mosaic Badge component --- .changeset/mosaic-badge.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 14 +++ packages/swingset/src/stories/badge.mdx | 43 +++++++++ .../swingset/src/stories/badge.stories.tsx | 95 +++++++++++++++++++ .../mosaic/components/badge/badge.styles.ts | 53 +++++++++++ .../mosaic/components/badge/badge.test.tsx | 55 +++++++++++ .../ui/src/mosaic/components/badge/badge.tsx | 24 +++++ .../ui/src/mosaic/components/badge/index.ts | 2 + packages/ui/src/mosaic/styles/index.ts | 2 + packages/ui/src/mosaic/tokens.stylex.ts | 4 + 11 files changed, 295 insertions(+) create mode 100644 .changeset/mosaic-badge.md create mode 100644 packages/swingset/src/stories/badge.mdx create mode 100644 packages/swingset/src/stories/badge.stories.tsx create mode 100644 packages/ui/src/mosaic/components/badge/badge.styles.ts create mode 100644 packages/ui/src/mosaic/components/badge/badge.test.tsx create mode 100644 packages/ui/src/mosaic/components/badge/badge.tsx create mode 100644 packages/ui/src/mosaic/components/badge/index.ts diff --git a/.changeset/mosaic-badge.md b/.changeset/mosaic-badge.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-badge.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 35bdb3e9da1..8ee60a0d29e 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -28,6 +28,7 @@ const docModules: Record> = { 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')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dee445b26f0..15217dd123f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -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, @@ -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 }; @@ -171,6 +184,7 @@ export const registry: StoryModule[] = [ // Blocks destructiveModule, // Components + badgeModule, buttonModule, cardComponentModule, inputModule, diff --git a/packages/swingset/src/stories/badge.mdx b/packages/swingset/src/stories/badge.mdx new file mode 100644 index 00000000000..6c11cbe694f --- /dev/null +++ b/packages/swingset/src/stories/badge.mdx @@ -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 + + + +## Props + + + +## Usage + + + Badge Label + + +--- + +## Examples + +### Intents + + + +### With an icon + + diff --git a/packages/swingset/src/stories/badge.stories.tsx b/packages/swingset/src/stories/badge.stories.tsx new file mode 100644 index 00000000000..5ecd991b601 --- /dev/null +++ b/packages/swingset/src/stories/badge.stories.tsx @@ -0,0 +1,95 @@ +import type { BadgeProps } from '@clerk/ui/mosaic/components/badge'; +import { Badge } from '@clerk/ui/mosaic/components/badge'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` 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 (knob values) and cast to BadgeProps. +// The cast is unavoidable: knobs are dynamically typed; Badge has a strict prop interface. +function knobsAsProps(props: Record) { + return props as unknown as BadgeProps; +} + +export function Primary(props: Record) { + return Badge Label; +} + +export function Intents(props: Record) { + return ( +
+ + Primary + + + Secondary + + + Warning + + + Destructive + + + Success + +
+ ); +} + +export function WithIcon(props: Record) { + return ( + + + + + Verified + + ); +} diff --git a/packages/ui/src/mosaic/components/badge/badge.styles.ts b/packages/ui/src/mosaic/components/badge/badge.styles.ts new file mode 100644 index 00000000000..34905829b55 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.styles.ts @@ -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'], + }, +}); diff --git a/packages/ui/src/mosaic/components/badge/badge.test.tsx b/packages/ui/src/mosaic/components/badge/badge.test.tsx new file mode 100644 index 00000000000..5930e6bd895 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.test.tsx @@ -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(Active); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('applies the default intent when none is passed', () => { + render(Active); + 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(Active); + expect(screen.getByText('Active')).toHaveAttribute('data-intent', intent); + }); + + it('lets the consumer className and style win', () => { + render( + + Active + , + ); + 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(); + render( + + Active + , + ); + const badge = screen.getByText('Active'); + expect(ref.current).toBe(badge); + expect(badge).toHaveAttribute('id', 'status'); + expect(badge).toHaveAttribute('aria-label', 'Status'); + }); +}); diff --git a/packages/ui/src/mosaic/components/badge/badge.tsx b/packages/ui/src/mosaic/components/badge/badge.tsx new file mode 100644 index 00000000000..d8334e2d8e0 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.tsx @@ -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(function MosaicBadge( + { intent = 'primary', className, style, children, ...rest }, + ref, +) { + return ( + + {children} + + ); +}); diff --git a/packages/ui/src/mosaic/components/badge/index.ts b/packages/ui/src/mosaic/components/badge/index.ts new file mode 100644 index 00000000000..b8e71bd1b09 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/index.ts @@ -0,0 +1,2 @@ +export { Badge } from './badge'; +export type { BadgeProps } from './badge'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index f1716fae927..22839ba4829 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -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'; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 859d98ae9ac..b3d0c5c98de 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -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))', From d0a3fcdd1543d16de12eee989adbcc4a72d8d833 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 17:07:26 -0400 Subject: [PATCH 02/13] adjust colors/sizing --- .../mosaic/components/badge/badge.styles.ts | 23 +++++++------------ packages/ui/src/mosaic/tokens.stylex.ts | 9 ++++++++ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/mosaic/components/badge/badge.styles.ts b/packages/ui/src/mosaic/components/badge/badge.styles.ts index 34905829b55..09eea8dc2b0 100644 --- a/packages/ui/src/mosaic/components/badge/badge.styles.ts +++ b/packages/ui/src/mosaic/components/badge/badge.styles.ts @@ -8,11 +8,9 @@ import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex // background it's dropped onto. export const styles = stylex.create({ base: { - borderRadius: radiusVars['--cl-radius-inner'], - borderStyle: 'solid', - borderWidth: '1px', + borderRadius: radiusVars['--cl-radius-full'], gap: space['1'], - paddingInline: space['1.5'], + paddingInline: space['2'], alignItems: 'center', boxSizing: 'border-box', display: 'inline-flex', @@ -26,28 +24,23 @@ export const styles = stylex.create({ }, 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'], + backgroundColor: colorVars['--cl-color-primary'], + color: colorVars['--cl-color-primary-foreground'], }, 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)`, + backgroundColor: colorVars['--cl-color-muted'], 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)`, + backgroundColor: colorVars['--cl-color-warning-faded'], 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)`, + backgroundColor: colorVars['--cl-color-destructive-faded'], 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)`, + backgroundColor: colorVars['--cl-color-success-faded'], color: colorVars['--cl-color-success'], }, }); diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index b3d0c5c98de..e0328756f35 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -26,14 +26,23 @@ import * as stylex from '@stylexjs/stylex'; const colorDefaults = { '--cl-color-primary': 'light-dark(oklch(0.205 0 0), oklch(0.922 0 0))', '--cl-color-primary-foreground': 'light-dark(oklch(0.985 0 0), oklch(0.205 0 0))', + '--cl-color-primary-faded': 'light-dark(oklch(0.9583 0.0214 291.74), oklch(0.3097 0.1008 285.05))', + '--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-destructive-faded': 'light-dark(oklch(0.9757 0.0118 17.36), oklch(0.255 0.0604 22.31))', + '--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-success-faded': 'light-dark(oklch(0.9859 0.0164 156.92), oklch(0.3297 0.052 152.31))', + '--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-warning-faded': 'light-dark(oklch(0.9799 0.0147 70.89), oklch(0.2725 0.0547 55.7))', + '--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))', '--cl-color-card-foreground': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))', '--cl-color-border': 'light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%))', From ba4d13adecbd887e93e5ed0833cde7c38a542797 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 19:51:26 -0400 Subject: [PATCH 03/13] refactor(ui): rename mergeProps to mergeStyleProps in Badge --- packages/ui/src/mosaic/components/badge/badge.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/mosaic/components/badge/badge.tsx b/packages/ui/src/mosaic/components/badge/badge.tsx index d8334e2d8e0..2d61372fc69 100644 --- a/packages/ui/src/mosaic/components/badge/badge.tsx +++ b/packages/ui/src/mosaic/components/badge/badge.tsx @@ -1,7 +1,7 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; -import { mergeProps, themeProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; import { styles } from './badge.styles'; export interface BadgeProps extends React.ComponentPropsWithRef<'span'> { @@ -15,7 +15,7 @@ export const Badge = React.forwardRef(function Mosa return ( {children} From dfb8912d897eb18c4ab272e0614dea80b6c30bb1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 19:55:26 -0400 Subject: [PATCH 04/13] feat(ui): support custom rendering in Badge via useRender --- .../mosaic/components/badge/badge.test.tsx | 17 ++++++++++++ .../ui/src/mosaic/components/badge/badge.tsx | 26 ++++++++++--------- packages/ui/tsdown.mosaic.config.mts | 2 +- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/mosaic/components/badge/badge.test.tsx b/packages/ui/src/mosaic/components/badge/badge.test.tsx index 5930e6bd895..6f540b01fd8 100644 --- a/packages/ui/src/mosaic/components/badge/badge.test.tsx +++ b/packages/ui/src/mosaic/components/badge/badge.test.tsx @@ -52,4 +52,21 @@ describe('Mosaic Badge', () => { expect(badge).toHaveAttribute('id', 'status'); expect(badge).toHaveAttribute('aria-label', 'Status'); }); + + it('renders a custom element via render, keeping the styling contract', () => { + render( + } + > + Active + , + ); + const badge = screen.getByRole('link', { name: 'Active' }); + expect(badge.tagName).toBe('A'); + expect(badge).toHaveAttribute('href', '/status'); + expect(badge).toHaveClass('cl-badge'); + expect(badge).toHaveAttribute('data-intent', 'success'); + }); }); diff --git a/packages/ui/src/mosaic/components/badge/badge.tsx b/packages/ui/src/mosaic/components/badge/badge.tsx index 2d61372fc69..23be5cdff6d 100644 --- a/packages/ui/src/mosaic/components/badge/badge.tsx +++ b/packages/ui/src/mosaic/components/badge/badge.tsx @@ -1,24 +1,26 @@ +import { type ComponentProps, type RenderProp, useRender } from '@clerk/headless/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { mergeStyleProps, themeProps } from '../../props'; import { styles } from './badge.styles'; -export interface BadgeProps extends React.ComponentPropsWithRef<'span'> { +export type BadgeProps = Omit, 'render'> & { intent?: 'primary' | 'secondary' | 'warning' | 'destructive' | 'success'; -} + render?: RenderProp> | React.ReactElement; +}; export const Badge = React.forwardRef(function MosaicBadge( - { intent = 'primary', className, style, children, ...rest }, + { intent = 'primary', render, className, style, ...rest }, ref, ) { - return ( - - {children} - - ); + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps(themeProps('badge', { intent }), stylex.props(styles.base, styles[intent]), className, style), + ...rest, + }, + }); }); diff --git a/packages/ui/tsdown.mosaic.config.mts b/packages/ui/tsdown.mosaic.config.mts index 6241344067b..3bc6a1bca22 100644 --- a/packages/ui/tsdown.mosaic.config.mts +++ b/packages/ui/tsdown.mosaic.config.mts @@ -37,7 +37,7 @@ export default defineConfig({ minify: false, // Use the standard React JSX runtime, not Emotion's — the Mosaic build must be Emotion-free. tsconfig: './tsconfig.mosaic.json', - external: ['react', 'react-dom', '@stylexjs/stylex'], + external: ['react', 'react-dom', '@stylexjs/stylex', '@clerk/headless', '@clerk/headless/utils'], plugins: [ stylexPlugin({ fileName: 'styles.css', From 5406ee6a0f46f1981cd536afa420e9017cdab72c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 27 Jul 2026 15:39:08 -0400 Subject: [PATCH 05/13] refactor(ui): rename Badge intent prop to color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - values: secondary→neutral, destructive→negative, success→positive - swingset PropTable: sx row now opt-out (Badge is StyleX, no sx) - docs: document render prop --- .../swingset/src/components/PropTable.tsx | 6 +++-- packages/swingset/src/stories/badge.mdx | 12 ++++++---- .../swingset/src/stories/badge.stories.tsx | 24 +++++++++---------- .../mosaic/components/badge/badge.styles.ts | 18 +++++++------- .../mosaic/components/badge/badge.test.tsx | 14 +++++------ .../ui/src/mosaic/components/badge/badge.tsx | 6 ++--- packages/ui/src/mosaic/tokens.stylex.ts | 18 +++++++------- 7 files changed, 52 insertions(+), 46 deletions(-) diff --git a/packages/swingset/src/components/PropTable.tsx b/packages/swingset/src/components/PropTable.tsx index dedf91462ba..784371d665f 100644 --- a/packages/swingset/src/components/PropTable.tsx +++ b/packages/swingset/src/components/PropTable.tsx @@ -15,11 +15,13 @@ 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' }; -export function PropTable({ meta, extra = [] }: PropTableProps) { +export function PropTable({ meta, extra = [], sx = true }: PropTableProps) { const playground = usePlayground(); const variants = meta.styles?._variants ?? {}; const defaults = meta.styles?._defaultVariants ?? {}; @@ -35,7 +37,7 @@ export function PropTable({ meta, extra = [] }: PropTableProps) { return { name, type, default: defDisplay }; }), ...extra, - SX_ROW, + ...(sx ? [SX_ROW] : []), ]; return ( diff --git a/packages/swingset/src/stories/badge.mdx b/packages/swingset/src/stories/badge.mdx index 6c11cbe694f..55f12320a5c 100644 --- a/packages/swingset/src/stories/badge.mdx +++ b/packages/swingset/src/stories/badge.mdx @@ -2,7 +2,7 @@ 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. +Badge labels the status or category of the thing next to it. It renders a `span` by default and forwards a ref to the underlying element; use the `render` prop when the badge belongs in a different element, such as a link. Its `intent` carries meaning through color, so keep the label itself self-describing for anyone who can't see it. ## Playground @@ -13,7 +13,11 @@ Badge labels the status or category of the thing next to it. It is presentationa ## Props - + ReactNode' }]} + sx={false} +/> ## Usage @@ -28,10 +32,10 @@ Badge labels the status or category of the thing next to it. It is presentationa ## Examples -### Intents +### Colors diff --git a/packages/swingset/src/stories/badge.stories.tsx b/packages/swingset/src/stories/badge.stories.tsx index 5ecd991b601..57f4bce4cd4 100644 --- a/packages/swingset/src/stories/badge.stories.tsx +++ b/packages/swingset/src/stories/badge.stories.tsx @@ -15,10 +15,10 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/components/badge/badge.tsx', styles: { _variants: { - intent: { primary: {}, secondary: {}, warning: {}, destructive: {}, success: {} }, + color: { primary: {}, neutral: {}, warning: {}, negative: {}, positive: {} }, }, _defaultVariants: { - intent: 'primary', + color: 'primary', }, }, }; @@ -33,38 +33,38 @@ export function Primary(props: Record) { return Badge Label; } -export function Intents(props: Record) { +export function Colors(props: Record) { return (
Primary - Secondary + Neutral Warning - Destructive + Negative - Success + Positive
); @@ -74,7 +74,7 @@ export function WithIcon(props: Record) { return ( { expect(screen.getByText('Active')).toBeInTheDocument(); }); - it('applies the default intent when none is passed', () => { + it('applies the default color when none is passed', () => { render(Active); const badge = screen.getByText('Active'); expect(badge).toHaveClass('cl-badge'); - expect(badge).toHaveAttribute('data-intent', 'primary'); + expect(badge).toHaveAttribute('data-color', 'primary'); }); - it.each(['primary', 'secondary', 'warning', 'destructive', 'success'] as const)('reflects the %s intent', intent => { - render(Active); - expect(screen.getByText('Active')).toHaveAttribute('data-intent', intent); + it.each(['primary', 'neutral', 'warning', 'negative', 'positive'] as const)('reflects the %s color', color => { + render(Active); + expect(screen.getByText('Active')).toHaveAttribute('data-color', color); }); it('lets the consumer className and style win', () => { @@ -56,7 +56,7 @@ describe('Mosaic Badge', () => { it('renders a custom element via render, keeping the styling contract', () => { render( } > @@ -67,6 +67,6 @@ describe('Mosaic Badge', () => { expect(badge.tagName).toBe('A'); expect(badge).toHaveAttribute('href', '/status'); expect(badge).toHaveClass('cl-badge'); - expect(badge).toHaveAttribute('data-intent', 'success'); + expect(badge).toHaveAttribute('data-color', 'positive'); }); }); diff --git a/packages/ui/src/mosaic/components/badge/badge.tsx b/packages/ui/src/mosaic/components/badge/badge.tsx index 23be5cdff6d..0073915564e 100644 --- a/packages/ui/src/mosaic/components/badge/badge.tsx +++ b/packages/ui/src/mosaic/components/badge/badge.tsx @@ -6,12 +6,12 @@ import { mergeStyleProps, themeProps } from '../../props'; import { styles } from './badge.styles'; export type BadgeProps = Omit, 'render'> & { - intent?: 'primary' | 'secondary' | 'warning' | 'destructive' | 'success'; + color?: 'primary' | 'neutral' | 'warning' | 'negative' | 'positive'; render?: RenderProp> | React.ReactElement; }; export const Badge = React.forwardRef(function MosaicBadge( - { intent = 'primary', render, className, style, ...rest }, + { color = 'primary', render, className, style, ...rest }, ref, ) { return useRender({ @@ -19,7 +19,7 @@ export const Badge = React.forwardRef(function Mosa render, ref, props: { - ...mergeStyleProps(themeProps('badge', { intent }), stylex.props(styles.base, styles[intent]), className, style), + ...mergeStyleProps(themeProps('badge', { color }), stylex.props(styles.base, styles[color]), className, style), ...rest, }, }); diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index e0328756f35..a76edb244d7 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -28,21 +28,21 @@ const colorDefaults = { '--cl-color-primary-foreground': 'light-dark(oklch(0.985 0 0), oklch(0.205 0 0))', '--cl-color-primary-faded': 'light-dark(oklch(0.9583 0.0214 291.74), oklch(0.3097 0.1008 285.05))', - '--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-destructive-faded': 'light-dark(oklch(0.9757 0.0118 17.36), oklch(0.255 0.0604 22.31))', + '--cl-color-neutral': 'light-dark(oklch(0.97 0 0), oklch(0.269 0 0))', + '--cl-color-neutral-foreground': 'light-dark(oklch(0.24 0 0), oklch(0.708 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-success-faded': 'light-dark(oklch(0.9859 0.0164 156.92), oklch(0.3297 0.052 152.31))', + '--cl-color-negative': 'light-dark(oklch(0.577 0.245 27.325), oklch(0.637 0.237 25.331))', + '--cl-color-negative-foreground': 'oklch(0.985 0 0)', + '--cl-color-negative-faded': 'light-dark(oklch(0.9757 0.0118 17.36), oklch(0.255 0.0604 22.31))', + + '--cl-color-positive': 'light-dark(oklch(0.548 0.153 152.535), oklch(0.696 0.17 162.48))', + '--cl-color-positive-foreground': 'oklch(0.985 0 0)', + '--cl-color-positive-faded': 'light-dark(oklch(0.9859 0.0164 156.92), oklch(0.3297 0.052 152.31))', '--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-warning-faded': 'light-dark(oklch(0.9799 0.0147 70.89), oklch(0.2725 0.0547 55.7))', - '--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))', '--cl-color-card-foreground': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))', '--cl-color-border': 'light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%))', From 8e4e83b2e7eb85fb0713b7293fa73cc1f9e157eb Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 27 Jul 2026 15:47:00 -0400 Subject: [PATCH 06/13] fix(ui): point Button destructive intent at renamed negative color token --- .../ui/src/mosaic/components/button/button.styles.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index a2fa84cfc32..34cc2a651d3 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -38,13 +38,13 @@ export const styles = stylex.create({ }, filledDestructive: { backgroundColor: { - default: colorVars['--cl-color-destructive'], - ':active': `color-mix(in oklab, ${colorVars['--cl-color-destructive']}, ${colorVars['--cl-color-destructive-foreground']} 24%)`, + default: colorVars['--cl-color-negative'], + ':active': `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 24%)`, '@media (hover: hover)': { - ':hover': `color-mix(in oklab, ${colorVars['--cl-color-destructive']}, ${colorVars['--cl-color-destructive-foreground']} 12%)`, + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 12%)`, }, }, - color: colorVars['--cl-color-destructive-foreground'], + color: colorVars['--cl-color-negative-foreground'], }, outlinePrimary: { borderColor: colorVars['--cl-color-border'], @@ -54,7 +54,7 @@ export const styles = stylex.create({ outlineDestructive: { borderColor: colorVars['--cl-color-border'], backgroundColor: 'transparent', - color: colorVars['--cl-color-destructive'], + color: colorVars['--cl-color-negative'], }, ghostPrimary: { backgroundColor: 'transparent', @@ -62,7 +62,7 @@ export const styles = stylex.create({ }, ghostDestructive: { backgroundColor: 'transparent', - color: colorVars['--cl-color-destructive'], + color: colorVars['--cl-color-negative'], }, // size — height-driven; padding sets only the inline axis From 49c80c2b1ca16c0bf9731e150e92e6456f24be5b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 27 Jul 2026 15:55:17 -0400 Subject: [PATCH 07/13] fix(ui): update swingset registry for renamed Badge Colors story --- packages/swingset/src/lib/registry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 15217dd123f..087108082ce 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -2,7 +2,7 @@ import { meta as accordionMeta } from '../stories/accordion.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; import { - Intents as BadgeIntents, + Colors as BadgeColors, meta as badgeMeta, Primary as BadgePrimary, WithIcon as BadgeWithIcon, @@ -124,7 +124,7 @@ const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: Car const badgeModule: StoryModule = { meta: badgeMeta, Primary: BadgePrimary, - Intents: BadgeIntents, + Colors: BadgeColors, WithIcon: BadgeWithIcon, }; From 890fc067a6cae49626172723723e617ca1901964 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 27 Jul 2026 15:56:13 -0400 Subject: [PATCH 08/13] docs(ui): fix stale intent references in Badge prose and comment --- packages/swingset/src/stories/badge.mdx | 2 +- packages/ui/src/mosaic/components/badge/badge.styles.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/swingset/src/stories/badge.mdx b/packages/swingset/src/stories/badge.mdx index 55f12320a5c..ca33dfdf342 100644 --- a/packages/swingset/src/stories/badge.mdx +++ b/packages/swingset/src/stories/badge.mdx @@ -2,7 +2,7 @@ import * as BadgeStories from './badge.stories'; # Badge -Badge labels the status or category of the thing next to it. It renders a `span` by default and forwards a ref to the underlying element; use the `render` prop when the badge belongs in a different element, such as a link. Its `intent` carries meaning through color, so keep the label itself self-describing for anyone who can't see it. +Badge labels the status or category of the thing next to it. It renders a `span` by default and forwards a ref to the underlying element; use the `render` prop when the badge belongs in a different element, such as a link. Its `color` carries meaning, so keep the label itself self-describing for anyone who can't see it. ## Playground diff --git a/packages/ui/src/mosaic/components/badge/badge.styles.ts b/packages/ui/src/mosaic/components/badge/badge.styles.ts index 34ab9e69f21..31b966db881 100644 --- a/packages/ui/src/mosaic/components/badge/badge.styles.ts +++ b/packages/ui/src/mosaic/components/badge/badge.styles.ts @@ -2,10 +2,8 @@ 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. +// warning/negative/positive tint a faded fill and use the saturated token as text; +// primary/neutral fill with the solid token and use its `-foreground` for text. export const styles = stylex.create({ base: { borderRadius: radiusVars['--cl-radius-full'], From 340170815180c15ec3450f9c840f7e5417321ab0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 28 Jul 2026 10:24:13 -0400 Subject: [PATCH 09/13] fix(repo): hot-reload Mosaic StyleX edits in swingset dev Dev now injects StyleX at runtime (unplugin runtimeInjection) instead of the build-time globals.css extraction, which Next never re-ran on .styles.ts edits. Prod keeps static extraction. --- packages/swingset/next.config.mjs | 21 ++++++---- packages/swingset/postcss.config.mjs | 60 +++++++++++++++++----------- 2 files changed, 50 insertions(+), 31 deletions(-) diff --git a/packages/swingset/next.config.mjs b/packages/swingset/next.config.mjs index 039a919e74a..532de5cb78a 100644 --- a/packages/swingset/next.config.mjs +++ b/packages/swingset/next.config.mjs @@ -61,15 +61,22 @@ const nextConfig = { // Swingset consumes Mosaic from source, so StyleX (`defineVars`/`create`/`props`) must be // compiled here — otherwise the calls hit the runtime and throw. The unplugin transforms the - // StyleX *JS only* (calls → static atom references), keeping SWC intact so `next/font` and the - // Emotion transform keep working. The CSS is emitted separately by `@stylexjs/postcss-plugin` - // (`@stylex` in `globals.css`), so this runs in extraction mode (no `runtimeInjection`); both - // share the same StyleX babel version/options so the atom hashes match, and the plugin's dev - // "no CSS asset" warning is expected and harmless. `useCSSLayers: true` matches the published - // build so atoms carry StyleX's `@layer priorityN` precedence. + // StyleX *JS only*, keeping SWC intact so `next/font` and the Emotion transform keep working. + // + // CSS strategy forks by env (see `postcss.config.mjs`): + // - Prod: `runtimeInjection: false`. Atoms are static class refs; the CSS is extracted + // separately by `@stylexjs/postcss-plugin` into `globals.css` (`useCSSLayers` preserves + // StyleX's `@layer priorityN` precedence). Both share the same babel version/options so + // atom hashes match; the plugin's dev "no CSS asset" warning is expected and harmless. + // - Dev: `runtimeInjection: true`. StyleX injects a ` + */ export const Badge = React.forwardRef(function MosaicBadge( { color = 'primary', render, className, style, ...rest }, ref, diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index 62d0dffbe46..aa38245d477 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -12,6 +12,26 @@ export interface ButtonProps extends React.ComponentPropsWithRef<'button'> { fullWidth?: boolean; } +/** + * A clickable action styled by the Mosaic recipe. Renders a `button` and forwards its + * ref; `intent`, `variant`, `size`, and `shape` compose to cover the full set of styles. + * + * @example + * // Default (primary, filled, md) + * + * + * @example + * // Destructive intent with a non-filled variant + * + * + * @example + * // Icon-only, circular, small + * + * + * @example + * // Full-width ghost button + * + */ export const Button = React.forwardRef(function MosaicButton( { intent = 'primary',