From 028925952072efd6ede8705071c671e93f041a4a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 17:12:39 -0400 Subject: [PATCH 1/7] feat(ui): add Mosaic Avatar component --- .changeset/mosaic-avatar.md | 5 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 16 +++ packages/swingset/src/stories/avatar.mdx | 50 ++++++++++ .../swingset/src/stories/avatar.stories.tsx | 99 +++++++++++++++++++ .../mosaic/components/avatar/avatar.styles.ts | 39 ++++++++ .../mosaic/components/avatar/avatar.test.tsx | 76 ++++++++++++++ .../src/mosaic/components/avatar/avatar.tsx | 48 +++++++++ .../ui/src/mosaic/components/avatar/index.ts | 2 + packages/ui/src/mosaic/styles/index.ts | 2 + 10 files changed, 338 insertions(+) create mode 100644 .changeset/mosaic-avatar.md create mode 100644 packages/swingset/src/stories/avatar.mdx create mode 100644 packages/swingset/src/stories/avatar.stories.tsx create mode 100644 packages/ui/src/mosaic/components/avatar/avatar.styles.ts create mode 100644 packages/ui/src/mosaic/components/avatar/avatar.test.tsx create mode 100644 packages/ui/src/mosaic/components/avatar/avatar.tsx create mode 100644 packages/ui/src/mosaic/components/avatar/index.ts diff --git a/.changeset/mosaic-avatar.md b/.changeset/mosaic-avatar.md new file mode 100644 index 00000000000..4f710065226 --- /dev/null +++ b/.changeset/mosaic-avatar.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add a new Mosaic `Avatar` component (StyleX) with `shape` (`circle` | `square`) and `size` (`lg` | `md` | `sm` | `xs`) props. Renders an image when `src` is set, otherwise its children as a fallback. diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 35bdb3e9da1..fc15ec1dcbf 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: { + avatar: dynamic(() => import('../stories/avatar.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..24c4dc81b5a 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,6 +1,13 @@ // 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 { + Image as AvatarImage, + meta as avatarMeta, + Primary as AvatarPrimary, + Shapes as AvatarShapes, + Sizes as AvatarSizes, +} from '../stories/avatar.stories'; import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories'; import { Centered as CardCentered, @@ -115,6 +122,14 @@ const organizationProfileMembersPanelModule: StoryModule = { const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered }; +const avatarModule: StoryModule = { + meta: avatarMeta, + Primary: AvatarPrimary, + Image: AvatarImage, + Sizes: AvatarSizes, + Shapes: AvatarShapes, +}; + const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled }; const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid }; @@ -171,6 +186,7 @@ export const registry: StoryModule[] = [ // Blocks destructiveModule, // Components + avatarModule, buttonModule, cardComponentModule, inputModule, diff --git a/packages/swingset/src/stories/avatar.mdx b/packages/swingset/src/stories/avatar.mdx new file mode 100644 index 00000000000..ee94bb31f29 --- /dev/null +++ b/packages/swingset/src/stories/avatar.mdx @@ -0,0 +1,50 @@ +import * as AvatarStories from './avatar.stories'; + +# Avatar + +Avatar represents a user or entity with an image, falling back to initials or an icon when no image is available. + +## Playground + + + +## Props + + + +## Usage + + + AC + + +--- + +## Examples + +### Image + + + +### Sizes + + + +### Shapes + + diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx new file mode 100644 index 00000000000..43d404060b0 --- /dev/null +++ b/packages/swingset/src/stories/avatar.stories.tsx @@ -0,0 +1,99 @@ +/** @jsxImportSource @emotion/react */ +import type { AvatarProps } from '@clerk/ui/mosaic/components/avatar'; +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; + +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 './avatar.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 `AvatarProps`. +export const meta: StoryMeta = { + group: 'Components', + title: 'Avatar', + source: 'packages/ui/src/mosaic/components/avatar/avatar.tsx', + styles: { + _variants: { + shape: { circle: {}, square: {} }, + size: { lg: {}, md: {}, sm: {}, xs: {} }, + }, + _defaultVariants: { + shape: 'circle', + size: 'md', + }, + }, +}; + +// Story functions accept Record (knob values) and cast to AvatarProps. +// The cast is unavoidable: knobs are dynamically typed; Avatar has a strict prop interface. +function knobsAsProps(props: Record) { + return props as unknown as AvatarProps; +} + +const IMAGE_SRC = 'https://images.clerk.dev/uploaded/img_avatar-placeholder.png'; + +export function Primary(props: Record) { + return AC; +} + +export function Image(props: Record) { + return ( + + ); +} + +export function Sizes(props: Record) { + return ( +
+ + XS + + + SM + + + MD + + + LG + +
+ ); +} + +export function Shapes(props: Record) { + return ( +
+ + AC + + + AC + +
+ ); +} diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts new file mode 100644 index 00000000000..cb5df816468 --- /dev/null +++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts @@ -0,0 +1,39 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + base: { + overflow: 'hidden', + alignItems: 'center', + backgroundColor: colorVars['--cl-color-muted'], + boxSizing: 'border-box', + color: colorVars['--cl-color-muted-foreground'], + display: 'inline-flex', + flexShrink: 0, + fontFamily: 'inherit', + fontWeight: typeScaleVars['--cl-text-label-weight'], + justifyContent: 'center', + lineHeight: 1, + position: 'relative', + userSelect: 'none', + }, + + // shape — square uses a fixed 6px radius for now; circle rounds fully + shapeCircle: { borderRadius: radiusVars['--cl-radius-full'] }, + shapeSquare: { borderRadius: '6px' }, + + // size — square box; fallback text scales with the box + sizeXs: { fontSize: '0.625rem', height: space['6'], width: space['6'] }, + sizeSm: { fontSize: '0.75rem', height: space['8'], width: space['8'] }, + sizeMd: { fontSize: '0.875rem', height: space['10'], width: space['10'] }, + sizeLg: { fontSize: '1rem', height: space['12'], width: space['12'] }, + + // image fills the clipped box + image: { + display: 'block', + objectFit: 'cover', + height: '100%', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx new file mode 100644 index 00000000000..035c56f9022 --- /dev/null +++ b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Avatar } from './avatar'; + +describe('Mosaic Avatar', () => { + it('applies default variants when none are passed', () => { + render(AC); + const avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('cl-avatar'); + expect(avatar).toHaveAttribute('data-shape', 'circle'); + expect(avatar).toHaveAttribute('data-size', 'md'); + expect(avatar).toHaveTextContent('AC'); + }); + + it('reflects shape and size as data attributes', () => { + render( + + AC + , + ); + const avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveAttribute('data-shape', 'square'); + expect(avatar).toHaveAttribute('data-size', 'lg'); + }); + + it('renders an image when src is provided instead of children', () => { + render( + + AC + , + ); + const img = screen.getByRole('img', { name: 'Alex' }); + expect(img).toHaveAttribute('src', 'https://example.com/a.png'); + expect(screen.queryByText('AC')).not.toBeInTheDocument(); + }); + + it('wires consumer className/style through to the element', () => { + render( + + AC + , + ); + const avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('cl-avatar', 'my-avatar'); + expect(avatar).toHaveStyle({ marginTop: '8px' }); + }); + + it('forwards arbitrary props and the ref', () => { + const ref = React.createRef(); + render( + + AC + , + ); + const avatar = screen.getByTestId('avatar'); + expect(ref.current).toBe(avatar); + expect(avatar).toHaveAttribute('aria-label', 'User avatar'); + }); +}); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.tsx b/packages/ui/src/mosaic/components/avatar/avatar.tsx new file mode 100644 index 00000000000..be60f800317 --- /dev/null +++ b/packages/ui/src/mosaic/components/avatar/avatar.tsx @@ -0,0 +1,48 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { mergeProps, themeProps } from '../../props'; +import { styles } from './avatar.styles'; + +export interface AvatarProps extends React.ComponentPropsWithRef<'span'> { + shape?: 'circle' | 'square'; + size?: 'lg' | 'md' | 'sm' | 'xs'; + /** Image source. When set, renders an `` filling the box; otherwise `children` is the fallback. */ + src?: string; + alt?: string; +} + +const sizeStyles = { + xs: styles.sizeXs, + sm: styles.sizeSm, + md: styles.sizeMd, + lg: styles.sizeLg, +} as const; + +export const Avatar = React.forwardRef(function MosaicAvatar( + { shape = 'circle', size = 'md', src, alt = '', className, style, children, ...rest }, + ref, +) { + return ( + + {src ? ( + {alt} + ) : ( + children + )} + + ); +}); diff --git a/packages/ui/src/mosaic/components/avatar/index.ts b/packages/ui/src/mosaic/components/avatar/index.ts new file mode 100644 index 00000000000..94930a04990 --- /dev/null +++ b/packages/ui/src/mosaic/components/avatar/index.ts @@ -0,0 +1,2 @@ +export { Avatar } from './avatar'; +export type { AvatarProps } from './avatar'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index f1716fae927..51fd6a0f47f 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 { Avatar } from '../components/avatar'; +export type { AvatarProps } from '../components/avatar'; export { Button } from '../components/button'; export type { ButtonProps } from '../components/button'; From 55a1e127295c62e5cc54e8918d9d053dfbdcd4e1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 17:27:48 -0400 Subject: [PATCH 2/7] refactor(ui): make Mosaic Avatar a compound component --- packages/swingset/src/lib/registry.ts | 4 +- packages/swingset/src/stories/avatar.mdx | 35 +++-- .../swingset/src/stories/avatar.stories.tsx | 88 +++++------ .../mosaic/components/avatar/avatar.styles.ts | 19 ++- .../mosaic/components/avatar/avatar.test.tsx | 109 +++++++++---- .../src/mosaic/components/avatar/avatar.tsx | 143 +++++++++++++++--- .../ui/src/mosaic/components/avatar/index.ts | 4 +- packages/ui/src/mosaic/styles/index.ts | 4 +- 8 files changed, 292 insertions(+), 114 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 24c4dc81b5a..c2f25e897b1 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 { - Image as AvatarImage, + Fallback as AvatarFallbackStory, meta as avatarMeta, Primary as AvatarPrimary, Shapes as AvatarShapes, @@ -125,7 +125,7 @@ const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: Car const avatarModule: StoryModule = { meta: avatarMeta, Primary: AvatarPrimary, - Image: AvatarImage, + Fallback: AvatarFallbackStory, Sizes: AvatarSizes, Shapes: AvatarShapes, }; diff --git a/packages/swingset/src/stories/avatar.mdx b/packages/swingset/src/stories/avatar.mdx index ee94bb31f29..4b445ba7f0a 100644 --- a/packages/swingset/src/stories/avatar.mdx +++ b/packages/swingset/src/stories/avatar.mdx @@ -2,7 +2,7 @@ import * as AvatarStories from './avatar.stories'; # Avatar -Avatar represents a user or entity with an image, falling back to initials or an icon when no image is available. +Avatar represents a user or entity as an image, falling back to initials or an icon when the image is missing or fails to load. It is a compound component: `Avatar` clips and sizes the box, `Avatar.Image` renders the picture once it loads, and `Avatar.Fallback` shows until then. ## Playground @@ -13,25 +13,42 @@ Avatar represents a user or entity with an image, falling back to initials or an ## Props +`shape` and `size` live on the `Avatar` root: + ## Usage - - AC - +```tsx +import { Avatar, AvatarImage, AvatarFallback } from '@clerk/ui/mosaic/components/avatar'; + + + + CN +; +``` + +## Parts + +| Part | Slot (`.cl-*`) | Description | +| ---------------- | -------------------- | ---------------------------------------------------------------------- | +| `Avatar` | `cl-avatar` | Root. Owns `shape` / `size`, clips its children to the shape. | +| `AvatarImage` | `cl-avatar-image` | Renders an `` once the source loads; renders nothing until then. | +| `AvatarFallback` | `cl-avatar-fallback` | Rendered while the image is pending or has failed. Optional `delayMs`. | --- ## Examples -### Image +### Fallback + +When the image fails to load, the fallback stays visible. diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx index 43d404060b0..a5126e2e127 100644 --- a/packages/swingset/src/stories/avatar.stories.tsx +++ b/packages/swingset/src/stories/avatar.stories.tsx @@ -32,49 +32,48 @@ function knobsAsProps(props: Record) { return props as unknown as AvatarProps; } -const IMAGE_SRC = 'https://images.clerk.dev/uploaded/img_avatar-placeholder.png'; +const IMAGE_SRC = 'https://github.com/shadcn.png'; export function Primary(props: Record) { - return AC; + return ( + + + CN + + ); } -export function Image(props: Record) { +export function Fallback(props: Record) { return ( - + + + CN + ); } export function Sizes(props: Record) { return (
- - XS - - - SM - - - MD - - - LG - + {(['xs', 'sm', 'md', 'lg'] as const).map(size => ( + + + CN + + ))}
); } @@ -82,18 +81,19 @@ export function Sizes(props: Record) { export function Shapes(props: Record) { return (
- - AC - - - AC - + {(['circle', 'square'] as const).map(shape => ( + + + CN + + ))}
); } diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts index cb5df816468..790123371da 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts +++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts @@ -3,12 +3,11 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; export const styles = stylex.create({ + // root — clips its parts to the shape/size; fill comes from the image or fallback base: { overflow: 'hidden', alignItems: 'center', - backgroundColor: colorVars['--cl-color-muted'], boxSizing: 'border-box', - color: colorVars['--cl-color-muted-foreground'], display: 'inline-flex', flexShrink: 0, fontFamily: 'inherit', @@ -17,13 +16,14 @@ export const styles = stylex.create({ lineHeight: 1, position: 'relative', userSelect: 'none', + verticalAlign: 'middle', }, // shape — square uses a fixed 6px radius for now; circle rounds fully shapeCircle: { borderRadius: radiusVars['--cl-radius-full'] }, shapeSquare: { borderRadius: '6px' }, - // size — square box; fallback text scales with the box + // size — square box; fallback text scales with the box via inherited font-size sizeXs: { fontSize: '0.625rem', height: space['6'], width: space['6'] }, sizeSm: { fontSize: '0.75rem', height: space['8'], width: space['8'] }, sizeMd: { fontSize: '0.875rem', height: space['10'], width: space['10'] }, @@ -31,9 +31,22 @@ export const styles = stylex.create({ // image fills the clipped box image: { + aspectRatio: '1 / 1', display: 'block', objectFit: 'cover', height: '100%', width: '100%', }, + + // fallback fills the box, centering its content and inheriting the sized font + fallback: { + alignItems: 'center', + backgroundColor: colorVars['--cl-color-muted'], + color: colorVars['--cl-color-muted-foreground'], + display: 'flex', + fontSize: 'inherit', + justifyContent: 'center', + height: '100%', + width: '100%', + }, }); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx index 035c56f9022..4c2e3f3a96a 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx @@ -1,27 +1,55 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Avatar } from './avatar'; +import { Avatar, AvatarFallback, AvatarImage } from './avatar'; + +// jsdom never fires load/error on images, so drive `new window.Image()` manually. +// Each instance resolves to the outcome keyed by its `src`. +type Outcome = 'load' | 'error'; +let outcomes: Record = {}; + +class MockImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(value: string) { + queueMicrotask(() => { + if (outcomes[value] === 'error') { + this.onerror?.(); + } else { + this.onload?.(); + } + }); + } +} + +vi.stubGlobal('Image', MockImage); + +afterEach(() => { + outcomes = {}; +}); describe('Mosaic Avatar', () => { - it('applies default variants when none are passed', () => { - render(AC); + it('applies default variants and reflects them as data attributes', () => { + render( + + CN + , + ); const avatar = screen.getByTestId('avatar'); expect(avatar).toHaveClass('cl-avatar'); expect(avatar).toHaveAttribute('data-shape', 'circle'); expect(avatar).toHaveAttribute('data-size', 'md'); - expect(avatar).toHaveTextContent('AC'); }); - it('reflects shape and size as data attributes', () => { + it('reflects shape and size overrides', () => { render( - AC + CN , ); const avatar = screen.getByTestId('avatar'); @@ -29,48 +57,69 @@ describe('Mosaic Avatar', () => { expect(avatar).toHaveAttribute('data-size', 'lg'); }); - it('renders an image when src is provided instead of children', () => { + it('shows the fallback while the image has not loaded', () => { + outcomes['https://example.com/a.png'] = 'error'; render( - - AC + + + CN + , + ); + const fallback = screen.getByText('CN'); + expect(fallback).toHaveClass('cl-avatar-fallback'); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('renders the image and hides the fallback once it loads', async () => { + outcomes['https://example.com/a.png'] = 'load'; + render( + + + CN , ); - const img = screen.getByRole('img', { name: 'Alex' }); + const img = await screen.findByRole('img', { name: 'Alex' }); + expect(img).toHaveClass('cl-avatar-image'); expect(img).toHaveAttribute('src', 'https://example.com/a.png'); - expect(screen.queryByText('AC')).not.toBeInTheDocument(); + expect(screen.queryByText('CN')).not.toBeInTheDocument(); }); - it('wires consumer className/style through to the element', () => { + it('keeps the fallback when the image errors', async () => { + outcomes['https://example.com/bad.png'] = 'error'; render( - - AC + + + CN , ); - const avatar = screen.getByTestId('avatar'); - expect(avatar).toHaveClass('cl-avatar', 'my-avatar'); - expect(avatar).toHaveStyle({ marginTop: '8px' }); + await waitFor(() => expect(screen.getByText('CN')).toBeInTheDocument()); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); }); - it('forwards arbitrary props and the ref', () => { + it('wires consumer className/style/ref through to the root', () => { const ref = React.createRef(); render( - AC + CN , ); const avatar = screen.getByTestId('avatar'); expect(ref.current).toBe(avatar); - expect(avatar).toHaveAttribute('aria-label', 'User avatar'); + expect(avatar).toHaveClass('cl-avatar', 'my-avatar'); + expect(avatar).toHaveStyle({ marginTop: '8px' }); + }); + + it('throws when a part is rendered outside ', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render(CN)).toThrow(/must be rendered inside /); + spy.mockRestore(); }); }); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.tsx b/packages/ui/src/mosaic/components/avatar/avatar.tsx index be60f800317..45e24b41be8 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.tsx @@ -4,12 +4,46 @@ import React from 'react'; import { mergeProps, themeProps } from '../../props'; import { styles } from './avatar.styles'; -export interface AvatarProps extends React.ComponentPropsWithRef<'span'> { - shape?: 'circle' | 'square'; - size?: 'lg' | 'md' | 'sm' | 'xs'; - /** Image source. When set, renders an `` filling the box; otherwise `children` is the fallback. */ - src?: string; - alt?: string; +type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; + +interface AvatarContextValue { + status: ImageLoadingStatus; + onStatusChange: (status: ImageLoadingStatus) => void; +} + +const AvatarContext = React.createContext(null); + +function useAvatarContext(part: string): AvatarContextValue { + const context = React.useContext(AvatarContext); + if (!context) { + throw new Error(`<${part}> must be rendered inside `); + } + return context; +} + +/** Preload `src` and report its load status, so the fallback shows until the image resolves. */ +function useImageLoadingStatus(src: string | undefined): ImageLoadingStatus { + const [status, setStatus] = React.useState('idle'); + + React.useEffect(() => { + if (!src) { + setStatus('error'); + return; + } + + let active = true; + const image = new window.Image(); + setStatus('loading'); + image.onload = () => active && setStatus('loaded'); + image.onerror = () => active && setStatus('error'); + image.src = src; + + return () => { + active = false; + }; + }, [src]); + + return status; } const sizeStyles = { @@ -19,30 +53,95 @@ const sizeStyles = { lg: styles.sizeLg, } as const; +export interface AvatarProps extends React.ComponentPropsWithRef<'span'> { + shape?: 'circle' | 'square'; + size?: 'lg' | 'md' | 'sm' | 'xs'; +} + export const Avatar = React.forwardRef(function MosaicAvatar( - { shape = 'circle', size = 'md', src, alt = '', className, style, children, ...rest }, + { shape = 'circle', size = 'md', className, style, children, ...rest }, + ref, +) { + const [status, setStatus] = React.useState('idle'); + const value = React.useMemo(() => ({ status, onStatusChange: setStatus }), [status]); + + return ( + + + {children} + + + ); +}); + +export type AvatarImageProps = React.ComponentPropsWithRef<'img'>; + +export const AvatarImage = React.forwardRef(function MosaicAvatarImage( + { src, alt = '', className, style, ...rest }, ref, ) { + const { onStatusChange } = useAvatarContext('AvatarImage'); + const status = useImageLoadingStatus(src); + + React.useEffect(() => { + onStatusChange(status); + }, [onStatusChange, status]); + + if (status !== 'loaded') { + return null; + } + + return ( + {alt} + ); +}); + +export interface AvatarFallbackProps extends React.ComponentPropsWithRef<'span'> { + /** Wait this many ms before showing the fallback, to avoid a flash on fast connections. */ + delayMs?: number; +} + +export const AvatarFallback = React.forwardRef(function MosaicAvatarFallback( + { delayMs, className, style, children, ...rest }, + ref, +) { + const { status } = useAvatarContext('AvatarFallback'); + const [canRender, setCanRender] = React.useState(delayMs === undefined); + + React.useEffect(() => { + if (delayMs === undefined) { + return; + } + const timer = setTimeout(() => setCanRender(true), delayMs); + return () => clearTimeout(timer); + }, [delayMs]); + + if (!canRender || status === 'loaded') { + return null; + } + return ( - {src ? ( - {alt} - ) : ( - children - )} + {children} ); }); diff --git a/packages/ui/src/mosaic/components/avatar/index.ts b/packages/ui/src/mosaic/components/avatar/index.ts index 94930a04990..aaa1ed3f91e 100644 --- a/packages/ui/src/mosaic/components/avatar/index.ts +++ b/packages/ui/src/mosaic/components/avatar/index.ts @@ -1,2 +1,2 @@ -export { Avatar } from './avatar'; -export type { AvatarProps } from './avatar'; +export { Avatar, AvatarImage, AvatarFallback } from './avatar'; +export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from './avatar'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 51fd6a0f47f..9e02c87e106 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -4,8 +4,8 @@ // static `styles.css`. Keep it isolated from Emotion/un-migrated code — grow it // as components migrate. -export { Avatar } from '../components/avatar'; -export type { AvatarProps } from '../components/avatar'; +export { Avatar, AvatarImage, AvatarFallback } from '../components/avatar'; +export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from '../components/avatar'; export { Button } from '../components/button'; export type { ButtonProps } from '../components/button'; From 76541d9fe887b3b9ab72d6c0bea17b8418e236e5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 17:28:01 -0400 Subject: [PATCH 3/7] docs(ui): update Avatar changeset for compound API --- .changeset/mosaic-avatar.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mosaic-avatar.md b/.changeset/mosaic-avatar.md index 4f710065226..e08637a2425 100644 --- a/.changeset/mosaic-avatar.md +++ b/.changeset/mosaic-avatar.md @@ -2,4 +2,4 @@ '@clerk/ui': minor --- -Add a new Mosaic `Avatar` component (StyleX) with `shape` (`circle` | `square`) and `size` (`lg` | `md` | `sm` | `xs`) props. Renders an image when `src` is set, otherwise its children as a fallback. +Add a new Mosaic `Avatar` compound component (StyleX). `Avatar` owns `shape` (`circle` | `square`) and `size` (`lg` | `md` | `sm` | `xs`); compose `AvatarImage` (renders once the image loads) and `AvatarFallback` (shown while the image is pending or has failed, with an optional `delayMs`) inside it. From 0a2876c14f5a6696edfe4dfac084eee0f189fe34 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 17:52:45 -0400 Subject: [PATCH 4/7] fix(ui): restore Avatar compound imports in swingset story --- packages/swingset/src/stories/avatar.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx index a5126e2e127..cfc302921d1 100644 --- a/packages/swingset/src/stories/avatar.stories.tsx +++ b/packages/swingset/src/stories/avatar.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @emotion/react */ import type { AvatarProps } from '@clerk/ui/mosaic/components/avatar'; -import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Avatar, AvatarFallback, AvatarImage } from '@clerk/ui/mosaic/components/avatar'; import type { StoryMeta } from '@/lib/types'; From 8f1a8b92e7e2c03ee111a5e51db73d7e421fe55c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 17:55:57 -0400 Subject: [PATCH 5/7] docs(swingset): use Clerk logo in Avatar examples; document import-strip footgun --- .../references/setup-and-footguns.md | 27 ++++++++++--------- packages/swingset/CLAUDE.md | 1 + packages/swingset/src/stories/avatar.mdx | 6 ++--- .../swingset/src/stories/avatar.stories.tsx | 18 ++++++------- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.claude/skills/clerk-monorepo/references/setup-and-footguns.md b/.claude/skills/clerk-monorepo/references/setup-and-footguns.md index 24fd34e46d2..a4a1a73f010 100644 --- a/.claude/skills/clerk-monorepo/references/setup-and-footguns.md +++ b/.claude/skills/clerk-monorepo/references/setup-and-footguns.md @@ -35,19 +35,20 @@ reports here. ## Footguns -| Trap | Symptom | Fix | -| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| System Node instead of 24.15 | Build "passes" but `Cannot find module @clerk/...` or missing types appear later | `node --version`; `nvm use` (`.nvmrc` pins 24.15.0) | -| `pnpm install` before `corepack enable` (or using npm/yarn) | `preinstall` aborts with an "only pnpm allowed" error | `corepack enable`, then `pnpm install` | -| Installing from a package subdirectory | Workspace links incomplete; runtime `Cannot resolve @clerk/shared` | Always `pnpm install` from the repo root | -| `pnpm dev` before `pnpm build` | Watch mode emits broken output; phantom type errors | Run `pnpm build` once first, then `dev` | -| Stale turbo cache after a Node/pnpm change | Old code runs, types do not update, unrelated tests fail | `pnpm nuke` (removes `.turbo`, `node_modules`, `dist`, coverage), then `pnpm install && pnpm build` | -| `pnpm --filter build/test` expecting deps to build | Filtered pnpm scripts skip turbo's `^build`, so deps may be stale | Use `pnpm turbo build/test --filter=@clerk/` to include dependencies | -| Stale `@clerk/shared` / types after editing shared | Type errors that "should not" exist in consumers | `pnpm turbo build --filter=@clerk/shared` | -| Editing the hosted UI but seeing no change | `ui` ships as its own `ui.browser.js` loaded alongside `clerk-js`; you watched the wrong target | Use `pnpm dev:fe-libs` so `ui` + `clerk-js` rebuild together | -| Committing integration secrets | `integration/.env.local`, `.keys.json`, `.keys.staging.json`, or `certs/sessions*.pem` leaked | Generated/fetched locally and gitignored; never add them. Under `certs/` only `sessions.pem` / `sessions-key.pem` are ignored, so keep other cert names out of git | -| Running integration tests without 1Password set up | `pnpm integration:secrets` fails to read from 1Password | Install the `op` CLI and enable desktop-app integration (below) | -| First `pnpm install` "hangs" | Large monorepo, large lockfile | Expected for the first run; give it a few minutes before assuming failure | +| Trap | Symptom | Fix | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| System Node instead of 24.15 | Build "passes" but `Cannot find module @clerk/...` or missing types appear later | `node --version`; `nvm use` (`.nvmrc` pins 24.15.0) | +| `pnpm install` before `corepack enable` (or using npm/yarn) | `preinstall` aborts with an "only pnpm allowed" error | `corepack enable`, then `pnpm install` | +| Installing from a package subdirectory | Workspace links incomplete; runtime `Cannot resolve @clerk/shared` | Always `pnpm install` from the repo root | +| `pnpm dev` before `pnpm build` | Watch mode emits broken output; phantom type errors | Run `pnpm build` once first, then `dev` | +| Stale turbo cache after a Node/pnpm change | Old code runs, types do not update, unrelated tests fail | `pnpm nuke` (removes `.turbo`, `node_modules`, `dist`, coverage), then `pnpm install && pnpm build` | +| `pnpm --filter build/test` expecting deps to build | Filtered pnpm scripts skip turbo's `^build`, so deps may be stale | Use `pnpm turbo build/test --filter=@clerk/` to include dependencies | +| Stale `@clerk/shared` / types after editing shared | Type errors that "should not" exist in consumers | `pnpm turbo build --filter=@clerk/shared` | +| Editing the hosted UI but seeing no change | `ui` ships as its own `ui.browser.js` loaded alongside `clerk-js`; you watched the wrong target | Use `pnpm dev:fe-libs` so `ui` + `clerk-js` rebuild together | +| Committing integration secrets | `integration/.env.local`, `.keys.json`, `.keys.staging.json`, or `certs/sessions*.pem` leaked | Generated/fetched locally and gitignored; never add them. Under `certs/` only `sessions.pem` / `sessions-key.pem` are ignored, so keep other cert names out of git | +| Running integration tests without 1Password set up | `pnpm integration:secrets` fails to read from 1Password | Install the `op` CLI and enable desktop-app integration (below) | +| First `pnpm install` "hangs" | Large monorepo, large lockfile | Expected for the first run; give it a few minutes before assuming failure | +| Adding an `import` in a separate edit before its first use | On-save lint-fix (`unused-imports/no-unused-imports`, an `error` in `eslint.config.mjs`) deletes the not-yet-referenced import; the next edit that adds the usage then throws `X is not defined` at runtime. Common when wiring a new export across files (e.g. swingset `registry.ts` + a `*.stories.tsx`). | Add the import and its first usage in the **same** edit, or add the usage first. After a multi-file wiring change, `grep` the new symbol to confirm both its `import` and its use survived before committing. | ## Unit tests vs integration tests diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index 6e1d253eef5..c4a745a9a78 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -25,6 +25,7 @@ These require reading several files together; the `README.md` covers the step-by - **Knobs are generated from CVA metadata, not hand-written.** A story's `meta.styles` is a Mosaic CVA style object exposing `_variants` / `_defaultVariants`. `lib/generateKnobs.ts` turns each variant into a control: variants whose keys are only `true`/`false` become boolean toggles, everything else becomes a select. Knob values are passed as props straight into the story component. This is why story functions take `Record` and cast to the real prop type. - **`lib/registry.ts` is the single source of truth for which components exist**, and they are imported *explicitly* (never `import *`) so sidebar order is deterministic. `getSidebarGroups`, `getModuleBySlug`, and slugging (`lib/slug.ts`, from `meta.title`) read from it. Adding a component touches up to three wiring points: `registry.ts` (sidebar entry + per-page playground lookup), `DocsViewer.tsx`'s `docModules` map (MDX docs), and the hardcoded redirect in `app/page.tsx`. + - ⚠️ **Add each new import and its first usage in the same edit.** The on-save lint-fix (`unused-imports/no-unused-imports` is an `error`) deletes any import that isn't referenced yet, so importing a story export in `registry.ts` (or a component in a `*.stories.tsx`) *before* the code that uses it silently drops the import and you get `X is not defined` at runtime. After wiring, `grep` the new symbol to confirm both the import and its use survived. (Repo-wide footgun; see `clerk-monorepo` skill `references/setup-and-footguns.md`.) - **Routing.** Each component is a single page: `/components/[component]` renders its MDX overview via `DocsViewer`. There are no per-story sub-pages — the interactive playground lives *inside* the overview. `app/page.tsx` is a static redirect (currently to `/components/button`) because `registry.ts` eagerly imports story modules (Emotion / `createContext`), so registry-derived data can't be computed in a Server Component. `DocsViewer` also renders a "View source" link (`ViewSource.tsx`) from `meta.source` — a repo-root-relative path turned into a GitHub URL by `lib/source.ts`. diff --git a/packages/swingset/src/stories/avatar.mdx b/packages/swingset/src/stories/avatar.mdx index 4b445ba7f0a..12e373e4e0b 100644 --- a/packages/swingset/src/stories/avatar.mdx +++ b/packages/swingset/src/stories/avatar.mdx @@ -24,10 +24,10 @@ import { Avatar, AvatarImage, AvatarFallback } from '@clerk/ui/mosaic/components - CN + CL ; ``` diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx index cfc302921d1..6789da14763 100644 --- a/packages/swingset/src/stories/avatar.stories.tsx +++ b/packages/swingset/src/stories/avatar.stories.tsx @@ -32,16 +32,16 @@ function knobsAsProps(props: Record) { return props as unknown as AvatarProps; } -const IMAGE_SRC = 'https://github.com/shadcn.png'; +const IMAGE_SRC = 'https://github.com/clerk.png'; export function Primary(props: Record) { return ( - CN + CL ); } @@ -51,9 +51,9 @@ export function Fallback(props: Record) { - CN + CL ); } @@ -69,9 +69,9 @@ export function Sizes(props: Record) { > - CN + CL ))} @@ -89,9 +89,9 @@ export function Shapes(props: Record) { > - CN + CL ))} From e443a8e89f7d7ccaadfeb65aba4e4410c16d6ca8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 18:16:20 -0400 Subject: [PATCH 6/7] refactor(ui): use Avatar.Root/.Image/.Fallback namespace API --- .changeset/mosaic-avatar.md | 2 +- packages/swingset/src/stories/avatar.mdx | 24 +++++----- .../swingset/src/stories/avatar.stories.tsx | 34 ++++++------- .../mosaic/components/avatar/avatar.test.tsx | 48 +++++++++---------- .../src/mosaic/components/avatar/avatar.tsx | 22 ++++++--- .../ui/src/mosaic/components/avatar/index.ts | 2 +- packages/ui/src/mosaic/styles/index.ts | 2 +- 7 files changed, 72 insertions(+), 62 deletions(-) diff --git a/.changeset/mosaic-avatar.md b/.changeset/mosaic-avatar.md index e08637a2425..23271d7e496 100644 --- a/.changeset/mosaic-avatar.md +++ b/.changeset/mosaic-avatar.md @@ -2,4 +2,4 @@ '@clerk/ui': minor --- -Add a new Mosaic `Avatar` compound component (StyleX). `Avatar` owns `shape` (`circle` | `square`) and `size` (`lg` | `md` | `sm` | `xs`); compose `AvatarImage` (renders once the image loads) and `AvatarFallback` (shown while the image is pending or has failed, with an optional `delayMs`) inside it. +Add a new Mosaic `Avatar` compound component (StyleX). `Avatar.Root` owns `shape` (`circle` | `square`) and `size` (`lg` | `md` | `sm` | `xs`); compose `Avatar.Image` (renders once the image loads) and `Avatar.Fallback` (shown while the image is pending or has failed, with an optional `delayMs`) inside it. diff --git a/packages/swingset/src/stories/avatar.mdx b/packages/swingset/src/stories/avatar.mdx index 12e373e4e0b..bc17a0c86d0 100644 --- a/packages/swingset/src/stories/avatar.mdx +++ b/packages/swingset/src/stories/avatar.mdx @@ -2,7 +2,7 @@ import * as AvatarStories from './avatar.stories'; # Avatar -Avatar represents a user or entity as an image, falling back to initials or an icon when the image is missing or fails to load. It is a compound component: `Avatar` clips and sizes the box, `Avatar.Image` renders the picture once it loads, and `Avatar.Fallback` shows until then. +Avatar represents a user or entity as an image, falling back to initials or an icon when the image is missing or fails to load. It is a compound component: `Avatar.Root` clips and sizes the box, `Avatar.Image` renders the picture once it loads, and `Avatar.Fallback` shows until then. ## Playground @@ -13,31 +13,31 @@ Avatar represents a user or entity as an image, falling back to initials or an i ## Props -`shape` and `size` live on the `Avatar` root: +`shape` and `size` live on `Avatar.Root`: ## Usage ```tsx -import { Avatar, AvatarImage, AvatarFallback } from '@clerk/ui/mosaic/components/avatar'; +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; - - + - CL -; + CL +; ``` ## Parts -| Part | Slot (`.cl-*`) | Description | -| ---------------- | -------------------- | ---------------------------------------------------------------------- | -| `Avatar` | `cl-avatar` | Root. Owns `shape` / `size`, clips its children to the shape. | -| `AvatarImage` | `cl-avatar-image` | Renders an `` once the source loads; renders nothing until then. | -| `AvatarFallback` | `cl-avatar-fallback` | Rendered while the image is pending or has failed. Optional `delayMs`. | +| Part | Slot (`.cl-*`) | Description | +| ----------------- | -------------------- | ---------------------------------------------------------------------- | +| `Avatar.Root` | `cl-avatar` | Owns `shape` / `size`, clips its children to the shape. | +| `Avatar.Image` | `cl-avatar-image` | Renders an `` once the source loads; renders nothing until then. | +| `Avatar.Fallback` | `cl-avatar-fallback` | Rendered while the image is pending or has failed. Optional `delayMs`. | --- diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx index 6789da14763..7b9fd503772 100644 --- a/packages/swingset/src/stories/avatar.stories.tsx +++ b/packages/swingset/src/stories/avatar.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @emotion/react */ import type { AvatarProps } from '@clerk/ui/mosaic/components/avatar'; -import { Avatar, AvatarFallback, AvatarImage } from '@clerk/ui/mosaic/components/avatar'; +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import type { StoryMeta } from '@/lib/types'; @@ -36,25 +36,25 @@ const IMAGE_SRC = 'https://github.com/clerk.png'; export function Primary(props: Record) { return ( - - + - CL - + CL + ); } export function Fallback(props: Record) { return ( - - + - CL - + CL + ); } @@ -62,17 +62,17 @@ export function Sizes(props: Record) { return (
{(['xs', 'sm', 'md', 'lg'] as const).map(size => ( - - - CL - + CL + ))}
); @@ -82,17 +82,17 @@ export function Shapes(props: Record) { return (
{(['circle', 'square'] as const).map(shape => ( - - - CL - + CL + ))}
); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx index 4c2e3f3a96a..8005d8747f2 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Avatar, AvatarFallback, AvatarImage } from './avatar'; +import { Avatar } from './avatar'; // jsdom never fires load/error on images, so drive `new window.Image()` manually. // Each instance resolves to the outcome keyed by its `src`. @@ -32,9 +32,9 @@ afterEach(() => { describe('Mosaic Avatar', () => { it('applies default variants and reflects them as data attributes', () => { render( - - CN - , + + CN + , ); const avatar = screen.getByTestId('avatar'); expect(avatar).toHaveClass('cl-avatar'); @@ -44,13 +44,13 @@ describe('Mosaic Avatar', () => { it('reflects shape and size overrides', () => { render( - - CN - , + CN + , ); const avatar = screen.getByTestId('avatar'); expect(avatar).toHaveAttribute('data-shape', 'square'); @@ -60,10 +60,10 @@ describe('Mosaic Avatar', () => { it('shows the fallback while the image has not loaded', () => { outcomes['https://example.com/a.png'] = 'error'; render( - - - CN - , + + + CN + , ); const fallback = screen.getByText('CN'); expect(fallback).toHaveClass('cl-avatar-fallback'); @@ -73,13 +73,13 @@ describe('Mosaic Avatar', () => { it('renders the image and hides the fallback once it loads', async () => { outcomes['https://example.com/a.png'] = 'load'; render( - - + - CN - , + CN + , ); const img = await screen.findByRole('img', { name: 'Alex' }); expect(img).toHaveClass('cl-avatar-image'); @@ -90,10 +90,10 @@ describe('Mosaic Avatar', () => { it('keeps the fallback when the image errors', async () => { outcomes['https://example.com/bad.png'] = 'error'; render( - - - CN - , + + + CN + , ); await waitFor(() => expect(screen.getByText('CN')).toBeInTheDocument()); expect(screen.queryByRole('img')).not.toBeInTheDocument(); @@ -102,14 +102,14 @@ describe('Mosaic Avatar', () => { it('wires consumer className/style/ref through to the root', () => { const ref = React.createRef(); render( - - CN - , + CN + , ); const avatar = screen.getByTestId('avatar'); expect(ref.current).toBe(avatar); @@ -117,9 +117,9 @@ describe('Mosaic Avatar', () => { expect(avatar).toHaveStyle({ marginTop: '8px' }); }); - it('throws when a part is rendered outside ', () => { + it('throws when a part is rendered outside ', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render(CN)).toThrow(/must be rendered inside /); + expect(() => render(CN)).toThrow(/must be rendered inside /); spy.mockRestore(); }); }); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.tsx b/packages/ui/src/mosaic/components/avatar/avatar.tsx index 45e24b41be8..f9087dbf899 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.tsx @@ -16,7 +16,7 @@ const AvatarContext = React.createContext(null); function useAvatarContext(part: string): AvatarContextValue { const context = React.useContext(AvatarContext); if (!context) { - throw new Error(`<${part}> must be rendered inside `); + throw new Error(`<${part}> must be rendered inside `); } return context; } @@ -58,7 +58,7 @@ export interface AvatarProps extends React.ComponentPropsWithRef<'span'> { size?: 'lg' | 'md' | 'sm' | 'xs'; } -export const Avatar = React.forwardRef(function MosaicAvatar( +const AvatarRoot = React.forwardRef(function MosaicAvatarRoot( { shape = 'circle', size = 'md', className, style, children, ...rest }, ref, ) { @@ -85,11 +85,11 @@ export const Avatar = React.forwardRef(function Mo export type AvatarImageProps = React.ComponentPropsWithRef<'img'>; -export const AvatarImage = React.forwardRef(function MosaicAvatarImage( +const AvatarImage = React.forwardRef(function MosaicAvatarImage( { src, alt = '', className, style, ...rest }, ref, ) { - const { onStatusChange } = useAvatarContext('AvatarImage'); + const { onStatusChange } = useAvatarContext('Avatar.Image'); const status = useImageLoadingStatus(src); React.useEffect(() => { @@ -116,11 +116,11 @@ export interface AvatarFallbackProps extends React.ComponentPropsWithRef<'span'> delayMs?: number; } -export const AvatarFallback = React.forwardRef(function MosaicAvatarFallback( +const AvatarFallback = React.forwardRef(function MosaicAvatarFallback( { delayMs, className, style, children, ...rest }, ref, ) { - const { status } = useAvatarContext('AvatarFallback'); + const { status } = useAvatarContext('Avatar.Fallback'); const [canRender, setCanRender] = React.useState(delayMs === undefined); React.useEffect(() => { @@ -145,3 +145,13 @@ export const AvatarFallback = React.forwardRef ); }); + +/** + * Compound avatar. `Avatar.Root` clips and sizes the box; `Avatar.Image` renders + * once its source loads; `Avatar.Fallback` shows until then. + */ +export const Avatar = { + Root: AvatarRoot, + Image: AvatarImage, + Fallback: AvatarFallback, +}; diff --git a/packages/ui/src/mosaic/components/avatar/index.ts b/packages/ui/src/mosaic/components/avatar/index.ts index aaa1ed3f91e..236ba032f57 100644 --- a/packages/ui/src/mosaic/components/avatar/index.ts +++ b/packages/ui/src/mosaic/components/avatar/index.ts @@ -1,2 +1,2 @@ -export { Avatar, AvatarImage, AvatarFallback } from './avatar'; +export { Avatar } from './avatar'; export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from './avatar'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 9e02c87e106..a7a644bc465 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -4,7 +4,7 @@ // static `styles.css`. Keep it isolated from Emotion/un-migrated code — grow it // as components migrate. -export { Avatar, AvatarImage, AvatarFallback } from '../components/avatar'; +export { Avatar } from '../components/avatar'; export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from '../components/avatar'; export { Button } from '../components/button'; export type { ButtonProps } from '../components/button'; From dbf4351d0d8d2fda3ff0dea2a0f06e87b0694caf Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 19:52:04 -0400 Subject: [PATCH 7/7] refactor(ui): adopt mergeStyleProps rename in Avatar --- packages/ui/src/mosaic/components/avatar/avatar.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/components/avatar/avatar.tsx b/packages/ui/src/mosaic/components/avatar/avatar.tsx index f9087dbf899..9496458966d 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.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 './avatar.styles'; type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; @@ -69,7 +69,7 @@ const AvatarRoot = React.forwardRef(function Mosai (functio ref={ref} src={src} alt={alt} - {...mergeProps(themeProps('avatar-image'), stylex.props(styles.image), className, style)} + {...mergeStyleProps(themeProps('avatar-image'), stylex.props(styles.image), className, style)} {...rest} /> ); @@ -138,7 +138,7 @@ const AvatarFallback = React.forwardRef(fu return ( {children}