diff --git a/.changeset/mosaic-avatar.md b/.changeset/mosaic-avatar.md new file mode 100644 index 00000000000..23271d7e496 --- /dev/null +++ b/.changeset/mosaic-avatar.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +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/.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/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..c2f25e897b1 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 { + Fallback as AvatarFallbackStory, + 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, + Fallback: AvatarFallbackStory, + 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..bc17a0c86d0 --- /dev/null +++ b/packages/swingset/src/stories/avatar.mdx @@ -0,0 +1,67 @@ +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.Root` clips and sizes the box, `Avatar.Image` renders the picture once it loads, and `Avatar.Fallback` shows until then. + +## Playground + + + +## Props + +`shape` and `size` live on `Avatar.Root`: + + + +## Usage + +```tsx +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; + + + + CL +; +``` + +## Parts + +| 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`. | + +--- + +## Examples + +### Fallback + +When the image fails to load, the fallback stays visible. + + + +### 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..7b9fd503772 --- /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://github.com/clerk.png'; + +export function Primary(props: Record) { + return ( + + + CL + + ); +} + +export function Fallback(props: Record) { + return ( + + + CL + + ); +} + +export function Sizes(props: Record) { + return ( +
+ {(['xs', 'sm', 'md', 'lg'] as const).map(size => ( + + + CL + + ))} +
+ ); +} + +export function Shapes(props: Record) { + return ( +
+ {(['circle', 'square'] as const).map(shape => ( + + + CL + + ))} +
+ ); +} 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..790123371da --- /dev/null +++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts @@ -0,0 +1,52 @@ +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', + boxSizing: 'border-box', + display: 'inline-flex', + flexShrink: 0, + fontFamily: 'inherit', + fontWeight: typeScaleVars['--cl-text-label-weight'], + justifyContent: 'center', + 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 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'] }, + sizeLg: { fontSize: '1rem', height: space['12'], width: space['12'] }, + + // 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 new file mode 100644 index 00000000000..8005d8747f2 --- /dev/null +++ b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx @@ -0,0 +1,125 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +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`. +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 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'); + }); + + it('reflects shape and size overrides', () => { + render( + + CN + , + ); + const avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveAttribute('data-shape', 'square'); + expect(avatar).toHaveAttribute('data-size', 'lg'); + }); + + it('shows the fallback while the image has not loaded', () => { + outcomes['https://example.com/a.png'] = 'error'; + render( + + + 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 = await screen.findByRole('img', { name: 'Alex' }); + expect(img).toHaveClass('cl-avatar-image'); + expect(img).toHaveAttribute('src', 'https://example.com/a.png'); + expect(screen.queryByText('CN')).not.toBeInTheDocument(); + }); + + it('keeps the fallback when the image errors', async () => { + outcomes['https://example.com/bad.png'] = 'error'; + render( + + + CN + , + ); + await waitFor(() => expect(screen.getByText('CN')).toBeInTheDocument()); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('wires consumer className/style/ref through to the root', () => { + const ref = React.createRef(); + render( + + CN + , + ); + const avatar = screen.getByTestId('avatar'); + expect(ref.current).toBe(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 new file mode 100644 index 00000000000..9496458966d --- /dev/null +++ b/packages/ui/src/mosaic/components/avatar/avatar.tsx @@ -0,0 +1,157 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { mergeStyleProps, themeProps } from '../../props'; +import { styles } from './avatar.styles'; + +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 = { + xs: styles.sizeXs, + sm: styles.sizeSm, + md: styles.sizeMd, + lg: styles.sizeLg, +} as const; + +export interface AvatarProps extends React.ComponentPropsWithRef<'span'> { + shape?: 'circle' | 'square'; + size?: 'lg' | 'md' | 'sm' | 'xs'; +} + +const AvatarRoot = React.forwardRef(function MosaicAvatarRoot( + { 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'>; + +const AvatarImage = React.forwardRef(function MosaicAvatarImage( + { src, alt = '', className, style, ...rest }, + ref, +) { + const { onStatusChange } = useAvatarContext('Avatar.Image'); + 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; +} + +const AvatarFallback = React.forwardRef(function MosaicAvatarFallback( + { delayMs, className, style, children, ...rest }, + ref, +) { + const { status } = useAvatarContext('Avatar.Fallback'); + 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 ( + + {children} + + ); +}); + +/** + * 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 new file mode 100644 index 00000000000..236ba032f57 --- /dev/null +++ b/packages/ui/src/mosaic/components/avatar/index.ts @@ -0,0 +1,2 @@ +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 f1716fae927..a7a644bc465 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, AvatarImageProps, AvatarFallbackProps } from '../components/avatar'; export { Button } from '../components/button'; export type { ButtonProps } from '../components/button';