diff --git a/_bmad-output/implementation-artifacts/3-4-ai-semantic-suggestions-and-accept-reject-workflow.md b/_bmad-output/implementation-artifacts/3-4-ai-semantic-suggestions-and-accept-reject-workflow.md
new file mode 100644
index 0000000..470e246
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/3-4-ai-semantic-suggestions-and-accept-reject-workflow.md
@@ -0,0 +1,565 @@
+# Story 3.4: AI Semantic Suggestions and Accept/Reject Workflow
+
+Status: done
+
+
+
+## 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 (
+