- Fix ChatBubble to handle non-string content with String() wrapper - Fix API route to use generateText for non-streaming requests - Add @ai-sdk/openai-compatible for non-OpenAI providers (DeepSeek, etc.) - Use Chat Completions API instead of Responses API for compatible providers - Update ChatBubble tests and fix component exports to kebab-case - Remove stale PascalCase ChatBubble.tsx file
27 KiB
Story 2.4: Export & Copy Actions
Status: review
Story
As a user, I want to copy the text or save the post, So that I can publish it on LinkedIn or save it for later.
Acceptance Criteria
-
Copy to Clipboard on Thumbs Up
- Given the user likes the draft
- When they click "Thumbs Up" or "Copy"
- Then the full Markdown text is copied to the clipboard
- And a success toast/animation confirms the action
-
Save Draft as Completed
- Given the draft is finalized
- When the user saves it
- Then it is marked as "Completed" in the local database
- And the user is returned to the Home/History screen
Tasks / Subtasks
-
Implement Clipboard Copy Functionality
- Create
copyToClipboard()utility insrc/lib/utils/clipboard.ts - Support copying Markdown formatted text
- Handle clipboard API errors gracefully
- Add fallback for older browsers
- Create
-
Create Success Feedback Toast
- Create
CopySuccessToast.tsxcomponent insrc/components/features/feedback/ - Show confetti or success animation on copy
- Auto-dismiss after 3 seconds
- Include haptic feedback on mobile devices
- Accessible: announce to screen readers
- Create
-
Extend DraftActions with Copy/Save
- Add "Copy" button variant (separate from Thumbs Up)
- Wire Thumbs Up to both copy AND save actions
- Add "Just Copy" option (copy without closing sheet)
- Add "Save & Close" action
-
Implement Draft Completion Logic
- Add
completeDraft(draftId: number)action to ChatStore - Update draft status from 'draft' to 'completed' in IndexedDB
- Clear
currentDraftfrom store after completion - Close DraftViewSheet after completion
- Navigate to Home/History screen (deferred to Epic 3)
- Add
-
Implement DraftService Completion Method
- Add
markAsCompleted(draftId: string)tosrc/lib/db/draft-service.ts - Update DraftRecord status in IndexedDB
- Add
completedAttimestamp - Return updated draft record
- Add
-
Create History Screen Navigation
- Add navigation to Home/History after save
- Ensure completed draft appears in history feed
- Scroll to newly saved draft in history
- Highlight the newly saved entry
-
Add Copy Accessibility Features
- Add
aria-labelto all copy buttons - Announce "Copied to clipboard" to screen readers
- Support keyboard shortcuts (Cmd+C / Ctrl+C) (deferred - enhancement)
- Ensure focus management after copy action (deferred - enhancement)
- Add
-
Test Copy & Save End-to-End
- Unit test: clipboard.copyText() with Markdown (12 tests)
- Unit test: DraftService.markAsCompleted() updates status (3 tests)
- Integration test: Thumbs Up -> Copy -> Save flow (DraftViewSheet tests)
- Integration test: Copy button only (no save) (DraftViewSheet tests)
- Edge case: Clipboard permission denied (clipboard fallback tests)
- Edge case: Draft already marked as completed (DraftService tests)
- Edge case: Copy with very long draft content (deferred - stress test)
-
Test History Integration
- Integration test: Completed draft appears in history feed (deferred to Epic 3)
- Integration test: Copy action from history view (Epic 3)
- Test: Multiple drafts saved in session (deferred to Epic 3)
Dev Notes
Architecture Compliance (CRITICAL)
Logic Sandwich Pattern - DO NOT VIOLATE:
- UI Components MUST NOT import
src/lib/dbor clipboard utilities directly - All completion logic MUST go through
ChatServicelayer - ChatService then calls
DraftServicefor database operations - Components use Zustand store via atomic selectors only
- Services return plain objects, not Dexie observables
State Management - Atomic Selectors Required:
// BAD - Causes unnecessary re-renders
const { currentDraft } = useChatStore();
// GOOD - Atomic selectors
const currentDraft = useChatStore(s => s.currentDraft);
const completeDraft = useChatStore(s => s.completeDraft);
Local-First Data Boundary:
- Completed drafts MUST be stored in IndexedDB
- Status change from 'draft' to 'completed' persists locally
- Clipboard operations are client-side only (no server interaction)
- Draft history is queryable from history view (Epic 3)
Edge Runtime Constraint:
- This story does NOT require Edge API calls (clipboard is client-side only)
- All operations happen in the browser for privacy and speed
Architecture Implementation Details
Issue Resolution (Review Fixes):
- Logic Sandwich Violation: Fixed
ChatStore.completeDraftto callChatService.approveDraftinstead of direct DB acton. - Missing Component: Created
CopyButton.tsxand refactoredDraftActionsto use it. - Redundancy: Consolidated
approveDraftandcompleteDraftin Store.
Story Purpose: This story implements the "Export & Completion" flow - the final step where users copy their polished draft to clipboard and save it to their local history. This completes the core value loop: Vent -> Generate -> Refine -> Export.
State Management Extensions:
// Add to ChatStore (src/lib/store/chat-store.ts)
interface ChatStore {
// Existing state
currentDraft: Draft | null;
// New actions for this story
completeDraft: (draftId: string) => Promise<void>;
copyDraftToClipboard: (draftId: string) => Promise<void>;
copyAndSaveDraft: (draftId: string) => Promise<void>;
}
Completion Logic Flow:
- User views draft in DraftViewSheet (Story 2.2)
- User taps Thumbs Up OR Copy button
- Thumbs Up path:
- Copy Markdown to clipboard
- Show success toast with confetti
- Mark draft as 'completed' in IndexedDB
- Clear currentDraft from store
- Close DraftViewSheet
- Navigate to Home/History
- Scroll to/highlight newly saved entry
- Copy Only path:
- Copy Markdown to clipboard
- Show success toast
- Keep sheet open (user can continue viewing)
Clipboard Utility Pattern:
// src/lib/utils/clipboard.ts
export class ClipboardUtil {
static async copyMarkdown(markdown: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(markdown);
return true;
} catch (error) {
// Fallback for older browsers
return this.fallbackCopy(markdown);
}
}
private static fallbackCopy(text: string): boolean {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
return success;
}
}
Draft Completion in Database:
// src/lib/db/draft-service.ts
export class DraftService {
static async markAsCompleted(draftId: string): Promise<Draft> {
await db.drafts.update(draftId, {
status: 'completed',
completedAt: Date.now()
});
return this.getDraftById(draftId);
}
}
Files to Create:
src/lib/utils/clipboard.ts- Clipboard utility with fallbacksrc/components/features/feedback/CopySuccessToast.tsx- Success feedbacksrc/components/features/draft/CopyButton.tsx- Standalone copy button
Files to Modify:
src/lib/store/chat-store.ts- Add completion actionssrc/lib/db/draft-service.ts- Add markAsCompleted() methodsrc/services/chat-service.ts- Add completion orchestrationsrc/components/features/draft/DraftActions.tsx- Add Copy/Save buttonssrc/app/(main)/page.tsx- Navigate to history after save (optional scroll)
UX Design Specifications
From UX Design Document:
Completion Reward Pattern:
- Thumbs Up triggers "You're done!" animation
- Confetti or haptic feedback provides dopamine hit
- Success toast confirms copy action
- Auto-navigation to history reinforces "saved to your journal"
Visual Feedback - Success State:
graph TD
A[User Taps Thumbs Up] --> B[Copy to Clipboard]
B --> C[Show Success Toast]
C --> D[Confetti Animation]
D --> E[Mark as Completed]
E --> F[Close Sheet]
F --> G[Navigate to History]
G --> H[Highlight New Entry]
Toast Design Specifications:
- Position: Top-center or bottom-center (mobile)
- Duration: 3 seconds auto-dismiss
- Icon: Checkmark or clipboard icon
- Text: "Copied to clipboard!" or "Draft saved!"
- Animation: Fade in + slide up
- Confetti: Subtle particle burst on success
Button Hierarchy:
- Primary (Thumbs Up): Copy + Save + Close (complete action)
- Secondary (Copy Only): Copy to clipboard, keep sheet open
- Tertiary (Close): Dismiss without saving
Tone and Emotion:
- Success state should feel celebratory
- "You're done, great job!" messaging
- Reinforces the value of completing the ritual
Micro-interactions:
- Thumbs Up should have "spring" animation
- Haptic feedback on mobile (vibration)
- Smooth transition to history screen
- New entry in history has subtle highlight/fade-in
Previous Story Intelligence (from Stories 2.1, 2.2, and 2.3)
Patterns Established (must follow):
- Logic Sandwich Pattern: UI -> Zustand -> Service -> DB (strictly enforced)
- Atomic Selectors: All state access uses
useChatStore(s => s.field) - Draft Storage: Drafts stored in IndexedDB via
draftstable - DraftViewSheet: Sheet component that responds to
currentDraftstate - Draft Status Flow: 'draft' -> 'regenerated' -> 'completed'
Key Files from Previous Stories:
src/lib/db/draft-service.ts- Draft CRUD operations (add completion method)src/lib/store/chat-store.ts- Has currentDraft, add completion actionssrc/components/features/draft/DraftActions.tsx- Thumbs Up/Down, add Copy/Savesrc/components/features/draft/DraftViewSheet.tsx- Sheet componentsrc/services/chat-service.ts- Chat orchestration (add completion flow)
Learnings to Apply:
- Story 2.2 established DraftViewSheet auto-opens on currentDraft - clear it to close
- Story 2.3 established draft versioning - 'completed' status marks the final version
- Use same success animation pattern from Story 2.2 (shimmer -> reveal)
- Follow same error handling pattern (retry on clipboard failure)
- Story 2.1 established DraftRecord structure - add
completedAttimestamp
Draft Data Structure (extending from Story 2.1):
interface DraftRecord {
id: string;
sessionId: string;
title: string;
content: string; // Markdown formatted
tags: string[];
createdAt: number;
completedAt?: number; // NEW: Set when draft is marked as completed
status: 'draft' | 'completed' | 'regenerated';
}
Integration with Refinement (Story 2.3):
- If user refined draft, the final version is marked as 'completed'
- Original draft(s) with status 'regenerated' remain in history
- Only the latest draft (the one user approved) gets 'completed' status
- History view (Epic 3) will show all versions, highlighting the completed one
Clipboard Implementation Specifications
Browser Clipboard API:
// Modern browsers (Chrome 66+, Firefox 63+, Safari 13.1+)
navigator.clipboard.writeText(markdown)
.then(() => showSuccessToast())
.catch(() => showFallbackMessage());
Fallback for Older Browsers:
// Create hidden textarea, select, execCommand('copy')
// Remove textarea after copy
// Returns boolean success
Clipboard Permissions:
- Clipboard API requires user gesture (button click)
- No permissions needed for writeText() in active tab
- May fail in iframes or cross-origin contexts
Accessibility for Copy:
<button
onClick={handleCopy}
aria-label="Copy draft to clipboard"
className="..."
>
<CopyIcon />
<span className="sr-only">Copy</span>
</button>
// After copy, announce to screen readers
useEffect(() => {
if (copied) {
announceToScreenReader('Draft copied to clipboard');
}
}, [copied]);
Keyboard Shortcut Support:
- Detect Cmd+C / Ctrl+C when draft is visible
- Show tooltip: "Press Cmd+C to copy"
- Prevent interference with browser default
Testing Requirements
Unit Tests:
ClipboardUtil.copyMarkdown()copies text correctlyClipboardUtil.copyMarkdown()handles errors gracefullyClipboardUtil.fallbackCopy()works for older browsersDraftService.markAsCompleted()updates statusDraftService.markAsCompleted()sets completedAt timestampChatStore.completeDraft()clears currentDraftChatStore.copyDraftToClipboard()calls ClipboardUtil
Integration Tests:
- Full completion flow: Thumbs Up -> Copy -> Save -> Navigate
- Copy only flow: Copy button -> Toast -> Sheet stays open
- Draft appears in history after completion
- Multiple drafts in session show all completed drafts
- Refinement + completion: Refined draft marked as completed
Edge Cases:
- Clipboard permission denied: Show error message
- Clipboard API unavailable: Use fallback method
- Draft already completed: Show message, don't duplicate
- Very long draft content: Should handle within clipboard limits
- Copy during refinement: Should work (copy current visible draft)
- Navigate away during copy: Should handle gracefully
Accessibility Tests:
- Screen reader announces "Copied to clipboard"
- Keyboard navigation works for all copy buttons
- Focus management after copy
- ARIA labels on all copy buttons
Performance Tests:
- Copy action completes within 500ms
- Success toast appears within 100ms
- Navigation to history is smooth (< 300ms)
Component Implementation Details
CopySuccessToast Component:
// src/components/features/feedback/CopySuccessToast.tsx
interface CopySuccessToastProps {
message: string;
duration?: number;
onClose: () => void;
}
export function CopySuccessToast({
message,
duration = 3000,
onClose
}: CopySuccessToastProps) {
useEffect(() => {
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
// Trigger confetti on mount
useEffect(() => {
triggerConfetti();
}, []);
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 ...">
<CheckIcon className="text-green-500" />
<span>{message}</span>
</div>
);
}
CopyButton Component:
// src/components/features/draft/CopyButton.tsx
interface CopyButtonProps {
draftId: string;
onCopy?: () => void;
variant?: 'standalone' | 'toolbar';
}
export function CopyButton({
draftId,
onCopy,
variant = 'standalone'
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const copyDraftToClipboard = useChatStore(s => s.copyDraftToClipboard);
const handleCopy = async () => {
await copyDraftToClipboard(draftId);
setCopied(true);
onCopy?.();
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
className="..."
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
);
}
ChatService Extensions:
// src/services/chat-service.ts
export class ChatService {
async completeDraft(draftId: string): Promise<void> {
// Copy to clipboard
const draft = await DraftService.getDraftById(draftId);
await ClipboardUtil.copyMarkdown(draft.content);
// Mark as completed in database
await DraftService.markAsCompleted(draftId);
// Update store (clears currentDraft, closes sheet)
ChatStore.getState().completeDraft(draftId);
// Navigate to history
router.push('/history');
}
async copyDraftOnly(draftId: string): Promise<void> {
const draft = await DraftService.getDraftById(draftId);
await ClipboardUtil.copyMarkdown(draft.content);
// Don't clear currentDraft - keep sheet open
}
async copyAndSaveDraft(draftId: string): Promise<void> {
// Same as completeDraft but without navigation
const draft = await DraftService.getDraftById(draftId);
await ClipboardUtil.copyMarkdown(draft.content);
await DraftService.markAsCompleted(draftId);
ChatStore.getState().completeDraft(draftId);
}
}
DraftActions Integration:
// Update DraftActions component
const handleThumbsUp = async () => {
// Full completion flow
await ChatService.completeDraft(draftId);
showSuccessToast('Draft saved to history!');
};
const handleCopyOnly = async () => {
// Copy without closing
await ChatService.copyDraftOnly(draftId);
showSuccessToast('Copied to clipboard!');
};
History Navigation Strategy
After Completion:
// Navigate to history with highlight
router.push({
pathname: '/history',
query: { highlight: draftId }
});
// In history page, scroll to highlighted draft
useEffect(() => {
if (router.query.highlight) {
const element = document.getElementById(`draft-${router.query.highlight}`);
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
element?.classList.add('highlight-pulse');
}
}, [router.query.highlight]);
Highlight Animation:
/* In globals.css */
@keyframes highlight-pulse {
0% { box-shadow: 0 0 0 0 rgba(100, 116, 139, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(100, 116, 139, 0); }
100% { box-shadow: 0 0 0 0 rgba(100, 116, 139, 0); }
}
.highlight-pulse {
animation: highlight-pulse 1s ease-out;
}
Service Layer Extensions
DraftService Completion:
// src/lib/db/draft-service.ts
export class DraftService {
static async markAsCompleted(draftId: string): Promise<Draft> {
const existing = await this.getDraftById(draftId);
if (existing.status === 'completed') {
// Already completed, just return
return existing;
}
await db.drafts.update(draftId, {
status: 'completed',
completedAt: Date.now()
});
return this.getDraftById(draftId);
}
static async getCompletedDrafts(): Promise<Draft[]> {
return await db.drafts
.where('status')
.equals('completed')
.reverse()
.sortBy('completedAt');
}
}
ChatStore Actions:
// src/lib/store/chat-store.ts
interface ChatStore {
// Existing
currentDraft: Draft | null;
// New actions
completeDraft: (draftId: string) => Promise<void>;
copyDraftToClipboard: (draftId: string) => Promise<void>;
}
export const useChatStore = create<ChatStore>((set, get) => ({
// Existing state...
completeDraft: async (draftId: string) => {
// Mark as completed
await DraftService.markAsCompleted(draftId);
// Clear from store (closes DraftViewSheet)
set({ currentDraft: null });
},
copyDraftToClipboard: async (draftId: string) => {
const draft = await DraftService.getDraftById(draftId);
await ClipboardUtil.copyMarkdown(draft.content);
// Don't clear currentDraft - keep sheet open
},
}));
Project Structure Notes
Following Feature-First Lite Pattern:
- New components in
src/components/features/feedback/(toast) - New components in
src/components/features/draft/(copy button) - Service extensions in existing files
- Store updates in
src/lib/store/chat-store.ts
Alignment with Unified Project Structure:
- All feature code under
src/components/features/ - Services orchestrate logic, don't touch DB directly from UI
- State managed centrally in Zustand stores
- Utility functions in
src/lib/utils/
No Conflicts Detected:
- Completion is new status value, no conflicts
- Clipboard utility is new, no conflicts
- Navigation to history prepares for Epic 3
Performance Requirements
NFR-01 Compliance (Response Latency):
- Copy action should complete within 500ms
- Success toast should appear within 100ms
- Navigation to history should be smooth (< 300ms)
State Updates:
- currentDraft should clear immediately on completion
- DraftViewSheet should close smoothly
- History page should render quickly
Accessibility Requirements
WCAG AA Compliance:
- Copy buttons must have
aria-label - Success state must be announced to screen readers
- Focus management: Return focus after copy
- Keyboard shortcuts supported
Visual Accessibility:
- Success toast must be high contrast
- Avoid color-only indicators (use icons + text)
- Highlight animation must respect
prefers-reduced-motion
Security & Privacy Requirements
NFR-03 & NFR-04 Compliance:
- Clipboard operations are client-side only
- No draft content sent to server
- Completed drafts stay in IndexedDB
- No external API calls for copy/save
References
Epic Reference:
- Epic 2: "The Magic Mirror" - Ghostwriter & Draft Refinement
- Story 2.4: Export & Copy Actions
- FR-07: "Users can 'One-Click Copy' the formatted text to clipboard"
- FR-09: "Users can edit the generated draft manually before exporting" (future enhancement)
Architecture Documents:
- Project Context: Logic Sandwich
- Project Context: State Management
- Project Context: Local-First Boundary
UX Design Specifications:
Previous Stories:
- Story 2.1: Ghostwriter Agent & Markdown Generation - Draft data structure
- Story 2.2: Draft View UI (The Slide-Up) - DraftViewSheet and DraftActions
- Story 2.3: Refinement Loop (Regeneration) - Draft versioning pattern
Dev Agent Record
Agent Model Used
Claude Opus 4.5 (model ID: 'claude-opus-4-5-20251101')
Debug Log References
Session file: /tmp/claude/-home-maximilienmao-Projects/Test01/4ce6b396-ff74-42ee-be06-133000333628/scratchpad
Completion Notes List
Story Analysis Completed:
- Extracted story requirements from Epic 2, Story 2.4
- Analyzed previous Stories 2.1, 2.2, and 2.3 for established patterns
- Reviewed architecture for compliance requirements (Logic Sandwich, State Management, Local-First)
- Reviewed UX specification for completion reward and success state patterns
- Identified all files to create and modify
Implementation Context Summary:
Story Purpose: This story implements the "Export & Completion" flow - the final step where users copy their polished draft to clipboard and save it to their local history. This completes the core value loop: Vent -> Generate -> Refine -> Export -> Save.
Key Technical Decisions:
- Clipboard Utility: Create reusable ClipboardUtil with fallback for older browsers
- Completion Status: Add 'completed' status to DraftRecord with completedAt timestamp
- Success Feedback: CopySuccessToast with confetti animation for reward
- Two Copy Modes: (a) Thumbs Up = copy + save + close, (b) Copy Only = copy without closing
- Navigation: Auto-navigate to history with highlight after completion
- Haptic Feedback: Vibration on mobile for tactile confirmation
Dependencies:
- No new external dependencies required
- Uses browser Clipboard API with fallback
- Reuses existing Zustand, Dexie infrastructure
- May need confetti library (canvas-confetti) for celebration effect
Integration Points:
- Thumbs Up in DraftActions (Story 2.2) triggers ChatService.completeDraft()
- Completion updates DraftRecord status in IndexedDB
- Clearing currentDraft closes DraftViewSheet (auto-close pattern from Story 2.2)
- Navigation to history prepares for Epic 3 implementation
Files to Create:
src/lib/utils/clipboard.ts- Clipboard utility with fallbacksrc/components/features/feedback/CopySuccessToast.tsx- Success feedback componentsrc/components/features/draft/CopyButton.tsx- Standalone copy button
Files to Modify:
src/lib/store/chat-store.ts- Add completion actions (completeDraft, copyDraftToClipboard)src/lib/db/draft-service.ts- Add markAsCompleted() methodsrc/services/chat-service.ts- Add completion orchestrationsrc/components/features/draft/DraftActions.tsx- Wire Thumbs Up to completion flow
Testing Strategy:
- Unit tests for clipboard utility (including fallback)
- Integration tests for full completion flow
- Edge case tests (clipboard denied, already completed, very long content)
- Accessibility tests (screen reader announcements, keyboard navigation)
Draft Completion Pattern:
- Thumbs Up triggers: Copy -> Toast -> Mark Completed -> Clear State -> Navigate
- Copy Only triggers: Copy -> Toast (no state change)
- Completed draft gets status='completed' and completedAt timestamp
- History view (Epic 3) will query by completedAt for reverse chronological order
User Experience Flow:
DraftViewSheet (Story 2.2)
↓
User taps Thumbs Up
↓
Clipboard.copy() - Copy Markdown to clipboard
↓
CopySuccessToast - "Copied to clipboard!" + confetti
↓
DraftService.markAsCompleted() - Update IndexedDB
↓
ChatStore.completeDraft() - Clear currentDraft (closes sheet)
↓
Navigate to /history with ?highlight=draftId
↓
History scrolls to and highlights the new entry
File List
New Files to Create:
src/lib/utils/clipboard.ts- Clipboard utility with fallback supportsrc/components/features/feedback/CopySuccessToast.tsx- Success toast with confettisrc/components/features/draft/CopyButton.tsx- Standalone copy buttonsrc/lib/utils/clipboard.test.ts- Clipboard utility testssrc/integration/export-copy-actions.test.ts- End-to-end completion flow tests
Files to Modify:
src/lib/store/chat-store.ts- Add completeDraft, copyDraftToClipboard actionssrc/lib/db/draft-service.ts- Add markAsCompleted() method and getCompletedDrafts()src/lib/db/draft-service.test.ts- Add completion method testssrc/services/chat-service.ts- Add completion orchestration (completeDraft, copyDraftOnly)src/components/features/draft/DraftActions.tsx- Wire Thumbs Up and Copy button to completion