diff --git a/apps/web/docs/components-ui.md b/apps/web/docs/components-ui.md
new file mode 100644
index 0000000..b44cc15
--- /dev/null
+++ b/apps/web/docs/components-ui.md
@@ -0,0 +1,242 @@
+# UI Primitives (`apps/web/src/components/ui/`)
+
+This document covers the shared UI primitives in `apps/web/src/components/ui/`: `Avatar`, `Badge`, `CopyButton`, `EmptyState`, `Modal`, `ProposalCard`, `SkeletonLoader`, and `Spinner`. For each component: its props, a usage example, whether it's presentational or stateful, any accessibility behavior implemented, and current test coverage. Test coverage was verified by grepping the whole repo for each component name inside `*.test.*` files — `ProposalCard.test.tsx` is the only test file found for any of these eight components.
+
+---
+
+## Avatar
+
+`apps/web/src/components/ui/Avatar.tsx`
+
+**Props**
+
+| Name | Type | Required | Default |
+|---|---|---|---|
+| `src` | `string` | No | `undefined` |
+| `fallback` | `string` | Yes | — |
+| `size` | `'sm' \| 'md' \| 'lg'` (maps to 24 / 36 / 48px) | Yes | — |
+| `online` | `boolean` | No | `undefined` (falsy) |
+
+`fallback` is also used to derive initials (via `getInitials`) and a deterministic background color (via a simple string hash → HSL hue) when no image is shown.
+
+**Usage**
+
+```tsx
+
+```
+
+**Type**: Stateful. Uses `useState` to track a `failedSrc` (so that if the image `onError`s, it falls back to the initials avatar instead of retrying), and `useMemo` to compute initials/color.
+
+**Accessibility**: The outer wrapper has `aria-label="Avatar for {fallback}"`. When an image is shown, its `alt` text is the same `ariaLabel`. The optional online indicator dot has its own `aria-label="Online"`.
+
+**Test coverage**: No test file today.
+
+---
+
+## Badge
+
+`apps/web/src/components/ui/Badge.tsx`
+
+**Props**
+
+| Name | Type | Required | Default |
+|---|---|---|---|
+| `variant` | `'default' \| 'success' \| 'warning' \| 'danger'` | No | `'default'` |
+| `children` | `React.ReactNode` | Yes | — |
+| `className` | `string` | No | `undefined` |
+
+**Usage**
+
+```tsx
+Active
+```
+
+**Type**: Presentational. A pure function component with no state, effects, or refs — just a `` with variant-based Tailwind classes.
+
+**Accessibility**: None implemented beyond it being a plain inline text element (no `role` or `aria-*` attributes).
+
+**Test coverage**: No test file today.
+
+---
+
+## CopyButton
+
+`apps/web/src/components/ui/CopyButton.tsx`
+
+**Props**
+
+| Name | Type | Required | Default |
+|---|---|---|---|
+| `value` | `string` | Yes | — |
+| `className` | `string` | No | `''` |
+
+**Usage**
+
+```tsx
+
+```
+
+**Type**: Stateful. Uses `useState` to track a `copied` boolean.
+
+**Accessibility / copied feedback**: The button's `aria-label` and `title` swap between `"Copy address"` / `"Copy to clipboard"` and `"Copied address"` / `"Copied!"` based on the `copied` state. Clicking calls `navigator.clipboard.writeText(value)`; on success it sets `copied` to `true` and shows an animated checkmark icon (via `framer-motion`'s `AnimatePresence`, cross-fading from a Copy icon to a Check icon) in place of the copy icon. The `copied` state is reset back to `false` after **2000ms** (`setTimeout`), reverting to the copy icon/labels. While `copied` is `true`, clicking again is a no-op (`if (copied) return`). Clicks call `e.stopPropagation()` so the button can sit inside a clickable parent (e.g. a card) without triggering the parent's click handler. Clipboard failures are silently swallowed (empty `catch`).
+
+**Test coverage**: No test file today.
+
+---
+
+## EmptyState
+
+`apps/web/src/components/ui/EmptyState.tsx`
+
+**Props**
+
+| Name | Type | Required | Default |
+|---|---|---|---|
+| `icon` | `string` | Yes | — |
+| `title` | `string` | Yes | — |
+| `description` | `string` | Yes | — |
+| `action` | `{ label: string; onClick: () => void }` | No | `undefined` |
+
+**Usage**
+
+```tsx
+
+```
+
+**Type**: Presentational. Pure function component, no state or effects.
+
+**Accessibility**: The icon `
` is marked `aria-hidden="true"` since it's decorative; the title/description are rendered as a semantic `