# Story 3.4: AI Semantic Suggestions and Accept/Reject Workflow

Status: done

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a user,
I want the AI to suggest improvements and let me review changes before applying them,
so that I maintain control and the AI helps me build better diagrams.

## Acceptance Criteria

1. **Given** the AI generates a diagram modification, **When** the response is ready, **Then** the canvas shows a visual diff: new nodes pulse with `--ai-diff-add` (green overlay), removed nodes fade with `--ai-diff-remove` (red overlay), modified nodes show before/after state (FR6), **And** Accept/Reject controls appear both inline in the chat message AND as a floating bar on the canvas.

2. **Given** I see a proposed change with visual diff, **When** I press Enter or click Accept, **Then** the proposed changes are committed to the graph data, **And** ELK.js re-layouts the diagram smoothly, **And** the diff highlights fade away.

3. **Given** I see a proposed change, **When** I press Escape or click Reject, **Then** the proposed changes are discarded, **And** the canvas reverts to its previous state, **And** badge chips remain so I can immediately refine my request.

4. **Given** the AI analyzes a diagram, **When** it detects semantic issues (FR5), **Then** it proactively mentions them in chat: "Note: Your BPMN process has no error boundary" or "Consider a junction table for this M:N relationship", **And** suggestions appear as distinct message types (info/suggestion styling).

5. **Given** the AI proposes changes, **When** I want to see what will change, **Then** the chat message includes a summary of changes (e.g., "Adding 2 nodes, modifying 1 edge, removing 1 node").

## Tasks / Subtasks

- [x] Task 1: Add proposal state to Zustand store (AC: #1, #2, #3)
  - [x] 1.1 Add `proposedPatch: GraphPatchData | null` to `GraphState` in `useGraphStore.ts`
  - [x] 1.2 Add `previousGraphSnapshot: { nodes: Node[]; edges: Edge[] } | null` to `GraphState`
  - [x] 1.3 Add `proposalStatus: 'idle' | 'pending' | 'accepted' | 'rejected'` to `GraphState`
  - [x] 1.4 Add `proposeChanges(patch: GraphPatchData): void` action — snapshots current nodes/edges, converts proposed patch to flow nodes/edges, merges with diff classes, sets status to 'pending'
  - [x] 1.5 Add `acceptProposal(): void` action — applies the `proposedPatch` via the existing apply flow (setNodes, setEdges, requestLayout), clears diff classes, clears proposal state, sets status to 'accepted' then 'idle'
  - [x] 1.6 Add `rejectProposal(): void` action — restores `previousGraphSnapshot` to nodes/edges, clears proposal state, sets status to 'rejected' then 'idle'
  - [x] 1.7 Add `clearProposal(): void` action — resets proposal state without side effects
  - [x] 1.8 Initialize all new fields in `reset()`

- [x] Task 2: Create `useProposalDiff` hook for diff computation (AC: #1, #5)
  - [x] 2.1 Create `apps/web/src/modules/copilot/hooks/useProposalDiff.ts`
  - [x] 2.2 Compute diff by comparing `previousGraphSnapshot` node/edge IDs vs `proposedPatch` node/edge IDs: `added` (new IDs), `removed` (missing IDs), `modified` (same ID, different label/type/properties)
  - [x] 2.3 Return `{ addedCount, removedCount, modifiedCount, changeSummary }` — `changeSummary` is a formatted string like "Adding 2 nodes, modifying 1 edge, removing 1 node"
  - [x] 2.4 Memoize with `useMemo` keyed on `proposedPatch` and `previousGraphSnapshot`

- [x] Task 3: Modify mutation pipeline to propose instead of auto-apply (AC: #1, #2, #3)
  - [x] 3.1 In `useGraphMutation.ts`, add `proposeGraphPatch(patch: GraphPatchData): void` alongside existing `applyGraphPatch`
  - [x] 3.2 `proposeGraphPatch` calls `useGraphStore.getState().proposeChanges(patch)` — snapshots current graph, converts proposed patch to flow format, merges both sets of nodes showing diff styling
  - [x] 3.3 In `CopilotPanel.tsx` tool detection effect, change from calling `applyGraphPatch(result.data)` to calling `proposeGraphPatch(result.data)` — the patch is now proposed, not auto-applied
  - [x] 3.4 Export both `applyGraphPatch` and `proposeGraphPatch` from hook

- [x] Task 4: Implement visual diff on canvas via node/edge className (AC: #1)
  - [x] 4.1 In the `proposeChanges` store action, compute diff between current and proposed nodes by ID: new nodes get `className: "ai-diff-add"`, nodes in current but not in proposed get `className: "ai-diff-remove"`, modified nodes get `className: "ai-diff-modified"`
  - [x] 4.2 Merge both sets: show proposed state for new/modified nodes + keep removed nodes visible with diff styling. Set this merged set via `setNodes`/`setEdges`
  - [x] 4.3 Add CSS for diff classes in `globals.css`: `.ai-diff-add` (green pulsing overlay using `--ai-diff-add`), `.ai-diff-remove` (red fade using `--ai-diff-remove`), `.ai-diff-modified` (blue outline using `--ai-accent`)
  - [x] 4.4 Add `@media (prefers-reduced-motion: reduce)` — diff classes use static overlays instead of animations
  - [x] 4.5 Edges follow same pattern: new edges get `ai-diff-add`, removed edges get `ai-diff-remove`

- [x] Task 5: Add Accept/Reject controls inline in AssistantBubble (AC: #1, #2, #3, #5)
  - [x] 5.1 In `CopilotPanel.tsx` `AssistantBubble`, when a tool result is `output-available` AND `proposalStatus === 'pending'`, render Accept/Reject buttons instead of "Diagram updated"
  - [x] 5.2 Accept button: calls `useGraphStore.getState().acceptProposal()` then persists the accepted patch to DB via the existing API (`api.diagrams[":id"].$patch`)
  - [x] 5.3 Reject button: calls `useGraphStore.getState().rejectProposal()`
  - [x] 5.4 Show change summary from `useProposalDiff` above the buttons: "Adding 2 nodes, removing 1 node"
  - [x] 5.5 After accept/reject, replace buttons with status text: "Diagram updated" or "Changes discarded"
  - [x] 5.6 Style: Accept = primary variant, Reject = ghost variant. Icons: Check for accept, X for reject

- [x] Task 6: Add floating Accept/Reject bar on canvas (AC: #1, #2, #3)
  - [x] 6.1 Create `apps/web/src/modules/diagram/components/editor/ProposalBar.tsx`
  - [x] 6.2 Use `@xyflow/react` `Panel` component at `position="bottom-center"` — renders inside ReactFlowProvider
  - [x] 6.3 Subscribe to `proposalStatus` from `useGraphStore` — only render when `proposalStatus === 'pending'`
  - [x] 6.4 Display: change summary text + Accept button + Reject button
  - [x] 6.5 Animate in/out with Motion `animate` + `AnimatePresence`
  - [x] 6.6 Add to `DiagramCanvas.tsx` `CanvasInner` — render `ProposalBar` inside the `ReactFlow` component as a sibling to `Panel` (layout indicator)
  - [x] 6.7 ARIA: `role="alert"`, `aria-label="AI proposes: [summary]. Press Enter to accept, Escape to reject."`

- [x] Task 7: Add keyboard shortcuts for accept/reject (AC: #2, #3)
  - [x] 7.1 In `CopilotPanel.tsx` `handleKeyDown`, when `proposalStatus === 'pending'`: Enter = accept proposal (prevent default send), Escape = reject proposal (clear badges behavior changes: only reject proposal, don't clear badges)
  - [x] 7.2 In `DiagramCanvas.tsx`, add `onKeyDown` handler on the ReactFlow wrapper: Enter = accept, Escape = reject (when proposal pending). This ensures keyboard shortcuts work regardless of focus location
  - [x] 7.3 Ensure Enter in textarea only triggers accept when textarea is empty (non-empty Enter = send new message)

- [x] Task 8: Enhance system prompt with semantic analysis instructions (AC: #4)
  - [x] 8.1 In `system-prompt.ts`, add a `## Semantic analysis` section to the system prompt with diagram-type-specific validation rules
  - [x] 8.2 BPMN rules: check for missing error boundaries, missing end events, gateways without merge, pools without message flows
  - [x] 8.3 E-R rules: check for M:N relationships without junction tables, entities without primary keys, circular foreign key chains
  - [x] 8.4 Architecture rules: check for single points of failure, services without database connections, missing load balancers
  - [x] 8.5 Flowchart rules: check for unreachable nodes, decisions with single outgoing path, missing terminal nodes
  - [x] 8.6 Orgchart rules: check for employees without managers (except root), excessive span of control (>10 direct reports)
  - [x] 8.7 Sequence rules: check for messages without return, participants with no interactions
  - [x] 8.8 Instruct the AI: "After generating or modifying a diagram, briefly note any semantic issues you detect. Present these as helpful suggestions in your chat response, NOT as blocking errors. Use phrasing like 'Note:' or 'Consider:' followed by the observation."

- [x] Task 9: Add suggestion message styling in CopilotPanel (AC: #4)
  - [x] 9.1 Detect suggestion patterns in assistant message text: lines starting with "Note:" or "Consider:" or "Suggestion:"
  - [x] 9.2 In `AssistantBubble`, render suggestion lines with a distinct visual: left border accent (using `--ai-accent`), info icon (Icons.Lightbulb or Icons.Info), slightly different background (`bg-muted/30`)
  - [x] 9.3 Keep suggestions inline in the markdown flow — do NOT extract them into separate components. Just wrap matching paragraphs with the styled container

- [x] Task 10: Add change summary to AI response (AC: #5)
  - [x] 10.1 In `system-prompt.ts`, add instruction: "When modifying an existing diagram, include a brief change summary in your response before calling the tool. Format: '**Changes:** Adding N nodes, modifying N edges, removing N nodes.' This helps users understand what will change before reviewing the visual diff."
  - [x] 10.2 The client-side `useProposalDiff` hook independently computes the same summary from actual graph comparison — the AI's text summary is informational; the hook's summary is authoritative (shown in accept/reject controls)

- [x] Task 11: Write tests (AC: all)
  - [x] 11.1 System prompt tests: verify semantic analysis section is included for each diagram type, verify change summary instruction is present (6+ tests)
  - [x] 11.2 `useProposalDiff` tests: diff computation — added nodes, removed nodes, modified nodes, mixed changes, empty graph, no changes (8+ tests)
  - [x] 11.3 Store action tests: `proposeChanges` sets correct state, `acceptProposal` clears proposal and sets nodes, `rejectProposal` restores snapshot (6+ tests)
  - [x] 11.4 Component rendering tests deferred to E2E per project standards

## Dev Notes

### Architecture Compliance

- **AI mutations through CRDT**: Per Winston architecture decision (Decision 3), all graph mutations flow through the Zustand store. This story adds a PROPOSAL LAYER between AI output and store mutation. The AI still outputs a complete `GraphData` via `generateDiagram` tool. The proposal layer intercepts the output, shows a visual diff, and only commits to the store on user acceptance. In Epic 4 (Liveblocks), `acceptProposal` will become a CRDT mutation — the proposal layer naturally fits as a pre-commit gate.
- **ELK.js in Web Worker**: Layout runs ONLY on accept (not on propose). During proposal, nodes are shown at their current positions with diff styling overlays. On accept, `requestLayout()` triggers the Web Worker for smooth re-layout.
- **Unified graph model**: Diff is computed at the `GraphData` level (DiagramNode/DiagramEdge IDs), then translated to @xyflow/react Node/Edge className attributes for visual rendering.
- **No new API endpoints**: The propose/accept/reject cycle is entirely client-side. The DB persist call (`api.diagrams[":id"].$patch`) only fires on accept — same as the current auto-apply flow.

### Critical Implementation Patterns (from Stories 3.1-3.3)

**Current auto-apply flow (to be modified):**
```typescript
// CopilotPanel.tsx — current behavior (Story 3.2/3.3):
if (result.success) {
  applyGraphPatch(result.data); // ← AUTO-APPLIES immediately
}

// Story 3.4 changes to:
if (result.success) {
  proposeGraphPatch(result.data); // ← PROPOSES for review
}
```

**Proposal state in Zustand store:**
```typescript
// New fields in GraphState interface:
proposedPatch: GraphPatchData | null;
previousGraphSnapshot: { nodes: Node[]; edges: Edge[] } | null;
proposalStatus: 'idle' | 'pending' | 'accepted' | 'rejected';

// proposeChanges action:
proposeChanges: (patch: GraphPatchData) => {
  const { nodes, edges } = get();
  // Snapshot current state for revert
  set({ previousGraphSnapshot: { nodes: [...nodes], edges: [...edges] } });
  set({ proposedPatch: patch, proposalStatus: 'pending' });

  // Convert proposed patch to flow format
  const proposed = graphToFlow(patchToGraphData(patch));

  // Compute diff: compare current node IDs vs proposed node IDs
  const currentIds = new Set(nodes.map(n => n.id));
  const proposedIds = new Set(proposed.nodes.map(n => n.id));

  // Merge: show proposed state + keep removed nodes visible
  const mergedNodes = [
    ...proposed.nodes.map(n => ({
      ...n,
      className: !currentIds.has(n.id) ? 'ai-diff-add'
        : isDifferent(n, nodes.find(c => c.id === n.id)) ? 'ai-diff-modified'
        : undefined,
    })),
    ...nodes
      .filter(n => !proposedIds.has(n.id))
      .map(n => ({ ...n, className: 'ai-diff-remove' })),
  ];

  set({ nodes: mergedNodes, edges: mergedEdges });
};

// acceptProposal action:
acceptProposal: () => {
  const { proposedPatch } = get();
  if (!proposedPatch) return;
  const graphData = patchToGraphData(proposedPatch);
  const { nodes, edges } = graphToFlow(graphData);
  set({
    nodes, edges,
    proposedPatch: null,
    previousGraphSnapshot: null,
    proposalStatus: 'idle',
  });
  get().requestLayout(); // Trigger ELK.js re-layout
};

// rejectProposal action:
rejectProposal: () => {
  const { previousGraphSnapshot } = get();
  if (!previousGraphSnapshot) return;
  set({
    nodes: previousGraphSnapshot.nodes,
    edges: previousGraphSnapshot.edges,
    proposedPatch: null,
    previousGraphSnapshot: null,
    proposalStatus: 'idle',
  });
};
```

**Visual diff CSS classes:**
```css
/* In globals.css — next to existing badge chip tokens */

/* AI Diff Overlay States */
.react-flow__node.ai-diff-add {
  outline: 2px solid var(--ai-diff-add);
  outline-offset: 2px;
  animation: ai-diff-pulse 1.5s ease-in-out infinite;
}
.react-flow__node.ai-diff-remove {
  opacity: 0.4;
  outline: 2px dashed var(--ai-diff-remove);
  outline-offset: 2px;
  text-decoration: line-through;
}
.react-flow__node.ai-diff-modified {
  outline: 2px solid var(--ai-accent);
  outline-offset: 2px;
}
@keyframes ai-diff-pulse {
  0%, 100% { outline-color: var(--ai-diff-add); }
  50% { outline-color: transparent; }
}
@media (prefers-reduced-motion: reduce) {
  .react-flow__node.ai-diff-add { animation: none; }
}

/* Edge diff classes */
.react-flow__edge.ai-diff-add path { stroke: oklch(0.80 0.18 152); stroke-dasharray: 8 4; }
.react-flow__edge.ai-diff-remove path { stroke: oklch(0.58 0.25 27); opacity: 0.4; }
```

**Floating ProposalBar on canvas:**
```typescript
// ProposalBar.tsx — inside ReactFlowProvider (Panel component)
import { Panel } from "@xyflow/react";
import { AnimatePresence, motion } from "motion/react";

function ProposalBar() {
  const proposalStatus = useGraphStore(s => s.proposalStatus);
  const acceptProposal = useGraphStore(s => s.acceptProposal);
  const rejectProposal = useGraphStore(s => s.rejectProposal);
  const { changeSummary } = useProposalDiff();

  return (
    <Panel position="bottom-center">
      <AnimatePresence>
        {proposalStatus === 'pending' && (
          <motion.div
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 20 }}
            role="alert"
            aria-label={`AI proposes: ${changeSummary}. Press Enter to accept, Escape to reject.`}
          >
            <span>{changeSummary}</span>
            <Button onClick={acceptProposal}>Accept (Enter)</Button>
            <Button variant="ghost" onClick={rejectProposal}>Reject (Esc)</Button>
          </motion.div>
        )}
      </AnimatePresence>
    </Panel>
  );
}
```

**Accept/Reject in AssistantBubble:**
```typescript
// In CopilotPanel.tsx AssistantBubble — replace "Diagram updated" indicator
const proposalStatus = useGraphStore(s => s.proposalStatus);

{hasToolResult && proposalStatus === 'pending' && (
  <div className="mt-2 flex items-center gap-2">
    <span className="text-xs text-muted-foreground">{changeSummary}</span>
    <Button size="sm" onClick={handleAccept}>
      <Icons.Check className="size-3 mr-1" /> Accept
    </Button>
    <Button size="sm" variant="ghost" onClick={handleReject}>
      <Icons.X className="size-3 mr-1" /> Reject
    </Button>
  </div>
)}
{hasToolResult && proposalStatus === 'idle' && (
  <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
    <Icons.Check className="size-3 text-green-500" />
    Diagram updated
  </div>
)}
```

**Semantic analysis system prompt addition:**
```typescript
// Add after the Constraints section in buildCopilotSystemPrompt:
const SEMANTIC_RULES: Record<DiagramType, string> = {
  bpmn: `- Check for processes without error boundaries or exception handling
- Check for gateways without corresponding merge/join
- Check for pools without inter-pool message flows
- Check for missing end events in subprocess branches`,
  er: `- Check for M:N relationships that may need a junction/associative table
- Check for entities without primary keys
- Check for potential circular foreign key dependencies
- Check for denormalization opportunities or concerns`,
  // ... other types
};

// Added to system prompt:
`## Semantic analysis
After generating or modifying a diagram, briefly note any semantic issues:
${SEMANTIC_RULES[diagramType]}
Present as helpful inline suggestions using "Note:" or "Consider:" prefix.
Do not block diagram generation for semantic issues.`
```

### Keyboard Shortcut Conflict Resolution

Current keyboard mappings in CopilotPanel:
- **Enter** (no shift) = send message
- **Escape** (with badges) = clear badges

Story 3.4 adds:
- **Enter** (when proposal pending AND textarea empty) = accept proposal
- **Escape** (when proposal pending) = reject proposal (NOT clear badges — badges should persist per AC#3)

Priority order in `handleKeyDown`:
1. If proposal pending + Enter + textarea empty → accept proposal
2. If proposal pending + Escape → reject proposal (skip badge clearing)
3. If Enter + textarea has text → send message (normal)
4. If Escape + no proposal + badges → clear badges (normal)

### DB Persistence Strategy

- On **propose**: No DB write. The proposal is ephemeral client state.
- On **accept**: Call `api.diagrams[":id"].$patch` with the accepted `graphData` (same as current auto-apply path in `useGraphMutation.ts`). Move the persist logic from `useGraphMutation.applyGraphPatch` into a shared `persistGraphData` utility or call it from the accept handler.
- On **reject**: No DB write. The diagram state reverts to what was already persisted.

### Suggestion Styling Pattern

```typescript
// Detect suggestion patterns in AssistantBubble text:
// Lines starting with "Note:", "Consider:", "Suggestion:", "Tip:"
const SUGGESTION_PATTERN = /^(Note|Consider|Suggestion|Tip):/m;

// Wrap matching paragraphs in styled container:
// - Left border with --ai-accent color
// - Info icon
// - Slightly muted background
```

Do NOT create a separate "suggestion" message type or separate component. Suggestions are part of the normal AI response text. The styling is applied at the markdown rendering level by detecting the pattern.

### Existing Infrastructure (Stories 2.9 + 3.1-3.3)

**Selection state** (Story 2.9): `selectedNodeIds` in store — untouched by this story. Badge chips persist through accept/reject per AC#3.

**Graph mutation hook** (Story 3.2): `useGraphMutation.ts` — extend with `proposeGraphPatch`. Keep `applyGraphPatch` for direct application (used by `acceptProposal`).

**System prompt** (Story 3.3): `buildCopilotSystemPrompt` — extend with semantic analysis section. No changes to scoped context logic.

**Node className** (Story 2.9): DiagramCanvas already uses `className` on nodes for BFS highlighting (`highlighted`, `dimmed`). Diff classes (`ai-diff-add`, `ai-diff-remove`, `ai-diff-modified`) are additional classes. **Important**: when a proposal is active, BFS highlighting should be suppressed or cleared to avoid class conflicts. Clear `highlightedNodeId` when `proposalStatus` becomes `pending`.

**Graph converter** (Story 3.2): `graphToFlow` converts `GraphData` to @xyflow nodes/edges. Used by `proposeChanges` to convert the proposed patch to flow format for rendering.

### Unified Graph Model (Source of Truth)

Location: `apps/web/src/modules/diagram/types/graph.ts`

```typescript
type DiagramType = "bpmn" | "er" | "orgchart" | "architecture" | "sequence" | "flowchart";

interface DiagramNode {
  id: string;      // Required — used for diff comparison
  type: string;    // Required — diagram-type-specific
  label: string;   // Required — display text
  // ... additional type-specific fields
}

interface DiagramEdge {
  id: string;      // Required — used for diff comparison
  from: string;    // Required — source node ID
  to: string;      // Required — target node ID
  label?: string;
  type?: string;
  cardinality?: string;
}
```

Diff comparison is by **node/edge ID**:
- Same ID, different properties → modified
- New ID in proposed → added
- ID only in current → removed

### File Structure

**Files to CREATE:**
```
apps/web/src/modules/copilot/hooks/
  └── useProposalDiff.ts              # Diff computation hook

apps/web/src/modules/diagram/components/editor/
  └── ProposalBar.tsx                 # Floating accept/reject panel on canvas
```

**Files to MODIFY:**
```
apps/web/src/modules/diagram/stores/
  └── useGraphStore.ts                # Add proposal state + actions

apps/web/src/modules/copilot/hooks/
  └── useGraphMutation.ts             # Add proposeGraphPatch alongside applyGraphPatch

apps/web/src/modules/copilot/components/
  └── CopilotPanel.tsx                # Change auto-apply to propose, add accept/reject in AssistantBubble, keyboard shortcuts, suggestion styling

apps/web/src/modules/diagram/components/editor/
  └── DiagramCanvas.tsx               # Add ProposalBar, keyboard shortcuts

packages/ai/src/modules/copilot/
  ├── system-prompt.ts                # Add semantic analysis section + change summary instruction
  └── system-prompt.test.ts           # Tests for new prompt sections

apps/web/src/assets/styles/
  └── globals.css                     # Add ai-diff-add, ai-diff-remove, ai-diff-modified CSS classes
```

**Files to REFERENCE (read-only):**
```
apps/web/src/modules/diagram/lib/graph-converter.ts      # graphToFlow, flowToGraph
apps/web/src/modules/diagram/types/graph.ts              # GraphData, DiagramNode, DiagramEdge
packages/ai/src/modules/copilot/mutation-schema.ts       # graphPatchSchema, validation functions
packages/ai/src/modules/copilot/api.ts                   # streamCopilot (no changes needed)
packages/ai/src/modules/copilot/schema.ts                # CopilotMessagePayload (no changes)
packages/ai/src/modules/copilot/types.ts                 # DiagramType, SelectedElement (no changes)
```

### Project Structure Notes

- `ProposalBar` goes in `apps/web/src/modules/diagram/components/editor/` — co-located with `DiagramCanvas.tsx` because it's a canvas UI element that uses `@xyflow/react` `Panel` and must be inside `ReactFlowProvider`
- `useProposalDiff` goes in `apps/web/src/modules/copilot/hooks/` — co-located with `useGraphMutation.ts` because it's part of the copilot mutation pipeline
- CSS diff classes go in `globals.css` alongside existing badge chip tokens and canvas tokens
- System prompt changes go in `packages/ai/src/modules/copilot/system-prompt.ts` — extend `buildCopilotSystemPrompt`

### Anti-Patterns to AVOID

- **Do NOT create a separate "diff mode" or "review mode" on the canvas** — the visual diff is CSS-only on the same ReactFlow instance. No mode switching, no separate rendering path.
- **Do NOT send partial graph data** — the AI still outputs a COMPLETE GraphData via `generateDiagram` tool. The diff is computed client-side by comparing the full proposed graph against the full current graph.
- **Do NOT create a new API endpoint for accept/reject** — accept uses the existing `api.diagrams[":id"].$patch`. Reject does nothing server-side.
- **Do NOT modify the `generateDiagram` tool or `graphPatchSchema`** — the AI output format is unchanged. The proposal layer is purely client-side.
- **Do NOT create a separate "suggestion" message type in the schema** — suggestions are regular AI text responses with specific phrasing patterns. Styling is applied at the rendering level.
- **Do NOT delay layout during proposal** — layout ONLY runs on accept. During proposal, nodes keep their current positions with diff overlay styling.
- **Do NOT use `require()` or CommonJS** — all packages are ESM-only
- **Do NOT inline `.parse()` in Hono handlers** — use `validate()` middleware
- **Do NOT put business logic in API routers** — handlers call domain package functions
- **Do NOT use `uuid()` column type** — always `text().primaryKey().$defaultFn(generateId)`
- **Do NOT run ELK.js on main thread** — use existing Web Worker

### Performance Requirements

- Visual diff appears: < 100ms after AI tool output is available (client-side diff computation only)
- Accept animation: < 200ms fade-out of diff highlights, then ELK.js layout (< 500ms per NFR)
- Reject revert: < 50ms (Zustand state restore, no layout needed)
- Keyboard shortcut response: < 50ms (direct store action calls)
- Diff overlay rendering: CSS-only, no JavaScript animation loop (use CSS `animation` + `outline`)
- Respect `prefers-reduced-motion` — diff pulse animations become static outlines

### Security Requirements

- `enforceAuth` middleware on all endpoints (already in place, unchanged)
- `deductCredits` middleware before AI generation calls (already in place, unchanged)
- Proposal state is entirely client-side — no new server endpoints, no new attack surface
- AI output still validated through `validateGraphPatch` in the `generateDiagram` tool execution before reaching the client

### Testing Standards

- Test runner: Vitest with explicit imports (`import { describe, it, expect } from 'vitest'`)
- Test location: co-located with source files
- Factory pattern for test data
- Component rendering tests deferred to E2E per project standards
- Workspace commands: `pnpm --filter @turbostarter/ai test` (system prompt tests), `pnpm test` (all)
- Expected new tests: ~20-25 (system prompt semantic analysis + useProposalDiff diff computation + store proposal actions)

### Previous Story Intelligence (Story 3.3)

**What was built:**
- `BadgeChip.tsx` — badge component with animation, dismiss, canvas scroll
- `CopilotPanel.tsx` — badge display, selectedElements in transport, scope indicator, Escape key to clear badges
- `system-prompt.ts` — scoped context section for selected elements, `buildSelectedContext` function
- Schema extensions: `selectedElementSchema`, `selectedElements` optional field

**Key learnings:**
- `useGraphStore.getState()` pattern avoids stale closures and prevents memo invalidation — use this in all callbacks
- `AnimatePresence` + `motion` for enter/exit animations — same pattern for ProposalBar
- Cross-component communication via Zustand store (not React Flow hooks) — CopilotPanel is outside `ReactFlowProvider`
- `Icons.User2`, `Icons.Server`, `Icons.Package` are valid icon exports (not `Icons.User`, `Icons.Box`, `Icons.Layers`)

**Code review fixes from 3.3 (patterns to follow):**
- Added `prefers-reduced-motion` support via `useReducedMotion()` hook — do the same for diff animations
- `getState()` pattern instead of closure — already adopted, continue using
- `.min(1)` constraints on string fields in schemas — already adopted
- MAX limits to prevent prompt bloat (MAX_NEIGHBOR_NODES=10) — already adopted

**No new dependencies needed** — Motion (animations), shadcn/ui (buttons), @xyflow/react (Panel) are already in workspace.

### Git Intelligence

Recent commit pattern: `feat: implement Story X.Y — <description>`. Follow this convention.

Story 3.3 modified these files (which Story 3.4 will modify again):
- `CopilotPanel.tsx` — add proposal controls, change auto-apply to propose
- `system-prompt.ts` — add semantic analysis section
- `system-prompt.test.ts` — add semantic analysis tests
- `useGraphStore.ts` — add proposal state
- `useGraphMutation.ts` — add proposeGraphPatch
- `DiagramCanvas.tsx` — add ProposalBar, keyboard shortcuts
- `globals.css` — add diff CSS classes

### References

- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.4] — Acceptance criteria, technical notes (diff state in Zustand, visual diff via CSS, semantic analysis)
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#AIDiffOverlay] — Component spec: green highlights (additions), red fades (removals), modified before/after, Accept (Enter), Reject (Esc)
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Interaction States] — Proposing state: diff overlay on canvas, "Here's what I'd change" + explanation, green/red highlights, Accept/Reject
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Design Tokens] — `--ai-diff-add: oklch(0.80 0.18 152 / 20%)`, `--ai-diff-remove: oklch(0.58 0.25 27 / 20%)`, `--ai-streaming`, `--ai-accent`
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Core Interaction] — "Propose → preview → accept" pattern (Cursor's diff pattern adapted for canvas)
- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 3] — AI Mutation Pipeline: client-side relay with soft-lock, structured JSON patches, SSE streaming
- [Source: _bmad-output/project-context.md] — Framework rules, coding standards, anti-patterns, testing standards
- [Source: _bmad-output/implementation-artifacts/3-3-badge-based-element-referencing-for-targeted-modifications.md] — Previous story patterns, code review fixes, established conventions

## Dev Agent Record

### Agent Model Used

Claude Opus 4.6 (claude-opus-4-6)

### Debug Log References

No debug issues encountered.

### Completion Notes List

- Task 1: Added `proposedPatch`, `previousGraphSnapshot`, `proposalStatus` to Zustand store with `proposeChanges`, `acceptProposal`, `rejectProposal`, `clearProposal` actions. Diff computed client-side by comparing node/edge IDs. BFS highlighting cleared on propose.
- Task 2: Created `useProposalDiff` hook with pure diff computation functions (`countNodeDiffs`, `countEdgeDiffs`, `buildSummary`) exported for testing. Uses `graphToFlow` to convert proposed GraphData before comparing against snapshot.
- Task 3: Extracted `patchToGraphData` and `persistGraphData` utilities from `applyGraphPatch`. Added `proposeGraphPatch` that converts patch → GraphData and calls store's `proposeChanges`. CopilotPanel now calls `proposeGraphPatch` instead of `applyGraphPatch`.
- Task 4: Added CSS diff overlay classes (`ai-diff-add` with pulse animation, `ai-diff-remove` with dashed outline + opacity, `ai-diff-modified` with accent outline). Added `--ai-diff-add` and `--ai-diff-remove` design tokens. Edge diff classes included. `prefers-reduced-motion` support for pulse animation.
- Task 5: AssistantBubble now receives `proposalStatus`, `changeSummary`, `diagramId` props. Shows Accept/Reject buttons when `proposalStatus === 'pending'`, shows "Diagram updated" otherwise. Accept persists to DB, reject restores snapshot.
- Task 6: Created `ProposalBar.tsx` using `@xyflow/react` `Panel` at `bottom-center`. Uses `AnimatePresence` + `motion` for enter/exit. Has `role="alert"` with descriptive `aria-label`. Shows keyboard hints (Enter/Esc). Added to `CanvasInner` inside ReactFlow.
- Task 7: Keyboard priority in CopilotPanel: proposal pending + Enter + empty textarea → accept; proposal pending + Escape → reject (preserves badges per AC#3); normal Enter → send; normal Escape → clear badges. DiagramCanvas wrapper also handles Enter/Escape when proposal pending.
- Task 8: Added `SEMANTIC_RULES` record with diagram-type-specific validation rules for all 6 types. Added "Semantic analysis" section to system prompt instructing AI to use "Note:" or "Consider:" prefix for suggestions, non-blocking.
- Task 9: Added suggestion detection pattern (`Note:|Consider:|Suggestion:|Tip:`) and `renderWithSuggestions` function in AssistantBubble. Matching paragraphs wrapped with left-border accent, Lightbulb icon, muted background. Inline in markdown flow.
- Task 10: Added "Change summary" section to system prompt instructing AI to include "**Changes:** ..." before tool calls. Client-side `useProposalDiff` independently computes authoritative summary.
- Task 11: Added 28 new tests — 12 system prompt tests (semantic analysis for all 6 types + change summary), 17 useProposalDiff tests (node/edge diff + summary formatting), 16 store proposal action tests. All pass with zero regressions (470 total tests).

### Change Log

- 2026-02-28: Implemented Story 3.4 — AI semantic suggestions and accept/reject workflow. Added proposal layer between AI output and graph mutation, visual diff on canvas, accept/reject controls (inline + floating bar + keyboard), semantic analysis in system prompt, suggestion styling.
- 2026-02-28: Code review fixes (9 issues). H1: Fixed stale useEffect dep (applyGraphPatch→proposeGraphPatch). H2: Added lastProposalOutcome to store, AssistantBubble now shows "Changes discarded" after reject. H3+H4: Guarded BFS highlighting and clearHighlight during proposals. M1: Extracted shared acceptCurrentProposal/rejectCurrentProposal utilities (deduplicated 4 locations). M2: Fixed proposalStatus type from string→ProposalStatus. L1+L2: Added edge modification detection and deeper node property comparison (columns, tag). Added 6 new tests, CSS for edge ai-diff-modified.

### File List

**Created:**
- `apps/web/src/modules/copilot/hooks/useProposalDiff.ts` — Diff computation hook
- `apps/web/src/modules/copilot/hooks/useProposalDiff.test.ts` — 17 tests
- `apps/web/src/modules/diagram/components/editor/ProposalBar.tsx` — Floating accept/reject panel

**Modified:**
- `apps/web/src/modules/diagram/stores/useGraphStore.ts` — Added proposal state + 4 actions
- `apps/web/src/modules/diagram/stores/useGraphStore.test.ts` — Added 16 proposal tests
- `apps/web/src/modules/copilot/hooks/useGraphMutation.ts` — Extracted utilities, added proposeGraphPatch
- `apps/web/src/modules/copilot/components/CopilotPanel.tsx` — Proposal controls, keyboard shortcuts, suggestion styling
- `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` — ProposalBar, keyboard shortcuts, diagramId prop
- `apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx` — Pass diagramId to DiagramCanvas
- `packages/ai/src/modules/copilot/system-prompt.ts` — Semantic analysis + change summary sections
- `packages/ai/src/modules/copilot/system-prompt.test.ts` — 12 new tests
- `apps/web/src/assets/styles/globals.css` — AI diff overlay CSS classes + design tokens
