---
name: components
description: Use when implementing a UI component in any app consuming `@medalsocial/meda` — building a card, dialog, status pill, empty state, dropdown, tooltip — or picking a primitive vs a shadcn-style adapter vs a domain folder. Required reading before generating any new visual code; the `meda-storybook` MCP server is the source of truth for what already exists.
---

# Meda Components

## When to load this skill

- About to author or modify a React component that renders UI.
- Choosing where a new component lives (primitive / ui-adapter / domain / recipe).
- Composing `Card`, `EmptyState`, `StatusPill`, or another exported primitive.
- Picking between primitives and shadcn-style ui adapters.
- Writing or updating a `*.stories.tsx`.
- Importing `MarkdownView` or another optional-peer-deps component.

## Look up first, generate second

**Before writing any new visual component, query the `meda-storybook` MCP server.** It exposes the running Storybook (`pnpm storybook` on `http://localhost:6006`) and is the authoritative index of what already exists. Many "new" components are existing primitives composed differently.

If the MCP is not running, start it:

```bash
pnpm storybook
```

The MCP is registered in `.mcp.json`.

## Where things live

| Folder | What it is |
|---|---|
| `src/primitives/` | Standalone visual building blocks. Each owns its own styling. |
| `src/components/ui/` | shadcn-style adapters that wrap `@base-ui/react`. |
| `src/shell/` | Layout regions — see the `shell` skill. |
| `src/{chat,kanban,calendar,timeline,marketing,workflow-builder,...}/` | Domain folders for higher-level composed surfaces. |
| `src/recipes/` | Framework-specific glue (e.g. `recipes/next.ts`). |
| `src/lib/` | Shared utilities (`cn()`, etc.). |

**Don't invent a new top-level layer** (primitives/ui/recipes). Adding a new *domain* folder for a real product area is fine; check with a maintainer first if uncertain.

## Primitive vs ui-adapter vs domain — picking where to add

| If you need… | Reach for | Example |
|---|---|---|
| A self-contained visual element with no behavior dependency | `src/primitives/` | `Card`, `StatusPill`, `EmptyState`, `Skeleton`, `FilterRail` |
| An overlay / menu / form control wrapping `@base-ui/react` | `src/components/ui/` | `DropdownMenu`, `Dialog`, `Drawer`, `Tooltip`, `Command`, `Checkbox`, `Collapsible` |
| A composed surface tied to a product area | A domain folder | A chat thread (`src/chat/`), a kanban column (`src/kanban/`) |
| Framework-specific glue | `src/recipes/` | `recipes/next.ts` |

## Primitives layer

**`@base-ui/react` is the canonical primitives layer.** Forbidden in new code: `@radix-ui/*` packages. shadcn-style adapters in `src/components/ui/` wrap `@base-ui/react` — follow the existing patterns in `dropdown-menu.tsx` / `tooltip.tsx`.

## Card — compound component

```tsx
import { Card } from '@medalsocial/meda';

<Card>
  <Card.Header>
    <h3 className="text-h4">Workspace usage</h3>
  </Card.Header>
  <Card.Body>
    Body content here.
  </Card.Body>
  <Card.Footer>
    <Button variant="ghost">Cancel</Button>
    <Button>Save</Button>
  </Card.Footer>
</Card>
```

`Card`, `Card.Header`, `Card.Body`, `Card.Footer` are all `<div>`-typed and accept any standard div props. The header/footer auto-hide their borders when adjacent to the body (`last:border-b-0` / `first:border-t-0`), so a header-only or body-only card has no orphan border. Always use the subcomponents — don't reach into the card with raw padded `<div>`s.

## EmptyState — variants matter

```tsx
import { EmptyState } from '@medalsocial/meda';
import { Inbox } from 'lucide-react';

<EmptyState
  icon={Inbox}
  title="No messages yet"
  description="When someone messages your workspace, it'll show up here."
  action={<Button>Invite teammates</Button>}
  variant="panel"
/>
```

Variants:
- `'default'` — full-page empty state, generous vertical spacing.
- `'panel'` — panel-sized empty state (e.g. inside a right panel or card).
- `'inline'` — compact, for list-row or table-row empty states.

The `icon` prop accepts a Lucide component (`Inbox`) OR a rendered element (custom inline SVG). Lucide components are auto-sized; rendered elements are placed in an `inline-flex` span and you control their size.

## StatusPill — solid tones, two sizes

```tsx
import { StatusPill } from '@medalsocial/meda';

<StatusPill tone="success">Synced</StatusPill>
<StatusPill tone="warning" size="md">Action required</StatusPill>
<StatusPill tone="danger" dot={false}>Failed</StatusPill>
```

- `tone`: `'neutral' | 'info' | 'success' | 'warning' | 'danger'`
- `size`: `'sm'` (default, 11px text) or `'md'` (12px text)
- `dot`: `true` (default) to show a leading dot

**Why solid backgrounds (not tinted):** the 11px small size needs ≥4.5:1 contrast to pass WCAG AA. Tinted backgrounds (`bg-info/15 text-info`) fail axe gates at this size. Solid `bg-info text-info-foreground` etc. always passes via the theme tokens. If you need a tinted variant, use it at `size="md"` or larger only, and verify with `vitest-axe`.

## ui-adapters — NOT exported from the npm package

shadcn-style adapters in `src/components/ui/` (`Dialog`, `DropdownMenu`, `Tooltip`, `Drawer`, `Command`, `Checkbox`, `Collapsible`) wrap `@base-ui/react`. **They are intentionally NOT exported from `@medalsocial/meda`** — meda follows the shadcn philosophy: you own the ui code.

Two supported ways to consume them:

1. **Vendor via the meda shadcn registry** (recommended for shell-consistent apps). meda publishes a registry at `https://meda.medalsocial.com/r/...` — install via the shadcn CLI:

   ```bash
   npx shadcn@latest add https://meda.medalsocial.com/r/<component>.json
   ```

   The components land in your app's `@/ui` folder (configurable via your `components.json` aliases). You own and can modify the code.

2. **Write your own wrapping `@base-ui/react` directly.** When you need a primitive meda doesn't provide a recipe for, follow the patterns visible in meda's `src/components/ui/dropdown-menu.tsx` / `tooltip.tsx` and ship the result in your own `@/ui` folder.

Do NOT try `import { Dialog } from '@medalsocial/meda'` — it will fail with a missing-export error.

## Optional peer dependencies — MarkdownView pattern

`MarkdownView` has optional peers (`react-markdown`, `remark-gfm`, `rehype-highlight`). It is **intentionally not re-exported from `src/primitives/index.ts`** — even type re-exports cause TS to follow the chain and break consumers who haven't installed the peers. Import via the dedicated subpath:

```ts
import { MarkdownView, type MarkdownViewProps } from '@medalsocial/meda/markdown-view';
```

When you add another optional-peer component, follow this pattern: own subpath, no root re-export, peers declared as `peerDependenciesMeta.*.optional`.

## Storybook discipline

**Every exported component has a `*.stories.tsx`.** New components without stories fail review.

Stories live next to the component (`card.tsx` + `card.stories.tsx`). Use the `__stories__/` folder only for cross-component demos (full shell stories, theme-switcher demos).

Visual snapshots run through Chromatic on every PR.

## Icons

**Lucide React only.** No other icon libraries in new code. Never use emojis as UI icons. Custom-drawn icons require explicit review.

Shell sizes: 22px (rails), 16px (header), 14px (inline). Inside primitives like `EmptyState`, the icon auto-sizes to match the variant.

## className composition

Do not use `[...].join(' ')` or template-string concatenation for classes — noisy, merge-unsafe.

meda uses a `cn()` helper internally (`src/lib/utils.ts`) built from `clsx` + `tailwind-merge`, but it is **not exported from the package**. In consumer apps, declare your own — first install the two deps explicitly (don't rely on them being transitive — that's not safe under pnpm strict, Yarn PnP, or any layout that hides un-declared dependencies):

```bash
pnpm add clsx tailwind-merge
```

```ts
// your-app/src/lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}
```

```tsx
import { cn } from '@/lib/utils';

<div className={cn('base classes', isActive && 'active', className)} />
```

## Anti-patterns

| Anti-pattern | Why it's wrong | Correct approach |
|---|---|---|
| Generating UI without checking Storybook first | Duplicates existing primitives | Query `meda-storybook` MCP first |
| `@radix-ui/*` in new code | Deprecated primitives layer | Wrap `@base-ui/react` in `src/components/ui/` |
| Re-exporting `MarkdownView` from root | Breaks consumers without optional peers | Import from `@medalsocial/meda/markdown-view` |
| Tinted `StatusPill` at `size="sm"` | Fails WCAG AA at 11px (vitest-axe gate) | Use solid tones at `sm`; tints only at `md`+ with a11y verification |
| Raw `<div>` inside `<Card>` with custom padding | Bypasses border-collapse logic; off-grid spacing | Use `Card.Header` / `Card.Body` / `Card.Footer` |
| `import { Dialog } from '@medalsocial/meda'` | Not exported — meda follows the shadcn "you own the ui" model | Vendor via `npx shadcn@latest add https://meda.medalsocial.com/r/...` or write your own wrapping `@base-ui/react` |
| `import { cn } from '@medalsocial/meda'` | Not exported | Declare your own three-line `cn()` using `clsx` + `tailwind-merge` (already transitive) |
| Adding emojis as UI icons | Inconsistent sizing + brand | Use Lucide equivalents (`Clock`, `Check`, `AlertTriangle`) |
| Inventing a new top-level layer folder | Loses the established taxonomy | Use primitives / ui / domain / recipes |
| Skipping `*.stories.tsx` for an exported component | Breaks the lookup-first discipline for the next contributor | Always ship a story |
| `[...].join(' ')` for classes | Error-prone | Use `cn()` |
