# Shared Components (v2)

### NEW — no v1 equivalent

Reusable component specifications used across multiple pages. Each component
includes: description, props/API, ASCII mockups of all states, usage locations,
and proposed source file path.

---

## 1. `<SaveIndicator>`

Inline status indicator for auto-save operations. Shows current save state
with icon + label, auto-hides on success.

### Props

```ts
interface SaveIndicatorProps {
  status: 'idle' | 'saving' | 'saved' | 'error';
  onRetry?: () => void;
  /**
   * When true, "saved" state displays a persistent timestamp ("Saved at 3:42 PM")
   * instead of fading out after 2s. Use for high-stakes contexts like page content
   * editing where the user needs ongoing confirmation that their work is safe.
   * Default: false (fade-out behavior).
   */
  persistent?: boolean;
}
```

### States

```
Idle:     (hidden — renders nothing)

Saving:   [spinner] Saving...

Saved:    [v] Saved              <- fades out after 2s, transitions to idle

Error:    [!] Failed to save [retry]
```

### Detailed ASCII

**Saving**
```
+---------------------------+
|  [spinner] Saving...      |
+---------------------------+
```
- `text-muted-foreground text-sm`
- Spinner is a 14px animated SVG

**Saved (default: `persistent=false`)**
```
+---------------------------+
|  [v] Saved                |
+---------------------------+
```
- `text-green-600 text-sm`
- Checkmark icon, 14px
- Fades to `opacity-0` over 300ms after 2s delay, then sets status to `idle`

**Saved (persistent mode: `persistent=true`)**
```
+---------------------------+
|  [v] Saved at 3:42 PM    |
+---------------------------+
```
- Same styling as default saved state, but does NOT fade out
- Timestamp updates on each save, formatted via `Intl.DateTimeFormat` with `hour`/`minute`
- Remains visible until next `saving` or `error` transition

**Error**
```
+--------------------------------------+
|  [!] Failed to save  [retry]        |
+--------------------------------------+
```
- `text-destructive text-sm`
- Alert icon, 14px
- `[retry]` is a text button: `text-destructive underline cursor-pointer`
- Does not auto-hide — persists until retry succeeds or user navigates away

### Usage

| Page | Location | Persistent? |
|------|----------|-------------|
| Content tab | Top-right of Tiptap editor toolbar (see content-tab.md) | Yes — page content is high-stakes |
| Plan tab | Top-right of phase description editor (see plan-tab.md) | No — phase descriptions are lightweight |

### Source

- `packages/web/src/components/SaveIndicator.tsx` (proposed)

---

## 2. `<EmptyState>`

Centered placeholder shown when a list or section has no items. Configurable
icon, messaging, and optional CTA button.

### Props

```ts
interface EmptyStateProps {
  icon: React.ReactNode;       // e.g., <Inbox />, <ListTodo />, <Users />
  title: string;               // e.g., "No initiatives yet"
  description?: string;        // e.g., "Create an initiative to get started."
  action?: {
    label: string;             // e.g., "Plan with Agent"
    onClick: () => void;
    icon?: React.ReactNode;    // e.g., <Sparkles /> — rendered before label
  };
  secondaryAction?: {
    label: string;             // e.g., "Add Phase"
    onClick: () => void;
    icon?: React.ReactNode;    // e.g., <Plus />
  };
}
```

When both `action` and `secondaryAction` are provided, the primary action
renders as `<Button variant="default">` and the secondary as
`<Button variant="outline">`, side by side with `gap-3`.

### Default (with action)

```
+-------------------------------------------+
|                                           |
|              [icon]                       |
|         No phases yet                     |
|   Add a phase to start planning.          |
|                                           |
|          [ Add Phase ]                    |
|                                           |
+-------------------------------------------+
```

### Without action

```
+-------------------------------------------+
|                                           |
|              [icon]                       |
|       No conversations yet                |
|   Questions from agents appear here.      |
|                                           |
+-------------------------------------------+
```

### With primary + secondary action

```
+-------------------------------------------+
|                                           |
|              [icon]                       |
|         No phases yet                     |
|   Add a phase to start planning,          |
|   or let an agent plan for you.           |
|                                           |
|   [sparkles Plan with Agent]  [+ Add Phase]|
|                                           |
+-------------------------------------------+
```
- Primary: `<Button variant="default">` (filled indigo)
- Secondary: `<Button variant="outline">` (border only)
- Container: `flex gap-3 justify-center`

### Without description

```
+-------------------------------------------+
|                                           |
|              [icon]                       |
|         No agents running                 |
|                                           |
+-------------------------------------------+
```

### Styling

- Container: `flex flex-col items-center justify-center py-16 text-center`
- Icon: `h-12 w-12 text-muted-foreground mb-4`
- Title: `text-lg font-medium text-foreground mb-1`
- Description: `text-sm text-muted-foreground mb-6`
- Action button: default shadcn `<Button>` variant

### Usage

| Page | Title | Primary Action | Secondary Action |
|------|-------|----------------|------------------|
| Initiatives list (no data) | "No initiatives yet" | "New Initiative" | -- |
| Initiatives list (no match) | "No matching initiatives" | -- (text link instead) | -- |
| Plan tab (no phases) | "No phases yet" | "Plan with Agent" | "Add Phase" |
| Execution tab (no tasks) | "No tasks to execute" | "Go to Plan Tab" | -- |
| Agents page (no agents) | "No agents running" | -- | -- |
| Inbox page (no conversations) | "No conversations yet" | -- | -- |
| Settings > Accounts (none) | "No accounts registered" | "Add Account" | -- |

### Source

- `packages/web/src/components/EmptyState.tsx` (proposed)

---

## 3. `<ErrorState>`

Centered error display with optional retry action. Used when a data fetch
fails or a section encounters an unrecoverable error.

### Props

```ts
interface ErrorStateProps {
  message: string;             // e.g., "Failed to load initiatives"
  onRetry?: () => void;        // If provided, shows Retry button
  details?: string;            // Technical error detail (stack trace, API response)
  errorCode?: string;          // e.g., "TRPC_TIMEOUT", "NETWORK_ERROR"
}
```

### With retry

```
+-------------------------------------------+
|                                           |
|           [AlertCircle]                   |
|      Failed to load initiatives           |
|                                           |
|            [ Retry ]                      |
|                                           |
+-------------------------------------------+
```

### Without retry (navigation fallback)

```
+-------------------------------------------+
|                                           |
|           [AlertCircle]                   |
|      Error loading initiative             |
|   Could not fetch initiative data.        |
|                                           |
|       [ Back to Initiatives ]             |
|                                           |
+-------------------------------------------+
```

### With details + error code (expandable)

```
+-------------------------------------------+
|                                           |
|           [AlertCircle]                   |
|      Failed to load initiatives           |
|      Error: TRPC_TIMEOUT                  |
|                                           |
|            [ Retry ]                      |
|                                           |
|      [v] Show details                     |
|      +-----------------------------------+|
|      | TRPCClientError: Request timed    ||
|      | out after 10000ms                 ||
|      |   at fetchQuery (trpc-client.ts:  ||
|      |   42:11)                          ||
|      +-----------------------------------+|
|                                           |
+-------------------------------------------+
```
- `Error: <code>` — `text-sm text-muted-foreground font-mono`, shown below the message when `errorCode` is provided
- `[v] Show details` — collapsible trigger, `text-xs text-muted-foreground cursor-pointer`
- Details panel: `bg-muted rounded-md p-3 font-mono text-xs text-muted-foreground max-h-32 overflow-y-auto`
- Details are collapsed by default — click to expand. Uses a `<details>/<summary>` element or state toggle.
- When `details` prop is absent, the collapsible section is not rendered at all.

Note: The "without retry" variant is not a prop configuration of `<ErrorState>`
itself. Pages that need navigation instead of retry render `<ErrorState>`
without `onRetry` and add their own navigation button below it.

### Styling

- Container: `flex flex-col items-center justify-center py-16 text-center`
- Icon: `h-12 w-12 text-destructive mb-4` (AlertCircle from lucide-react)
- Message: `text-lg font-medium text-foreground mb-4`
- Retry button: `<Button variant="outline">Retry</Button>`

### Usage

| Page | Message | Has Retry? |
|------|---------|------------|
| Initiatives list | "Failed to load initiatives" | Yes |
| Initiative detail | "Error loading initiative" | No (nav button) |
| Review tab | "Failed to load proposals" | Yes |
| Agents page | "Failed to load agents" | Yes |
| Settings page | "Failed to load settings" | Yes |

### Source

- `packages/web/src/components/ErrorState.tsx` (proposed)

---

## 4. `<CommandPalette>`

Global search overlay triggered by keyboard shortcut. Provides quick
navigation to initiatives, agents, tasks, and settings pages.

### Trigger

- `Cmd+K` (Mac) / `Ctrl+K` (Windows)
- Header button `[cmd-k]` (see app-layout.md)

### Props

```ts
interface CommandPaletteProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
}
```

Internally fetches data via tRPC queries (initiatives, agents, tasks).

### Default State (open, empty query)

```
+----------------------------------------------------------+
|  [search] Search everything...                     [Esc] |
|  --------------------------------------------------------|
|  Recent                                                  |
|    -> Auth System Overhaul                               |
|    -> Agents                                             |
|    -> Settings                                           |
+----------------------------------------------------------+
```

Shows recent navigation history when search is empty.

### With Search Results

```
+----------------------------------------------------------+
|  [search] auth___                                  [Esc] |
|  --------------------------------------------------------|
|  Initiatives                                             |
|    Auth System Overhaul                         [ACTIVE] |
|  --------------------------------------------------------|
|  Tasks                                                   |
|    Implement auth middleware             [execute] 3/7   |
|    Review auth token flow               [verify]  done  |
|  --------------------------------------------------------|
|  Agents                                                  |
|    blue-fox-7 (auth middleware)               [RUNNING]  |
+----------------------------------------------------------+
```

### No Results

```
+----------------------------------------------------------+
|  [search] xyznonexistent___                        [Esc] |
|  --------------------------------------------------------|
|                                                          |
|              No results for "xyznonexistent"             |
|                                                          |
|          Try a shorter query, or browse:                 |
|          -> All Initiatives                              |
|          -> All Agents                                   |
|          -> Settings                                     |
|                                                          |
+----------------------------------------------------------+
```

- "Try a shorter query, or browse:" — `text-sm text-muted-foreground`
- Fallback navigation links are always the same three static items
- Clicking a fallback link navigates and closes the palette

### Keyboard Navigation

```
  [search] auth___                                  [Esc]
  ---------------------------------------------------------
  Initiatives
  > Auth System Overhaul                         [ACTIVE]   <- highlighted
  ---------------------------------------------------------
  Tasks
    Implement auth middleware             [execute] 3/7
```

- `>` indicates the currently highlighted item
- `Arrow Up` / `Arrow Down` — move highlight
- `Enter` — navigate to highlighted item, close palette
- `Esc` — close palette
- Type to filter — results update as you type (debounced 100ms)

### Result Groups

| Group | Source | Display |
|-------|--------|---------|
| Initiatives | `listInitiatives` query | Name + status badge |
| Tasks | `listTasks` query (across initiatives) | Name + category + status |
| Agents | `listAgents` query | Name + task description + status badge |
| Pages | `Go to Initiatives`, `Go to Agents`, etc. | Static navigation items |

Groups are hidden when they have 0 matches. Max 5 items per group.
Maximum total visible results: 20 (across all groups combined).

### Recent Items

Recent navigation history is stored in `sessionStorage` key `cw-cmd-recent`
as an ordered array of `{ type, id, label, path }` objects (max 10 entries,
LRU eviction). Navigating to any entity via the palette pushes it to the
front. The "Recent" section only appears when the search query is empty.

```
  Recent                                   [clock icon per row]
    -> Auth System Overhaul                [initiative]
    -> blue-fox-7                          [agent]
    -> Settings                            [page]
```

### Fuzzy Matching Behavior

- Uses substring match by default (case-insensitive)
- Query is split on whitespace: `"auth pkce"` matches "Implement **auth** **PKCE** flow"
- Each token must appear somewhere in the candidate string (AND logic)
- No typo tolerance — keep it simple. If fuzzy matching via a library like
  `fuse.js` is added later, configure threshold conservatively (0.3) to avoid
  noise in a mission-control context where precision matters.

### Result Ranking

Within each group, results are ordered by:
1. **Exact prefix match** — query matches the start of the name (highest)
2. **Substring position** — earlier match index ranks higher
3. **Recency** — more recently updated items break ties (`updatedAt` DESC)

### Section Keyboard Shortcuts

| Key | Action |
|-----|--------|
| `Arrow Up` / `Arrow Down` | Move highlight between items (wraps around) |
| `Enter` | Navigate to highlighted item, close palette |
| `Esc` | Close palette |
| `Backspace` on empty query | Close palette |
| `Cmd+K` / `Ctrl+K` while open | Close palette (toggle behavior) |

### Overlay Behavior

- Renders as a centered modal with backdrop (`bg-black/50`)
- Width: `max-w-lg` (512px)
- Position: `top-[20%]` of viewport
- Backdrop click closes the palette
- Focus is trapped inside the modal while open
- Search input auto-focuses on open

### Source

- `packages/web/src/components/CommandPalette.tsx` (proposed)

---

## 5. `<ThemeToggle>`

3-state segmented control for switching between light, system, and dark
color schemes. Persists selection to `localStorage`.

### Props

```ts
// No props — reads/writes theme from ThemeProvider context
// ThemeProvider wraps the app root
type Theme = 'light' | 'system' | 'dark';
```

### States

```
Light active:    [*sun*] [monitor] [moon]
System active:   [sun] [*monitor*] [moon]
Dark active:     [sun] [monitor] [*moon*]
```

### Detailed ASCII

**Light mode selected**
```
+-----+----------+---------+
| sun | monitor  |  moon   |
+-----+----------+---------+
  ^^^
  filled bg, active text
```

**System mode selected (default)**
```
+-----+----------+---------+
| sun | monitor  |  moon   |
+-----+----------+---------+
        ^^^^^^^^
        filled bg, active text
```

**Dark mode selected**
```
+-----+----------+---------+
| sun | monitor  |  moon   |
+-----+----------+---------+
                    ^^^^^^
                    filled bg, active text
```

### Styling

- Container: `inline-flex rounded-lg bg-muted p-0.5 gap-0.5`
- Segment (inactive): `px-2 py-1 rounded-md text-muted-foreground hover:text-foreground`
- Segment (active): `px-2 py-1 rounded-md bg-background text-foreground shadow-sm`
- Icons: 16px lucide-react icons (`Sun`, `Monitor`, `Moon`)
- No text labels — icons only in the header (compact)

### Tooltip on hover

```
  [sun]     -> "Light"
  [monitor] -> "System (Dark)"     or "System (Light)" — reflects resolved preference
  [moon]    -> "Dark"
```

The System tooltip dynamically resolves the current `prefers-color-scheme`
media query to show the effective mode. This eliminates user confusion about
what "System" actually means on their machine. Read `isDark` from the
`ThemeProvider` context to determine the parenthetical label.

### Persistence

- Stored in `localStorage` key `cw-theme`
- Default: `system`
- On load: reads `localStorage`, applies `class="dark"` to `<html>` if
  theme is `dark` or if theme is `system` and `prefers-color-scheme: dark`

### Usage

| Page | Location |
|------|----------|
| App layout (header) | Right cluster, between Cmd+K button and health dot |

See app-layout.md for header placement.

### Source

- `packages/web/src/components/ThemeToggle.tsx` (proposed)
- `packages/web/src/providers/ThemeProvider.tsx` (proposed)

---

## 6. `<StatusDot>`

Semantic status indicator rendered as a small colored circle. Used pervasively
across the UI for agents, tasks, health, conversations, and previews. This is
the single most repeated visual element in the app — it needs to be a proper
component, not ad-hoc `<span className="h-2 w-2 rounded-full bg-..." />` copy-pasted everywhere.

### Props

```ts
interface StatusDotProps {
  /**
   * Maps to a status token from the theme (status-active-dot, status-success-dot, etc.).
   * This is the semantic status, not a color — the theme resolves the actual hue.
   */
  status: 'active' | 'success' | 'warning' | 'error' | 'neutral' | 'urgent';
  /**
   * Dot diameter. Defaults to 'md'.
   * - 'sm' (6px): inline with text, badges, compact lists
   * - 'md' (8px): agent cards, task rows, sidebar items
   * - 'lg' (10px): health indicator in header, large card headers
   */
  size?: 'sm' | 'md' | 'lg';
  /**
   * When true, applies a pulsing opacity animation (0.4 -> 1.0, 2s cycle).
   * Use for statuses that represent ongoing activity (running agent, building preview).
   * Default: false.
   *
   * Animation uses opacity only — no scale — to avoid layout shifts in tight layouts
   * like agent cards. The keyframes:
   *   0% { opacity: 1 }
   *   50% { opacity: 0.4 }
   *   100% { opacity: 1 }
   */
  pulse?: boolean;
  /**
   * Accessible label for screen readers. Required.
   * Examples: "Running", "Crashed", "Connected", "Blocked"
   *
   * Rendered as `aria-label` on the element and `role="status"`.
   * Status should never be communicated through color alone —
   * this label is the accessible equivalent.
   */
  label: string;
}
```

### Size Variants

```
  sm (6px):   *          <- inline with text runs
  md (8px):   (*)        <- default, agent cards
  lg (10px):  ( * )      <- header health dot
```

### Visual Rendering

```
+--------------------------------------------+
| Active (pulse):    (*) ← opacity animation |
| Success (static):  (v) ← solid green       |
| Warning (static):  (?) ← solid amber       |
| Error (static):    (!) ← solid red         |
| Neutral (static):  (-) ← solid grey        |
| Urgent (pulse):    (!) ← pulsing purple    |
+--------------------------------------------+
```

### Color Mapping (from theme tokens)

| Status | Token | Typical Color | Default Pulse? |
|--------|-------|---------------|----------------|
| `active` | `status-active-dot` | Blue | Yes (running agents, building previews) |
| `success` | `status-success-dot` | Green | No |
| `warning` | `status-warning-dot` | Amber | No |
| `error` | `status-error-dot` | Red | No |
| `neutral` | `status-neutral-dot` | Grey | No |
| `urgent` | `status-urgent-dot` | Purple | Yes (blocked tasks, urgent inbox) |

Note: `pulse` is a prop, not automatic per status. The table above shows
conventional usage. A completed agent does not pulse; a health dot in green
state pulses briefly (3s) then stops — that logic lives in the consuming
component, not in `StatusDot` itself.

### Styling

```
.status-dot {
  display: inline-block;
  border-radius: 9999px;              /* fully round */
  flex-shrink: 0;                     /* never collapse in flex layouts */
  background: hsl(var(--status-{status}-dot));
}

.status-dot--sm  { width: 6px; height: 6px; }
.status-dot--md  { width: 8px; height: 8px; }
.status-dot--lg  { width: 10px; height: 10px; }

.status-dot--pulse {
  animation: status-pulse 2s ease-in-out infinite;
}

@keyframes status-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.4; }
}
```

### Usage

| Page | Context | Status | Size | Pulse? |
|------|---------|--------|------|--------|
| App header | Health indicator | success/warning/error | lg | Green: 3s then stop |
| Agents | Agent card status | active/warning/error/neutral | md | active: yes |
| Execution tab | Phase/task status | active/success/warning/neutral | md | active: yes |
| Inbox | Conversation priority | urgent/warning/neutral/success | md | urgent: yes |
| Dialogs | Task dependency status | varies | sm | No |
| Settings | Account health | success/warning | sm | No |

### Accessibility

- `role="status"` + `aria-label` on every instance
- Color is never the sole indicator — always paired with text label or icon in context
- Pulse animation respects `prefers-reduced-motion: reduce` (disable animation)

### Source

- `packages/web/src/components/StatusDot.tsx` (proposed)

---

## 7. `<SkeletonLoader>`

Shimmer placeholder blocks shown during data loading. Mirrors the anatomy of
the content it replaces to prevent layout shift. Used on every data-fetching
page.

### Props

```ts
interface SkeletonProps {
  /**
   * Shape variant:
   * - 'line': horizontal bar (text placeholder). Default height 16px.
   * - 'circle': avatar / status dot placeholder. Uses `size` for diameter.
   * - 'rect': block placeholder (card, image area). Uses `width` x `height`.
   */
  variant?: 'line' | 'circle' | 'rect';
  /** Width. For 'line': defaults to '100%'. For 'circle': ignored (uses size). */
  width?: string | number;
  /** Height. For 'line': defaults to 16px. For 'circle': ignored (uses size). */
  height?: string | number;
  /** Diameter for 'circle' variant. Defaults to 32px. */
  size?: number;
  /** Additional className for width/margin overrides. */
  className?: string;
}

/**
 * Composite skeleton that renders N skeleton cards matching a specific
 * page's card anatomy. Use instead of manually assembling Skeleton primitives.
 */
interface SkeletonCardProps {
  /** Number of skeleton cards to render. Default: 5. */
  count?: number;
  /** Card layout variant matching the page being loaded. */
  layout: 'agent-card' | 'initiative-card' | 'conversation-card' | 'project-card' | 'account-card';
}
```

### Shimmer Animation

All skeleton elements share a single shimmer sweep: a translucent highlight
gradient that moves left-to-right on a 1.5s infinite loop. This uses a CSS
`background-position` animation on a linear gradient, not a pseudo-element
overlay, so it works on any shape.

```
Resting:    ░░░░░░░░░░░░░░░░░░░░
Shimmer:    ░░░░░▓▓▓░░░░░░░░░░░░   <- highlight sweeps right
Shimmer:    ░░░░░░░░░░░░▓▓▓░░░░░
```

```css
.skeleton {
  background: linear-gradient(
    90deg,
    hsl(var(--muted)) 25%,
    hsl(var(--muted-foreground) / 0.08) 50%,
    hsl(var(--muted)) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
  border-radius: var(--radius);
}

@keyframes shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
```

Uses `--duration-glacial` (500ms) as the animation timing reference from
the theme spec, though the actual cycle is 1.5s (3x glacial) for a gentler feel.

### Composite Card Skeletons

**Agent card skeleton** (matches `agents.md` card anatomy):
```
+---------------------------+
| [.] ░░░░░░░░░░░░         |   <- circle (8px) + line (60%)
|   ░░░░░ . ░░░░░░         |   <- line (40%) + dot + line (50%)
|   ░░░░░░░░░░░░░          |   <- line (70%)
|   ░░░░          ░░░ ░░░  |   <- line (30%) + gap + line (20%) + line (20%)
+---------------------------+
```

**Initiative card skeleton** (matches `initiatives-list.md` card anatomy):
```
+---------------------------+
| ░░░░░░░░░░░░░░░░  ░░ ░░  |   <- line (65%) + badge + badge
| ░░░░░░░░░░  ░░░░░        |   <- line (40%) + line (30%)
| ░░░░░░░░░░░░░░░░░░░░░░░  |   <- rect (100% x 6px) progress bar
| ░░░░░░░░    ░░░░░  ░░░░  |   <- line (35%) + line (25%) + line (20%)
+---------------------------+
```

**Conversation card skeleton** (matches `inbox.md` card anatomy):
```
+---------------------------+
| [.] ░░░░░░░░░░  ░░░      |   <- circle (8px) + line (55%) + line (15%)
| ░░░░░░░░░░░░░░░░░░       |   <- line (80%)
| ░░░░░░░░░░░░              |   <- line (50%)
+---------------------------+
```

### Loading-to-Content Transition

- Skeletons fade out: `opacity-0` over 150ms (`duration-fast`)
- Content fades in: `opacity-100` over 150ms
- No layout jump: skeleton card dimensions must exactly match real card dimensions
- Container uses `min-h` to prevent collapse during transition
- On error: skeletons fade out, `<ErrorState>` fades in centered

### Accessibility

- `aria-hidden="true"` on all skeleton elements (they are decorative)
- `role="status"` + `aria-label="Loading"` on the skeleton container
- Respects `prefers-reduced-motion: reduce` — disable shimmer, show static grey blocks

### Usage

| Page | Card Layout | Count | Notes |
|------|-------------|-------|-------|
| Initiatives list | `initiative-card` | 5 | Matches card height to prevent jump |
| Agents | `agent-card` | 5 | Output panel shows empty state, not skeleton |
| Inbox | `conversation-card` | 6 | List panel only |
| Settings > Projects | `project-card` | 3 | |
| Settings > Accounts | `account-card` | 3 | |
| Initiative detail | Custom | 1 | Two-row header + tab bar + content area |
| Content tab | Custom | 1 | Sidebar tree + editor area |

### Source

- `packages/web/src/components/Skeleton.tsx` (proposed — primitives)
- `packages/web/src/components/SkeletonCard.tsx` (proposed — composite cards)

---

## 8. `<Toast>` (via Sonner)

Global notification toasts for asynchronous feedback. Rendered by the
`<Toaster>` provider at the app root. Individual toasts are fired imperatively
via `toast()`, `toast.success()`, `toast.error()`, etc.

This project uses [Sonner](https://sonner.emilkowal.dev/) (already a shadcn/ui
dependency). No custom toast component is needed — this spec documents the
configuration and usage conventions.

### Toaster Configuration

```tsx
// In app root layout (see app-layout.md)
<Toaster
  position="bottom-right"
  toastOptions={{
    className: 'font-sans text-sm',
    duration: 5000,                    // default auto-dismiss: 5s
  }}
  visibleToasts={3}                    // max 3 visible, older collapse to "+N more"
  richColors                           // enable semantic color variants
  closeButton                          // always show dismiss X
  offset={16}                          // 16px from viewport edge
/>
```

### Toast Variants

```
Success:    +---------------------------------------+
            |  [v] Account added              [x]  |
            +---------------------------------------+
            bg-status-success-bg, auto-dismiss 5s

Error:      +---------------------------------------+
            |  [!] Failed to delete project   [x]  |
            |      [Retry]                         |
            +---------------------------------------+
            bg-status-error-bg, persistent (no auto-dismiss)

Info:       +---------------------------------------+
            |  [i] Answer sent to blue-fox-7  [x]  |
            +---------------------------------------+
            default bg, auto-dismiss 5s

Warning:    +---------------------------------------+
            |  [!] Account X exhausted,       [x]  |
            |      switched to Y                   |
            +---------------------------------------+
            bg-status-warning-bg, auto-dismiss 5s

With action:+---------------------------------------+
            |  [!] blue-fox-7 crashed         [x]  |
            |      [View Agent]                    |
            +---------------------------------------+
            persistent, action navigates to /agents
```

### Duration Rules

| Category | Duration | Rationale |
|----------|----------|-----------|
| Success confirmations | 5s | User glances, moves on |
| Info / status changes | 5s | FYI, not blocking |
| Warnings (account switch, etc.) | 5s | Notable but not actionable |
| "All tasks complete" | 8s | Celebration moment, give it room |
| "Agent has a question" | 8s | Needs attention but not emergency |
| Agent crashed | Persistent | Action required — must be manually dismissed |
| Task needs approval | Persistent | Action required |
| Errors with retry | Persistent | User must acknowledge or retry |

### Critical Event Toasts (fired globally via EventBus)

These fire regardless of the current page (configured in app-layout.md):

| Event | Toast Call | Duration |
|-------|-----------|----------|
| Agent crashed | `toast.error("blue-fox-7 crashed", { action: { label: "View", onClick: navigate } })` | Persistent |
| Task needs approval | `toast("Task ready for approval", { action: { label: "Review", onClick: navigate } })` | Persistent |
| All tasks complete | `toast.success("Initiative complete — ready for review")` | 8s |
| Account exhausted | `toast.warning("Account X exhausted, switched to Y")` | 5s |
| Agent asking question | `toast("blue-fox-7 has a question", { action: { label: "Answer", onClick: navigate } })` | 8s |

### Stacking Behavior

```
                                        +---+
   (viewport bottom-right)             | +N |  <- collapsed count
                                        +---+
                              +---------------------------+
                              | Toast 3 (oldest visible)  |
                              +---------------------------+
                              +---------------------------+
                              | Toast 2                   |
                              +---------------------------+
                              +---------------------------+
                              | Toast 1 (newest)          |
                              +---------------------------+
```

- Max 3 visible; older toasts collapse into a "+N more" pill
- New toasts enter from the bottom with `--ease-spring` (slight overshoot)
- Dismissed toasts slide out to the right with `--ease-in`
- Z-index: `var(--z-toast)` (60) — above modals, below command palette

### Source

- `packages/web/src/components/ui/sonner.tsx` (shadcn/ui generated — configure here)
- Toaster mount point: `packages/web/src/App.tsx` or root layout

---

## 9. `<Badge>`

Inline status label rendered as a small colored pill. Used for entity status
(RUNNING, ACTIVE, BLOCKED), category labels (execute, verify), tab counts,
and filter chips.

### Props

```ts
interface BadgeProps {
  /** Visual variant controlling color scheme. */
  variant:
    | 'active'      // blue bg — running, in_progress, building
    | 'success'     // green bg — completed, connected, done
    | 'warning'     // amber bg — waiting, exhausted, pending_approval
    | 'error'       // red bg — crashed, failed, disconnected
    | 'neutral'     // grey bg — stopped, idle, pending, exited
    | 'urgent'      // purple bg — blocked
    | 'outline'     // border-only, no fill — provider labels, category tags
    | 'secondary';  // muted fill — mode labels, secondary metadata
  /** Badge text content. Rendered uppercase in status badges, as-is for categories. */
  children: React.ReactNode;
  /** Size variant. Default: 'sm'. */
  size?: 'sm' | 'xs';
  /** Optional leading icon (rendered at 12px for xs, 14px for sm). */
  icon?: React.ReactNode;
  /**
   * When true, text is rendered in uppercase with letter-spacing.
   * Use for status badges (RUNNING, ACTIVE). Default: false.
   */
  uppercase?: boolean;
}
```

### Visual Variants

```
Status badges (uppercase=true):
  [RUNNING]   <- variant="active", blue bg
  [ACTIVE]    <- variant="active", blue bg
  [DONE]      <- variant="success", green bg
  [WAITING]   <- variant="warning", amber bg
  [CRASHED]   <- variant="error", red bg
  [STOPPED]   <- variant="neutral", grey bg
  [BLOCKED]   <- variant="urgent", purple bg
  [QUEUED]    <- variant="active", blue bg

Category badges (uppercase=false, variant="outline"):
  [execute]   <- outline, no fill
  [verify]    <- outline, no fill
  [research]  <- outline, no fill

Provider/mode badges (uppercase=false):
  [claude]    <- variant="outline"
  [execute]   <- variant="secondary"

Tab count badges (size="xs"):
  [*3]        <- variant="active", xs size (agents tab)
  [2]         <- variant="warning", xs size (inbox tab)

Filter chips (with dismiss):
  [running x] <- variant="active" with trailing X button
```

### Size Variants

| Size | Font | Padding | Border Radius | Line Height |
|------|------|---------|---------------|-------------|
| `sm` (default) | `text-xs` (12px) | `px-2 py-0.5` | `rounded-sm` (2px) | 20px |
| `xs` | `text-[10px]` | `px-1.5 py-px` | `rounded-sm` (2px) | 16px |

### Color Mapping (uses status tokens from theme)

| Variant | Background | Text | Border |
|---------|-----------|------|--------|
| `active` | `bg-status-active-bg` | `text-status-active-fg` | `border-status-active-border` |
| `success` | `bg-status-success-bg` | `text-status-success-fg` | `border-status-success-border` |
| `warning` | `bg-status-warning-bg` | `text-status-warning-fg` | `border-status-warning-border` |
| `error` | `bg-status-error-bg` | `text-status-error-fg` | `border-status-error-border` |
| `neutral` | `bg-status-neutral-bg` | `text-status-neutral-fg` | `border-status-neutral-border` |
| `urgent` | `bg-status-urgent-bg` | `text-status-urgent-fg` | `border-status-urgent-border` |
| `outline` | `transparent` | `text-foreground` | `border border-border` |
| `secondary` | `bg-secondary` | `text-secondary-foreground` | none |

### Entity-to-Variant Mapping

Use this table when deciding which variant to assign. This is the canonical
source — do not invent new mappings without adding them here.

| Entity | State | Variant | Label |
|--------|-------|---------|-------|
| Agent | running | `active` | RUNNING |
| Agent | waiting_for_input | `warning` | WAITING |
| Agent | completed | `neutral` | COMPLETED |
| Agent | crashed | `error` | CRASHED |
| Agent | stopped | `neutral` | STOPPED |
| Task | in_progress | `active` | RUNNING |
| Task | completed | `success` | DONE |
| Task | pending | `neutral` | PENDING |
| Task | pending_approval | `warning` | APPROVAL |
| Task | blocked | `urgent` | BLOCKED |
| Task | queued | `active` | QUEUED |
| Task | failed | `error` | FAILED |
| Initiative | active | `active` | ACTIVE |
| Initiative | completed | `success` | COMPLETED |
| Initiative | archived | `neutral` | ARCHIVED |
| Initiative mode | review | `warning` | REVIEW |
| Initiative mode | planning | `secondary` | PLANNING |
| Preview | building | `active` | BUILDING |
| Preview | running | `success` | RUNNING |
| Preview | failed | `error` | FAILED |
| Preview | stopped | `neutral` | STOPPED |
| Account | active | `success` | -- (no badge) |
| Account | exhausted | `warning` | EXHAUSTED |

### Accessibility

- Badge text is readable by screen readers (no `aria-hidden`)
- Color is never the sole differentiator — the text label provides meaning
- Sufficient contrast ratios are ensured by the status token pairs (bg/fg)

### Source

- `packages/web/src/components/Badge.tsx` (proposed — or extend shadcn/ui `badge.tsx`)

---

## 10. `<KeyboardShortcutHint>`

Tooltip component that displays the keyboard shortcut for an action. Used in
button tooltips, command palette results, and the `?` shortcut help overlay.

### Props

```ts
interface KeyboardShortcutHintProps {
  /** The key combination. Platform-aware: automatically swaps Cmd/Ctrl.
   *  Format: modifier keys joined with '+'. Examples: "Cmd+K", "Shift+N", "Esc", "1"
   */
  keys: string;
  /** Tooltip text shown before the shortcut. e.g., "Search" renders as "Search  ⌘K" */
  label?: string;
  /**
   * Rendering mode:
   * - 'tooltip': renders inside a shadcn Tooltip (default). Wraps `children`.
   * - 'inline': renders as inline <kbd> elements (for command palette results, help overlay).
   */
  mode?: 'tooltip' | 'inline';
  /** The element that triggers the tooltip. Only used in 'tooltip' mode. */
  children?: React.ReactNode;
}
```

### Platform Detection

```ts
// Detect platform once at module level
const isMac = navigator.platform?.startsWith('Mac') ||
              navigator.userAgent?.includes('Mac');

// Key symbol mapping
const KEY_SYMBOLS: Record<string, string> = {
  'Cmd':       isMac ? '⌘' : 'Ctrl',
  'Ctrl':      isMac ? '⌃' : 'Ctrl',
  'Alt':       isMac ? '⌥' : 'Alt',
  'Shift':     '⇧',
  'Enter':     '↵',
  'Backspace': '⌫',
  'Esc':       'Esc',
  'Tab':       '⇥',
  'ArrowUp':   '↑',
  'ArrowDown': '↓',
  'ArrowLeft': '←',
  'ArrowRight':'→',
};
```

### Tooltip Mode (default)

Wraps a trigger element with a shadcn `<Tooltip>`. The tooltip content
shows `label` (if provided) followed by the key combination in `<kbd>` pills.

```
Hovering over the Cmd+K button:

  +-----+
  | ⌘K  |    <- trigger element (the button)
  +-----+
      |
  +-------------+
  | Search  ⌘ K |   <- tooltip: label + kbd pills
  +-------------+
```

```
Hovering over a tab:

  +----------+
  | Content  |    <- trigger (tab)
  +----------+
      |
  +-----------+
  | Content 1 |   <- tooltip: label + key
  +-----------+
```

### Inline Mode

Renders `<kbd>` pills directly in the flow. Used in command palette results
and the keyboard shortcut help overlay.

```
Command palette result with shortcut:

  > Go to Agents                                    ⌘ 2
                                                    ^^^
                                                    inline <kbd> pills

Shortcut help overlay row:

  Next file                                         j
  Previous file                                     k
  Toggle resolve                                    r
  Search                                          ⌘ K
```

### `<kbd>` Pill Styling

```
Individual key:
  +---+
  | K |    <- single character key
  +---+

  +------+
  | Ctrl |  <- modifier key (text on non-Mac)
  +------+

  +---+
  | ⌘ |    <- modifier key (symbol on Mac)
  +---+
```

- Each key segment is a separate `<kbd>` element
- `<kbd>` styling: `inline-flex items-center justify-center min-w-[20px] h-5
   px-1.5 rounded-sm bg-muted border border-border text-[11px] font-mono
   text-muted-foreground leading-none`
- Multiple keys separated by `gap-0.5` (2px)
- Modifier+key combinations: `⌘` `K` (two separate pills, no `+` separator)

### Usage

| Location | Mode | Keys | Label |
|----------|------|------|-------|
| Header Cmd+K button | tooltip | `Cmd+K` | "Search" |
| Tab tooltips (1-4) | tooltip | `1`, `2`, `3`, `4` | Tab name |
| Command palette results | inline | varies | -- |
| `?` shortcut help overlay | inline | all shortcuts | Action name |
| Inbox send button | inline | `Cmd+Enter` | -- |
| Plan tab add task | tooltip | `N` | "New task" |

### Accessibility

- `<kbd>` elements are semantic HTML — screen readers announce them as keyboard input
- Tooltips use `role="tooltip"` via shadcn Tooltip
- Platform-specific symbols (⌘, ⌃, ⌥) have appropriate `aria-label` fallbacks
  (`aria-label="Command"`, `aria-label="Control"`, `aria-label="Option"`)

### Source

- `packages/web/src/components/KeyboardShortcutHint.tsx` (proposed)

---

## Cross-Reference Index

Quick lookup of which shared components are used on each page.

| Component | app-layout | initiatives-list | initiative-detail | content-tab | plan-tab | execution-tab | review-tab | agents | inbox | settings |
|-----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| SaveIndicator | | | | x | x | | | | | |
| EmptyState | | x | | | x | x | | x | x | x |
| ErrorState | | x | x | | x | | x | x | | x |
| CommandPalette | x | | | | | | | | | |
| ThemeToggle | x | | | | | | | | | |
| StatusDot | x | | | | | x | | x | x | x |
| SkeletonLoader | | x | x | x | | | | x | x | x |
| Toast | x | | | | | x | | | x | x |
| Badge | | x | x | | | x | x | x | | x |
| KeyboardShortcutHint | x | x | x | | x | x | x | x | x | |

---

## Design Review Notes

### Existing components: what was already solid

1. **SaveIndicator** — the `persistent` prop with timestamp display is exactly right.
   High-stakes contexts (page content editing) need ongoing confirmation; lightweight
   contexts (phase descriptions) do not. The `Intl.DateTimeFormat` call is a good
   detail. One nit: consider adding a `fadeDuration` prop (default 2000ms) for
   contexts where the fade timing needs tuning. Not critical — the current hardcoded
   2s is fine for now.

2. **EmptyState** — the dual-action pattern with primary/secondary buttons is well
   spec'd. The button variant assignments (default vs outline) are correct. The
   usage table covering 7 pages with specific copy is useful for implementation.
   The "without description" variant prevents over-engineering simple empty states.

3. **ErrorState** — the expandable details section with `<details>/<summary>` is
   the right call. Using native HTML disclosure is simpler than a state toggle and
   works without JS. The `errorCode` displayed in `font-mono` is good for bug
   reports. One gap: there is no "Copy error details" button for users filing
   issues. Consider adding a clipboard-copy icon inside the details panel.

4. **CommandPalette** — this section is thorough. The fuzzy matching behavior
   (whitespace-split AND-logic, no typo tolerance) is a good conservative default
   for a precision-oriented tool. The result ranking (prefix > position > recency)
   is sound. Recent items in `sessionStorage` with LRU eviction is correct — these
   should not persist across sessions. The "no results" fallback with static
   navigation links prevents dead ends.

5. **ThemeToggle** — the dynamic `"System (Dark)"` / `"System (Light)"` tooltip
   resolving `prefers-color-scheme` is the right UX. Users who pick "System" and
   see dark mode need to know it is working as intended, not broken. Reading `isDark`
   from ThemeProvider context is the clean implementation path.

### Added components: rationale

6. **StatusDot** — this was the most glaring omission. The dot appears on every
   data-driven page, in at least 6 different contexts, and was being described
   ad-hoc in agents.md, app-layout.md, inbox.md, execution-tab.md, and dialogs.md.
   Without a shared spec, each page would implement its own dot with slightly
   different sizes, animations, and accessibility. The `pulse` prop is intentionally
   not automatic per status — the consuming component decides (e.g., health dot
   pulses for 3s then stops; agent dot pulses indefinitely). The `label` prop is
   required, not optional, because status-by-color-alone fails WCAG.

7. **SkeletonLoader** — every wireframe describes its own skeleton layout but none
   referenced a shared primitive. The composite `SkeletonCard` pattern (with named
   layouts like `agent-card`, `initiative-card`) prevents skeleton/content dimension
   mismatches that cause layout jumps. The shimmer animation is standardized at
   1.5s with theme token references.

8. **Toast** — app-layout.md already described Sonner configuration and critical
   event toasts, but scattered across two sections. Centralizing the duration rules,
   stacking behavior, and variant usage in one place prevents inconsistency. The
   duration table (5s default, 8s for attention, persistent for action-required)
   is a policy decision that should live in a shared spec, not be reinvented per page.

9. **Badge** — the most polymorphic component in the system. It appears as status
   badges (RUNNING), category labels (execute), tab counts (*3), provider labels
   (claude), and filter chips. Without a canonical variant-to-color mapping, every
   developer picks their own colors. The entity-to-variant table is the single
   source of truth — if a status is not in that table, it does not get a badge.

10. **KeyboardShortcutHint** — a keyboard-first tool that does not show its shortcuts
    is just a tool. The `<kbd>` pill styling with platform-aware symbols (⌘ vs Ctrl)
    is table stakes. The dual mode (tooltip wrapping trigger elements vs inline in
    command palette results) covers both discovery patterns. The `aria-label`
    fallbacks on Mac symbols prevent screen readers from announcing "unknown character."

### Open questions for implementation

- **StatusDot `pulse` and `prefers-reduced-motion`**: The spec says disable animation,
  but should it fall back to a static solid dot or a subtly different visual (e.g.,
  a ring outline)? A static dot for a running agent looks identical to a completed
  agent to users with reduced-motion preferences. Recommendation: use a double-ring
  outline as the reduced-motion fallback for `pulse=true`.

- **Badge filter chips**: The spec mentions `[running x]` with a dismiss button but
  does not fully define the dismiss interaction. This depends on the filter bar
  implementation in initiatives-list.md and agents.md. Defer the dismissible badge
  variant until those filter patterns are finalized.

- **SkeletonCard dimension matching**: The spec says skeleton dimensions must match
  real card dimensions. In practice, this requires the skeleton and card to share
  a CSS class or dimension constant. Consider extracting card height values into
  CSS custom properties (e.g., `--card-height-agent: 112px`) referenced by both
  the real card and its skeleton.
