nexus/.planning/phases/22-agent-streaming/22-UI-SPEC.md
Nexus Dev 3ab0955da9 docs(22): fix UI-SPEC checker blocks — copywriting, typography, spacing, color note
- Replace generic "Save"/"Cancel" with "Save edit"/"Discard edit" in inline edit controls
- Add solution path to agent load error: "Could not load agents. Refresh to try again."
- Eliminate 12px font size by using 13px + text-muted-foreground for slash command descriptions (4 sizes total: 11/13/15/20px)
- Replace gap-1.5 (6px) with gap-2 (8px) in identity bar — multiples-of-4 compliance
- Add theming note on bg-cyan-400 semantic token promotion path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 03:55:47 +00:00

507 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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-2 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-2` (8px) — between icon and agent name; matches existing `gap-2` pattern in ChatPanel header
- 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, slash command descriptions (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 |
**Weights used:** 400 (regular) and 600 (semibold). No additional weights. Same constraint as Phase 21.
**Slash command descriptions:** Rendered at 13px / 400 with `text-muted-foreground` — color/opacity distinction from the 13px / 400 command label is sufficient; no separate size needed. This keeps the total distinct sizes at 4 (11px, 13px, 15px, and 20px from Phase 21 markdown headings).
**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. Note: `bg-cyan-400` bypasses the CSS variable system. If theming requirements evolve (e.g. a theme requires a different streaming color), promote this to a semantic token such as `--streaming-indicator` in the CSS variable layer.
### 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 edit" and "Discard edit" buttons appear below; send button hidden |
| Edit textarea + click "Save edit" | POST updated message to server; assistant messages after this message are removed from thread (regeneration); new streaming response begins |
| Click "Discard edit" | Textarea reverts to read-only bubble; no changes |
| Save with empty content | "Save edit" 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 edit" | `variant="default" size="sm"` |
| Inline edit cancel button | "Discard edit" | `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; `text-muted-foreground` |
| Slash command: `/ask-pm` description | "Route to PM" | Second line; `text-muted-foreground` |
| Slash command: `/ask-engineer` description | "Route to Engineer" | Second line; `text-muted-foreground` |
| Slash command: `/task` description | "Create a task" | Second line; `text-muted-foreground` |
| Slash command: `/search` description | "Search conversations" | Second line; `text-muted-foreground`; "Coming soon" suffix in muted text; greyed out |
| 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. Refresh to try again." — 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 — "Save edit" and "Discard edit" (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