docs(22): UI design contract for agent-streaming phase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-01 17:25:42 +00:00
parent 2c89093107
commit f7e4ed9d7b

View file

@ -0,0 +1,507 @@
---
phase: 22
slug: agent-streaming
status: draft
shadcn_initialized: true
preset: new-york / neutral / css-variables
created: 2026-04-01
---
# Phase 22 — UI Design Contract
> Visual and interaction contract for Phase 22: Agent Streaming.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.
---
## Design System
| Property | Value | Source |
|----------|-------|--------|
| Tool | shadcn/ui | `ui/components.json` — unchanged from Phase 21 |
| Style | new-york | `ui/components.json` |
| Base color | neutral | `ui/components.json` |
| CSS variables | true | `ui/components.json` |
| Component library | Radix UI (via shadcn new-york) | `ui/components.json` |
| Icon library | lucide-react ^0.574.0 | `ui/components.json` |
| Font | System UI (`font-sans`, inherited) | `ui/src/index.css` |
**Existing shadcn components available (no install needed):**
`avatar`, `badge`, `button`, `card`, `checkbox`, `collapsible`, `command`, `dialog`, `dropdown-menu`, `input`, `label`, `popover`, `scroll-area`, `select`, `separator`, `sheet`, `skeleton`, `tabs`, `textarea`, `tooltip`
**Existing custom components to reuse/extend:**
- `ChatPanel.tsx` — extend with agent selector header area and streaming state; do not restructure the two-column layout
- `ChatMessage.tsx` — extend props to accept `agentId`, `agentName`, `agentIcon` for identity rendering
- `ChatMessageList.tsx` — replace with virtualized list (`@tanstack/react-virtual`) for PERF-03
- `ChatInput.tsx` — extend with slash command popover and @mention popover (reuse `MentionOption` pattern from `MarkdownEditor.tsx`)
- `AgentIcon` (from `AgentIconPicker.tsx`) — reuse directly for agent avatar rendering in messages
- `agentStatusDot` (from `status-colors.ts`) — reuse `running: "bg-cyan-400 animate-pulse"` for streaming indicator
**New package required:**
- `@tanstack/react-virtual` — not currently installed; add to `ui/package.json` for PERF-03 (1,000+ message virtualization)
---
## Layout Contract
### Layout Unchanged
The overall layout established in Phase 21 is unchanged:
```
[ CompanyRail ] [ Sidebar ] [ <main> ] [ ChatPanel ] [ PropertiesPanel ]
```
Phase 22 adds new UI surfaces **inside** `ChatPanel` only. No layout-level changes.
### ChatPanel Header — Agent Selector
The `ChatPanel` header row gains an agent selector to the left of the close button:
```
[ "Chat" label ] [ AgentSelector dropdown ] [ ─── spacer ─── ] [ X close ]
```
- The `AgentSelector` is a `<Select>` or `<Popover>` trigger showing the active agent's icon + name
- Width: fit-content, max 120px, truncated with ellipsis
- Placement: `flex items-center gap-2` row, same `px-4 py-2` padding as Phase 21 header
- When no agent is selected, label reads "Select agent" in `text-muted-foreground`
### Message Bubble — Agent Identity Bar
Every assistant message gains an identity bar above the message content:
```
[ AgentIcon 16px ] [ agentName 13px semibold ] [ timestamp 11px muted ]
```
- Identity bar: `flex items-center gap-1.5 mb-1`
- Icon: 16×16px, rendered via `<AgentIcon icon={agentIcon} className="h-4 w-4" />`
- Name: `text-[13px] font-semibold text-foreground`
- Timestamp: `text-[11px] text-muted-foreground`
- No background, no border — identity floats above message content
### Streaming Cursor
A blinking block cursor appended to the last streamed token while generation is active:
- Element: `<span className="inline-block w-2 h-[1em] bg-foreground/70 animate-cursor-blink ml-0.5 align-text-bottom" />`
- Animation: `animate-cursor-blink` — declare in `index.css` (see Animation section)
- Rendered only when `isStreaming: true` on the message; removed when stream ends
### Stop Button
While a response is streaming, a Stop button appears below the message thread, centered above `ChatInput`:
```
[ (─────────────) ] ← centered row
[ ■ Stop generating ] ← Button variant="outline" size="sm"
[ (─────────────) ]
[ ChatInput ]
```
- Container: `flex justify-center py-2 border-t border-border`
- Button: `variant="outline" size="sm"`, icon `Square` (filled stop), label "Stop generating"
- Disappears immediately on click (optimistic) before server confirms cancellation
### Edit / Retry Controls
User messages gain an edit pencil on hover. Assistant messages gain a retry button on hover.
**User message edit:**
- `Pencil` icon button, 14×14px, `variant="ghost" size="icon"`, positioned at top-right of message bubble
- Visible only on `group-hover`; always visible on touch devices
- Click → switches message bubble to inline edit mode (see Interaction Contract)
**Assistant message retry:**
- `RefreshCw` icon button, 14×14px, `variant="ghost" size="icon"`, positioned below message content, right-aligned
- Visible only on `group-hover`; always visible on touch devices
- Only present on messages where `isStreaming: false` and `role === "assistant"`
### Slash Command Popover
When the user types `/` as the first character in `ChatInput`:
```
[ /brainstorm — Route to Brainstormer ] ← highlighted item
[ /ask-pm — Route to PM ]
[ /ask-engineer — Route to Engineer ]
[ /task — Create a task ]
[ /search — Search conversations ]
```
- Container: shadcn `<Popover>` anchored to the top-left of the textarea, opens upward
- List: `<Command>` component inside popover (reuses existing `cmdk` install)
- Width: 260px, max 5 items visible before scroll
- Dismiss: Escape, clicking outside, or completing the command
### @Mention Popover
When the user types `@` in `ChatInput`, an agent autocomplete popover appears. Reuses the `MentionOption` pattern from `MarkdownEditor.tsx`:
- Container: `<Popover>` anchored to the `@` character position, opens upward
- List: agent names filtered by query string after `@`
- Each row: `<AgentIcon>` 14×14px + `agentName` text + `role` label in muted text
- Width: 200px, max 5 agents before scroll
- Selection: click or Enter inserts `@agentName` token into textarea and routes message to that agent
---
## Spacing Scale
Inherited from Phase 21. No new tokens for Phase 22.
| Token | Value | Phase 22 Usage |
|-------|-------|----------------|
| xs | 4px | Icon gaps in identity bar (`gap-1`) |
| sm | 8px | Stop button padding, retry button margin |
| md | 16px | Panel padding (unchanged) |
| lg | 24px | (no new usage) |
| xl | 32px | (no new usage) |
**New spacing values (Phase 22 only):**
- Identity bar gap: `gap-1.5` (6px) — between icon and agent name
- Edit/retry icon button size: 14×14px (`h-3.5 w-3.5`) — matches `MoreHorizontal` in `ChatConversationItem`
- Streaming cursor width: `w-2` (8px), height: `h-[1em]`
---
## Typography
All inherited from Phase 21. One addition for agent identity:
| Role | Size | Weight | Line Height | Usage |
|------|------|--------|-------------|-------|
| Body / message text | 15px (0.9375rem) | 400 | 1.6 | Chat message prose (unchanged) |
| Label / UI chrome | 13px (0.8125rem) | 400 | 1.4 | Conversation list, timestamps (unchanged) |
| Agent name (identity bar) | 13px (0.8125rem) | 600 | 1.4 | Agent name above assistant messages |
| Message timestamp | 11px (0.6875rem) | 400 | 1.4 | Timestamp in identity bar |
| Slash command label | 13px (0.8125rem) | 400 | 1.4 | Command name in slash popover |
| Slash command description | 12px (0.75rem) | 400 | 1.4 | Description text in slash popover, `text-muted-foreground` |
**Weights used:** 400 (regular) and 600 (semibold). No additional weights. Same constraint as Phase 21.
**11px timestamp note:** This is a 2px reduction below the Phase 21 minimum of 13px, used only within the compact identity bar where spatial context is sufficient. It is never used for interactive or error text.
---
## Color
All values inherited from Phase 21 CSS variable system. No new color variables introduced.
| Role | Catppuccin Mocha | Tokyo Night | Catppuccin Latte | Phase 22 Usage |
|------|-----------------|-------------|-----------------|----------------|
| Dominant (60%) | `--background` #1e1e2e | `--background` #1a1b26 | `--background` #eff1f5 | Unchanged |
| Secondary (30%) | `--card` #181825 | `--card` #16161e | `--card` #e6e9ef | Unchanged |
| Accent (10%) | `--accent` #45475a | `--accent` #3b4261 | `--accent` #bcc0cc | Hovered rows (unchanged) |
| Primary | `--primary` | `--primary` | `--primary` | Agent selector focus ring, send button |
| Destructive | `--destructive` | `--destructive` | `--destructive` | Not used in Phase 22 |
| Muted text | `--muted-foreground` | `--muted-foreground` | `--muted-foreground` | Timestamps, role labels, empty states |
**Accent reserved for (Phase 22 additions):**
- Same as Phase 21 (conversation rows, code block toolbar)
- Slash command popover highlighted item: `bg-accent` (via `<Command>` item selection)
**Streaming indicator color:** `bg-cyan-400 animate-pulse` — reused directly from `agentStatusDot.running` in `status-colors.ts`. Applied as a 6×6px dot beside the agent name in the identity bar while streaming.
### Agent Role Colors (THEME-03)
Agent avatars are distinguishable across all three themes using semantic role color tokens. These use Tailwind semantic colors with `dark:` variants — no new CSS variables needed.
| Role | Icon color (light theme) | Icon color (dark theme) | Rationale |
|------|--------------------------|-------------------------|-----------|
| `pm` | `text-blue-600` | `text-blue-400` | Matches `todo` status color (existing pattern) |
| `engineer` | `text-violet-600` | `text-violet-400` | Matches `in_review` status color (existing pattern) |
| `ceo` / `general` | `text-yellow-600` | `text-yellow-400` | Matches `idle` agent status (existing pattern) |
| `designer` | `text-pink-600` | `text-pink-400` | New — no conflict with existing semantic |
| `qa` | `text-orange-600` | `text-orange-400` | Matches `paused` agent status (existing pattern) |
| `researcher` | `text-teal-600` | `text-teal-400` | New — no conflict with existing semantic |
| `devops` / `cto` | `text-green-600` | `text-green-400` | Matches `done` / `active` status (existing pattern) |
| `cmo` / `cfo` | `text-neutral-600` | `text-neutral-400` | Secondary/finance roles — muted |
| fallback | `text-muted-foreground` | `text-muted-foreground` | Unknown or null role |
These colors apply to the `<AgentIcon>` in the message identity bar only. The existing `status-colors.ts` patterns are NOT modified — Phase 22 adds a new `agent-role-colors.ts` utility.
---
## Component Inventory
New components to build in Phase 22:
| Component | shadcn base | Notes |
|-----------|-------------|-------|
| `ChatAgentSelector.tsx` | `select` or `popover` + `command` | Dropdown in ChatPanel header; shows active agent icon + name; lists all workspace agents |
| `ChatMessageIdentityBar.tsx` | none | Icon + name + timestamp row above assistant messages; uses `AgentIcon` |
| `ChatStreamingCursor.tsx` | none | Inline blinking cursor element; rendered conditionally during streaming |
| `ChatStopButton.tsx` | `button` | "Stop generating" button shown above input during active stream |
| `ChatMessageActions.tsx` | `button`, `tooltip` | Edit (on user messages) and Retry (on assistant messages) hover actions |
| `ChatSlashCommandPopover.tsx` | `popover`, `command` | Slash command menu anchored to textarea; 5 commands |
| `ChatMentionPopover.tsx` | `popover` | Agent @mention autocomplete anchored to @ position |
| `useStreamingChat.ts` | none | Hook managing SSE connection, token accumulation, streaming state |
| `agent-role-colors.ts` | none | Map of `AgentRole → Tailwind class string` for icon coloring |
**Existing components to modify:**
| Component | Change |
|-----------|--------|
| `ChatPanel.tsx` | Add `ChatAgentSelector` to header; add `ChatStopButton` above input when streaming |
| `ChatMessage.tsx` | Accept `agentId`, `agentName`, `agentIcon`, `agentRole`, `isStreaming` props; render `ChatMessageIdentityBar` and `ChatStreamingCursor`; wrap in `group` for hover actions |
| `ChatMessageList.tsx` | Replace div iteration with `@tanstack/react-virtual` virtualizer; pass agent identity props down to `ChatMessage` |
| `ChatInput.tsx` | Wire `ChatSlashCommandPopover` (on `/` keystroke) and `ChatMentionPopover` (on `@` keystroke); accept `disabled` prop during streaming |
**Icons (lucide-react) — Phase 22 additions:**
- `Square` — Stop generating button icon (filled stop)
- `Pencil` — Edit user message
- `RefreshCw` — Retry assistant message
- `ChevronDown` — Agent selector dropdown indicator
- `Bot` — Default agent icon fallback (already in `AGENT_ICON_NAMES`)
**All icons from Phase 21 remain unchanged.**
---
## Interaction Contract
### Streaming Response
| Interaction | Behavior |
|-------------|---------|
| User sends message | `ChatInput` disabled; `isSubmitting` spinner on send button; SSE connection opens |
| First token arrives | New assistant message appended to list; identity bar renders; `ChatStreamingCursor` shown; `ChatStopButton` appears above input |
| Tokens continue | Text accumulates character-by-character in the message; cursor stays at end; list auto-scrolls to bottom if user has not scrolled up |
| User scrolls up during stream | Auto-scroll pauses; "Jump to bottom" button appears (↓ icon, `variant="outline" size="icon-sm"`, fixed at bottom-right of message list) |
| Stream ends | `ChatStreamingCursor` removed; `ChatStopButton` removed; `ChatInput` re-enabled; message gets final `updatedAt` timestamp |
| Stream error | Streaming cursor replaced with error inline text: "Response interrupted. [Retry ↺]" — `text-destructive` with inline Retry link |
### Stop Generation
| Interaction | Behavior |
|-------------|---------|
| Click "Stop generating" | Button removed immediately (optimistic); SSE connection closed client-side; partial message preserved as-is with a `[stopped]` suffix in muted text |
| Server confirms stop | No additional UI change needed — state already settled on client |
### Message Edit (User Messages)
| Interaction | Behavior |
|-------------|---------|
| Hover user message | `Pencil` icon button appears at top-right of bubble |
| Click `Pencil` | Message bubble becomes an inline textarea pre-filled with existing content; "Save" and "Cancel" buttons appear below; send button hidden |
| Edit textarea + click "Save" | POST updated message to server; assistant messages after this message are removed from thread (regeneration); new streaming response begins |
| Click "Cancel" | Textarea reverts to read-only bubble; no changes |
| Save with empty content | "Save" button disabled when textarea is empty |
### Retry (Assistant Messages)
| Interaction | Behavior |
|-------------|---------|
| Hover assistant message | `RefreshCw` icon button appears below message, right-aligned |
| Click `RefreshCw` | Current assistant message replaced with a streaming placeholder; SSE connection opens; regenerated response streams in |
| Retry during active stream | Not available — `RefreshCw` button hidden while any stream is active |
### Agent Selector
| Interaction | Behavior |
|-------------|---------|
| Click agent selector | Popover/dropdown opens listing all workspace agents (icon + name + role label) |
| Select agent | Active agent updated for current conversation; `PATCH /conversations/:id` with `agentId`; selector shows new agent immediately (optimistic) |
| No agents available | Selector shows "No agents" in muted text; disabled state |
| Active agent deleted externally | Selector shows "Unknown agent" in muted text; conversation continues with null `agentId` until user selects another |
### Slash Commands (INPUT-05)
| Interaction | Behavior |
|-------------|---------|
| Type `/` as first char | `ChatSlashCommandPopover` opens above input listing all 5 commands |
| Type `/bra` (partial) | Popover filters to matching commands |
| Arrow keys / Tab | Navigate popover items |
| Enter or click item | Command token inserted into textarea (e.g. `/brainstorm `); popover closes; routing applied on send |
| Escape | Popover closes; typed text remains |
| Send with `/brainstorm` prefix | Routes message to Brainstormer agent regardless of active agent selector |
**Slash command routing table:**
| Command | Routes to |
|---------|-----------|
| `/brainstorm` | Brainstormer (general role with brainstormer prompt) |
| `/ask-pm` | PM role agent |
| `/ask-engineer` | Engineer role agent |
| `/task` | PM role agent (task creation intent) |
| `/search` | No-op in Phase 22 (stubbed; Phase 24 implements) |
### @Mention Routing (INPUT-06)
| Interaction | Behavior |
|-------------|---------|
| Type `@` | `ChatMentionPopover` opens listing workspace agents (icon + name + role) |
| Type `@eng` (partial) | List filters to agents whose name contains "eng" (case-insensitive) |
| Select agent | `@AgentName` token inserted; popover closes |
| Send message with `@AgentName` | Message routed to named agent for this turn only (overrides active agent selector) |
| Unknown mention | Sent as plain text; no routing override |
### Virtualized Message List (PERF-03)
- Uses `@tanstack/react-virtual` with `useVirtualizer`
- `overscan: 5` — renders 5 items above/below visible window
- Item height: estimated 80px default; dynamic measurement via `measureElement` for variable-height markdown messages
- `scrollMarginBottom: 120px` to keep new messages visible above the input area
- "Jump to bottom" appears when `scrollOffset > 200px` from bottom; clicking calls `scrollToIndex(messages.length - 1, { align: 'end' })`
---
## Copywriting Contract
All Phase 21 copy is preserved unchanged. Phase 22 additions:
| Element | Copy | Notes |
|---------|------|-------|
| Agent selector placeholder | "Select agent" | `text-muted-foreground`; shown when no agent assigned |
| Agent selector no-options | "No agents configured" | Disabled state |
| Stop button label | "Stop generating" | `variant="outline" size="sm"`; icon `Square` precedes label |
| Edit message button aria-label | "Edit message" | Icon-only; `Pencil` icon |
| Retry message button aria-label | "Retry response" | Icon-only; `RefreshCw` icon |
| Inline edit save button | "Save" | `variant="default" size="sm"` |
| Inline edit cancel button | "Cancel" | `variant="ghost" size="sm"` |
| Streaming interrupted error | "Response interrupted." | Inline `text-destructive`; followed by Retry link |
| Retry inline link | "Retry" | `text-primary underline cursor-pointer` — not a `<Button>` |
| Stopped message suffix | "[stopped]" | `text-muted-foreground text-[11px] ml-1`; appended to partial message |
| Slash command: `/brainstorm` description | "Route to Brainstormer" | Second line in slash popover item |
| Slash command: `/ask-pm` description | "Route to PM" | Second line |
| Slash command: `/ask-engineer` description | "Route to Engineer" | Second line |
| Slash command: `/task` description | "Create a task" | Second line |
| Slash command: `/search` description | "Search conversations" | Second line; greyed out — "Coming soon" suffix in muted text |
| Jump to bottom button aria-label | "Scroll to latest message" | Icon-only; `ArrowDown` icon |
| Input placeholder (streaming) | "Waiting for response..." | Replaces "Message your agent..." while `isStreaming: true`; textarea is disabled |
**Tone:** Direct, functional, no corporate language. Consistent with Phase 21.
---
## States and Loading
| Component | Loading state | Empty state | Error state | Streaming state |
|-----------|--------------|-------------|-------------|-----------------|
| `ChatAgentSelector` | Skeleton pill 80px wide | "No agents configured" | "Could not load agents." — popover body | n/a |
| `ChatMessage` (assistant) | n/a | n/a | Inline "Response interrupted. Retry" | Cursor blink + streaming text accumulation |
| `ChatMessageList` | 3x Skeleton rows (h-16 variable) | Inherited from Phase 21 | Inherited from Phase 21 | Auto-scroll; Stop button visible |
| `ChatInput` | n/a | n/a | Inherited from Phase 21 | `disabled`; placeholder "Waiting for response..." |
| `ChatSlashCommandPopover` | n/a | n/a | n/a | Hidden while streaming |
| `ChatMentionPopover` | Skeleton 3 rows | "No agents found" | n/a | Hidden while streaming |
**Optimistic updates:**
- Agent selector: updates conversation's `agentId` optimistically; reverts on API error with toast "Could not update agent. Try again."
- Stop: button removed immediately; error shown inline if SSE close fails silently
- Edit/Retry: new streaming message renders immediately; old messages removed optimistically
---
## Theme Integration Contract
Phase 22 extends Phase 21's zero-new-plumbing approach:
- Agent role colors use `text-{color}-600 dark:text-{color}-400` Tailwind classes — resolve correctly in all three themes via the existing `.dark` class on `<html>`
- Tokyo Night: `.dark` class is present (as established in Phase 21 ThemeContext) — dark variants apply correctly
- Streaming cursor: `bg-foreground/70` uses CSS variable — resolves to near-white in dark themes, near-black in Catppuccin Latte
- Slash command popover and mention popover: use shadcn `<Popover>` + `<Command>` which inherit `--popover`, `--popover-foreground` CSS variables — no manual theme wiring needed
**THEME-03 checklist:**
- Agent icon colors declared per-role with `dark:` variants — all three themes tested
- Streaming indicator uses `bg-cyan-400` (same across themes — sufficient contrast on all three backgrounds)
- No hardcoded hex colors introduced in Phase 22
---
## Accessibility
Inherits all Phase 21 accessibility contracts. Phase 22 additions:
| Concern | Requirement |
|---------|-------------|
| Agent selector | `aria-label="Active agent"` on trigger; dropdown items have `role="option"` |
| Streaming message | `aria-live="polite"` on `ChatMessageList` (established in Phase 21) handles streamed token announcements without modification |
| Stop button | `aria-label="Stop generating response"` |
| Streaming cursor | `aria-hidden="true"` — decorative only |
| Edit textarea | `aria-label="Edit your message"` on inline textarea |
| Retry button | `aria-label="Retry response"` |
| Slash popover | `role="listbox"` on items; announce open state with `aria-expanded` |
| Mention popover | `role="listbox"` on items; announce open state with `aria-expanded` |
| Jump to bottom | `aria-label="Scroll to latest message"` |
| Edit save/cancel | Standard button labels (no icon-only ambiguity) |
| Focus after stop | Focus returns to `ChatInput` textarea after stop action |
| Focus after retry | No focus change — user may be reading the regenerated message |
---
## Animation and Motion
Inherits Phase 21 animation contract. Phase 22 additions:
| Element | Animation | Duration | Easing | CSS |
|---------|-----------|----------|--------|-----|
| Streaming cursor | `animate-cursor-blink` keyframe | 800ms | step-start | See below |
| Stop button appear | `animate-in fade-in slide-in-from-bottom-1` | 150ms | `ease-out` — shadcn default |
| Stop button disappear | `animate-out fade-out slide-out-to-bottom-1` | 100ms | `ease-in` |
| Slash popover open | `animate-in fade-in zoom-in-95` | 100ms | `ease-out` — shadcn Popover default |
| Mention popover open | Same as slash popover | 100ms | `ease-out` |
| Jump to bottom button | `animate-in fade-in slide-in-from-bottom-2` | 150ms | `ease-out` |
| Streaming indicator dot | `animate-pulse` (Tailwind) | continuous | — |
| Edit mode transition | No animation — immediate swap; avoids layout shift | — | — |
**`animate-cursor-blink` keyframe (add to `index.css`):**
```css
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.animate-cursor-blink {
animation: cursor-blink 800ms step-start infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-cursor-blink {
animation: none;
opacity: 1;
}
}
```
**Reduced motion:** All Phase 22 entrance/exit animations must respect `prefers-reduced-motion`. Use `motion-safe:` Tailwind prefix or `@media` guards matching the Phase 21 pattern in `index.css`.
---
## Performance Contract
| Requirement | Target | Implementation |
|-------------|--------|----------------|
| PERF-02 | First token ≤ 100ms server-to-UI | SSE connection opened before server begins generation; client-side: no React re-render batching delay — use `startTransition` for text accumulation to keep input responsive |
| PERF-03 | 1,000+ messages no jank | `@tanstack/react-virtual` with dynamic measurement; `overscan: 5`; avoid re-rendering all messages on token append — only the active streaming message re-renders |
**Token accumulation pattern:**
- Maintain a `streamingContent` string in `useStreamingChat` local state
- Append tokens via `setState(prev => prev + token)` — single string concat, not array push
- When stream ends, commit full message to React Query cache via `queryClient.setQueryData`; clear local `streamingContent`
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | All existing Phase 21 components (already installed) | not required |
| npm (non-registry) | `@tanstack/react-virtual` — standard npm package install via `pnpm add` | not applicable (not a shadcn registry block) |
| Third-party | none | not applicable |
**No third-party shadcn registries used in Phase 22.**
---
## Checker Sign-Off
- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS
**Approval:** pending