# Messaging UX Improvements Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Transform messaging input from fixed-height TextEditor to dynamic UITextView that starts compact and grows naturally, with automatic keyboard management and tap-to-dismiss functionality.

**Architecture:** Replace ExpandableMessageInput with DynamicTextInput (UIViewRepresentable wrapper around UITextView). Use iOS 16+ `sizeThatFits()` for automatic height calculation. Add keyboard observers to ChatView for auto-scroll and keyboard avoidance. Add tap gesture for keyboard dismissal.

**Tech Stack:** SwiftUI, UIKit (UITextView, UIViewRepresentable), Combine (for keyboard notifications)

---

## Task 1: Create DynamicTextInput Component Foundation

**Files:**
- Create: `lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift`

**Step 1: Create basic UIViewRepresentable structure**

Create the file with basic structure:

```swift
//
//  DynamicTextInput.swift
//  lichunWebsocket
//
//  Dynamic text input using UITextView for precise height control
//

import SwiftUI
import UIKit

struct DynamicTextInput: UIViewRepresentable {
    @Binding var text: String
    let placeholder: String
    let minHeight: CGFloat
    let maxHeight: CGFloat

    init(
        text: Binding<String>,
        placeholder: String = "Type a message...",
        minHeight: CGFloat = 40,
        maxHeight: CGFloat = 120
    ) {
        self._text = text
        self.placeholder = placeholder
        self.minHeight = minHeight
        self.maxHeight = maxHeight
    }

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator

        // Configure for dynamic sizing
        textView.isScrollEnabled = false
        textView.backgroundColor = .clear
        textView.font = UIFont.systemFont(ofSize: 17)
        textView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
        textView.textContainer.lineFragmentPadding = 0

        return textView
    }

    func updateUIView(_ textView: UITextView, context: Context) {
        if textView.text != text {
            textView.text = text
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UITextViewDelegate {
        var parent: DynamicTextInput

        init(_ parent: DynamicTextInput) {
            self.parent = parent
        }

        func textViewDidChange(_ textView: UITextView) {
            parent.text = textView.text
        }
    }
}
```

**Step 2: Test basic structure in preview**

Add preview at bottom of file:

```swift
// MARK: - Preview
#Preview {
    VStack {
        Spacer()

        DynamicTextInput(text: .constant(""))
            .frame(height: 40)
            .background(Color.white)
            .cornerRadius(20)
            .padding()
    }
    .background(AppColors.background)
}
```

**Step 3: Verify preview renders**

Open Xcode:
```bash
cd /Users/craigvandergalien/Documents/GitHub/lichunWebsocket/.worktrees/messaging-ux-improvements
open lichunWebsocket.xcodeproj
```

Expected: Preview shows basic text input (no styling yet, just functional)

**Step 4: Commit foundation**

```bash
cd /Users/craigvandergalien/Documents/GitHub/lichunWebsocket/.worktrees/messaging-ux-improvements
git add lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift
git commit -m "feat(messaging): add DynamicTextInput UIViewRepresentable foundation

- Basic UITextView wrapper with Coordinator
- Disabled scroll for content-based sizing
- Two-way text binding via delegate
- Ready for sizeThatFits implementation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 2: Implement iOS 16+ Dynamic Sizing

**Files:**
- Modify: `lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift`

**Step 1: Add sizeThatFits method**

Add this method to the `DynamicTextInput` struct (after `updateUIView`):

```swift
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
    let width = proposal.width ?? 300

    // Calculate content height
    let size = uiView.sizeThatFits(CGSize(width: width, height: .infinity))
    let height = max(minHeight, min(size.height, maxHeight))

    // Enable scrolling only when content exceeds max height
    DispatchQueue.main.async {
        uiView.isScrollEnabled = size.height > maxHeight
    }

    return CGSize(width: width, height: height)
}
```

**Step 2: Update preview to test dynamic sizing**

Replace preview with interactive test:

```swift
#Preview {
    struct PreviewWrapper: View {
        @State private var text = ""

        var body: some View {
            VStack {
                Text("Height demo: Type to see growth")
                    .font(.caption)
                    .foregroundColor(.gray)

                Spacer()

                DynamicTextInput(text: $text)
                    .background(Color.white)
                    .cornerRadius(20)
                    .overlay(
                        RoundedRectangle(cornerRadius: 20)
                            .stroke(Color.blue, lineWidth: 1)
                    )
                    .padding()

                Text("Height: dynamic")
                    .font(.caption)
                    .foregroundColor(.gray)
            }
            .background(AppColors.background)
        }
    }

    return PreviewWrapper()
}
```

**Step 3: Test in preview**

Expected behavior:
- Starts at ~40px height
- Grows as you type and text wraps
- Stops growing at ~120px
- Scrolls internally after 120px

**Step 4: Commit dynamic sizing**

```bash
git add lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift
git commit -m "feat(messaging): implement iOS 16+ dynamic height sizing

- Use sizeThatFits() for automatic height calculation
- Start at minHeight (40px), grow to maxHeight (120px)
- Enable scroll only when exceeding maxHeight
- Preview demonstrates dynamic growth behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 3: Add Cozy Styling to DynamicTextInput

**Files:**
- Modify: `lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift`

**Step 1: Add appearance configuration**

Update `makeUIView` to configure text appearance:

```swift
func makeUIView(context: Context) -> UITextView {
    let textView = UITextView()
    textView.delegate = context.coordinator

    // Configure for dynamic sizing
    textView.isScrollEnabled = false
    textView.backgroundColor = .clear
    textView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
    textView.textContainer.lineFragmentPadding = 0

    // Text appearance
    textView.font = UIFont.systemFont(ofSize: 17)
    textView.textColor = UIColor(AppColors.primaryText)
    textView.tintColor = UIColor(AppColors.primary)
    textView.autocorrectionType = .yes
    textView.autocapitalizationType = .sentences
    textView.keyboardType = .default
    textView.returnKeyType = .default

    return textView
}
```

**Step 2: Add placeholder support**

Add placeholder property and methods to Coordinator:

```swift
class Coordinator: NSObject, UITextViewDelegate {
    var parent: DynamicTextInput
    private var placeholderLabel: UILabel?

    init(_ parent: DynamicTextInput) {
        self.parent = parent
    }

    func setupPlaceholder(in textView: UITextView) {
        let label = UILabel()
        label.text = parent.placeholder
        label.font = textView.font
        label.textColor = UIColor(AppColors.disabledText)
        label.translatesAutoresizingMaskIntoConstraints = false
        textView.addSubview(label)

        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 12),
            label.topAnchor.constraint(equalTo: textView.topAnchor, constant: 10)
        ])

        placeholderLabel = label
        updatePlaceholder()
    }

    private func updatePlaceholder() {
        placeholderLabel?.isHidden = !parent.text.isEmpty
    }

    func textViewDidChange(_ textView: UITextView) {
        parent.text = textView.text
        updatePlaceholder()
    }

    func textViewDidBeginEditing(_ textView: UITextView) {
        updatePlaceholder()
    }
}
```

Update `makeUIView` to setup placeholder:

```swift
func makeUIView(context: Context) -> UITextView {
    // ... existing code ...

    // Setup placeholder after creating textView
    context.coordinator.setupPlaceholder(in: textView)

    return textView
}
```

**Step 3: Test placeholder behavior**

In preview, verify:
- Placeholder shows when empty
- Placeholder hides when typing
- Placeholder color is light gray

**Step 4: Commit styling**

```bash
git add lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift
git commit -m "feat(messaging): add cozy styling and placeholder to DynamicTextInput

- Configure text appearance (color, font, autocorrect)
- Add placeholder label with proper positioning
- Hide/show placeholder based on text content
- Use AppColors for consistent theming

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 4: Create Full Input Bar with Send Button

**Files:**
- Modify: `lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift`

**Step 1: Restructure to include send button**

Wrap the component to include send button. Update the struct:

```swift
struct DynamicTextInput: View {
    @Binding var text: String
    let canSend: Bool
    let onSend: () -> Void

    @FocusState private var isFocused: Bool

    var body: some View {
        HStack(alignment: .bottom, spacing: AppSpacing.sm) {
            // Text input
            DynamicTextView(text: $text, isFocused: $isFocused)
                .background(
                    LinearGradient(
                        colors: [
                            AppColors.surfaceElevated,
                            Color(hex: 0xFFF5ED)
                        ],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                )
                .cornerRadius(20)
                .overlay(
                    RoundedRectangle(cornerRadius: 20)
                        .strokeBorder(
                            isFocused ? AppColors.primary.opacity(0.25) : AppColors.secondaryText.opacity(0.12),
                            lineWidth: 1.5
                        )
                )
                .shadow(
                    color: isFocused ? AppColors.primary.opacity(0.08) : Color.black.opacity(0.03),
                    radius: isFocused ? 8 : 4,
                    x: 0,
                    y: 2
                )
                .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isFocused)

            // Send button
            Button(action: {
                if canSend && !text.isEmpty {
                    let generator = UIImpactFeedbackGenerator(style: .medium)
                    generator.impactOccurred()
                    onSend()
                }
            }) {
                ZStack {
                    Circle()
                        .fill(
                            canSend && !text.isEmpty
                                ? LinearGradient(
                                    colors: [AppColors.primary, AppColors.accent],
                                    startPoint: .topLeading,
                                    endPoint: .bottomTrailing
                                )
                                : LinearGradient(
                                    colors: [AppColors.disabledText],
                                    startPoint: .topLeading,
                                    endPoint: .bottomTrailing
                                )
                        )
                        .frame(width: 44, height: 44)

                    Image(systemName: "arrow.up")
                        .font(.system(size: 18, weight: .bold))
                        .foregroundColor(.white)
                }
            }
            .disabled(!canSend || text.isEmpty)
            .scaleEffect(canSend && !text.isEmpty ? 1.0 : 0.9)
            .animation(.spring(response: 0.3, dampingFraction: 0.6), value: canSend)
            .animation(.spring(response: 0.3, dampingFraction: 0.6), value: text.isEmpty)
        }
        .padding(.horizontal, AppSpacing.md)
        .padding(.vertical, AppSpacing.sm)
        .background(
            ZStack {
                AppColors.background

                LinearGradient(
                    colors: [
                        Color.black.opacity(0.03),
                        Color.clear
                    ],
                    startPoint: .top,
                    endPoint: .bottom
                )
                .frame(height: 20)
                .offset(y: -10)
            }
        )
    }
}

// MARK: - DynamicTextView (UIViewRepresentable)

private struct DynamicTextView: UIViewRepresentable {
    @Binding var text: String
    @FocusState.Binding var isFocused: Bool

    let minHeight: CGFloat = 40
    let maxHeight: CGFloat = 120

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator

        // Configure for dynamic sizing
        textView.isScrollEnabled = false
        textView.backgroundColor = .clear
        textView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
        textView.textContainer.lineFragmentPadding = 0

        // Text appearance
        textView.font = UIFont.systemFont(ofSize: 17)
        textView.textColor = UIColor(AppColors.primaryText)
        textView.tintColor = UIColor(AppColors.primary)
        textView.autocorrectionType = .yes
        textView.autocapitalizationType = .sentences
        textView.keyboardType = .default
        textView.returnKeyType = .default

        // Setup placeholder
        context.coordinator.setupPlaceholder(in: textView)

        return textView
    }

    func updateUIView(_ textView: UITextView, context: Context) {
        if textView.text != text {
            textView.text = text
            context.coordinator.updatePlaceholder()
        }
    }

    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
        let width = proposal.width ?? 300

        let size = uiView.sizeThatFits(CGSize(width: width, height: .infinity))
        let height = max(minHeight, min(size.height, maxHeight))

        DispatchQueue.main.async {
            uiView.isScrollEnabled = size.height > maxHeight
        }

        return CGSize(width: width, height: height)
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(text: $text, isFocused: $isFocused)
    }

    class Coordinator: NSObject, UITextViewDelegate {
        @Binding var text: String
        @FocusState.Binding var isFocused: Bool
        private var placeholderLabel: UILabel?

        init(text: Binding<String>, isFocused: FocusState<Bool>.Binding) {
            self._text = text
            self._isFocused = isFocused
        }

        func setupPlaceholder(in textView: UITextView) {
            let label = UILabel()
            label.text = "Type a message..."
            label.font = textView.font
            label.textColor = UIColor(AppColors.disabledText)
            label.translatesAutoresizingMaskIntoConstraints = false
            textView.addSubview(label)

            NSLayoutConstraint.activate([
                label.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 12),
                label.topAnchor.constraint(equalTo: textView.topAnchor, constant: 10)
            ])

            placeholderLabel = label
            updatePlaceholder()
        }

        func updatePlaceholder() {
            placeholderLabel?.isHidden = !text.isEmpty
        }

        func textViewDidChange(_ textView: UITextView) {
            text = textView.text
            updatePlaceholder()
        }

        func textViewDidBeginEditing(_ textView: UITextView) {
            isFocused = true
            updatePlaceholder()
        }

        func textViewDidEndEditing(_ textView: UITextView) {
            isFocused = false
        }
    }
}
```

**Step 2: Update preview**

```swift
#Preview {
    struct PreviewWrapper: View {
        @State private var text = ""

        var body: some View {
            VStack {
                Spacer()

                DynamicTextInput(
                    text: $text,
                    canSend: true,
                    onSend: {
                        print("Sent: \(text)")
                        text = ""
                    }
                )
            }
            .background(AppColors.background)
        }
    }

    return PreviewWrapper()
}
```

**Step 3: Test complete input bar**

In preview, verify:
- Input starts compact
- Grows as you type
- Send button appears/animates
- Focus states work (border, shadow)
- Send clears text

**Step 4: Commit input bar**

```bash
git add lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift
git commit -m "feat(messaging): create complete input bar with send button

- Restructure as full input bar component
- Add send button with gradient and animations
- Add focus states with border/shadow transitions
- Add haptic feedback on send
- Integrate all cozy design elements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 5: Integrate DynamicTextInput into ChatView

**Files:**
- Modify: `lichunWebsocket/Features/Messaging/Views/ChatView.swift`

**Step 1: Replace ExpandableMessageInput**

In ChatView.swift, replace the input area section (around line 102-107):

```swift
// OLD:
ExpandableMessageInput(
    text: $textFieldInput,
    canSend: canSendMessage,
    onSend: sendMessage
)

// NEW:
DynamicTextInput(
    text: $textFieldInput,
    canSend: canSendMessage,
    onSend: sendMessage
)
```

**Step 2: Build and test in simulator**

```bash
xcodebuild -project lichunWebsocket.xcodeproj -scheme lichunWebsocket -destination 'platform=iOS Simulator,name=iPhone 15' build
```

Expected: Build succeeds, no errors

**Step 3: Run in simulator**

Open Xcode and run (⌘R), or:
```bash
xcrun simctl boot "iPhone 15"
# Then run from Xcode
```

Navigate to Messages → Select a conversation

Verify:
- Input starts compact (~40px)
- Grows smoothly as you type
- Send button works
- Messages still send correctly

**Step 4: Commit integration**

```bash
git add lichunWebsocket/Features/Messaging/Views/ChatView.swift
git commit -m "feat(messaging): integrate DynamicTextInput into ChatView

- Replace ExpandableMessageInput with DynamicTextInput
- Input now starts compact and grows dynamically
- All existing send functionality preserved
- No changes to WebSocket message flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 6: Add Keyboard Observation and Auto-Scroll

**Files:**
- Modify: `lichunWebsocket/Features/Messaging/Views/ChatView.swift`

**Step 1: Add keyboard state tracking**

At top of ChatView struct, add state variables:

```swift
@State private var keyboardHeight: CGFloat = 0
@FocusState private var inputFocused: Bool
```

**Step 2: Add keyboard observers**

Add this method to ChatView:

```swift
private func setupKeyboardObservers() {
    NotificationCenter.default.addObserver(
        forName: UIResponder.keyboardWillShowNotification,
        object: nil,
        queue: .main
    ) { notification in
        guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }

        withAnimation(.easeOut(duration: 0.25)) {
            keyboardHeight = keyboardFrame.height
        }

        // Scroll to bottom when keyboard appears
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            if let scrollProxy = scrollProxy {
                scrollToBottom(using: scrollProxy)
            }
        }
    }

    NotificationCenter.default.addObserver(
        forName: UIResponder.keyboardWillHideNotification,
        object: nil,
        queue: .main
    ) { _ in
        withAnimation(.easeOut(duration: 0.25)) {
            keyboardHeight = 0
        }
    }
}

private func removeKeyboardObservers() {
    NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
    NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
}
```

**Step 3: Store ScrollViewProxy**

Add state variable to store proxy:

```swift
@State private var scrollProxy: ScrollViewProxy?
```

Update ScrollViewReader section:

```swift
ScrollViewReader { proxy in
    ScrollView(showsIndicators: false) {
        // ... existing content ...
    }
    .onChange(of: conversation?.conversation.count) { _ in
        scrollToBottom(using: proxy)
    }
    .onAppear {
        scrollProxy = proxy
        setupKeyboardObservers()

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            scrollToBottom(using: proxy)
        }
    }
    .onDisappear {
        removeKeyboardObservers()
    }
}
```

**Step 4: Add keyboard padding**

Update ScrollView content to add bottom padding:

```swift
ScrollView(showsIndicators: false) {
    VStack(spacing: AppSpacing.md) {
        // ... existing message content ...

        Color.clear
            .frame(height: 20 + keyboardHeight)
            .id("bottom")
    }
    .padding(.horizontal, AppSpacing.md)
    .padding(.top, AppSpacing.sm)
}
```

**Step 5: Test keyboard behavior**

Run in simulator, navigate to chat:

Verify:
- Tap input → keyboard appears, messages scroll to bottom
- Keyboard slides up smoothly (0.25s)
- Messages stay visible above keyboard
- Tap outside → keyboard dismisses (not implemented yet, coming next)

**Step 6: Commit keyboard management**

```bash
git add lichunWebsocket/Features/Messaging/Views/ChatView.swift
git commit -m "feat(messaging): add keyboard observation and auto-scroll

- Track keyboard height with NotificationCenter observers
- Auto-scroll to bottom when keyboard appears
- Add keyboard-aware padding to ScrollView
- Smooth 0.25s easeOut animation matching iOS native

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 7: Add Tap-to-Dismiss Keyboard Gesture

**Files:**
- Modify: `lichunWebsocket/Features/Messaging/Views/ChatView.swift`

**Step 1: Add tap gesture to ScrollView**

Wrap the ScrollView in a gesture modifier:

```swift
ScrollView(showsIndicators: false) {
    // ... existing content ...
}
.background(
    Color.clear
        .contentShape(Rectangle())
        .onTapGesture {
            hideKeyboard()

            // Haptic feedback
            let generator = UIImpactFeedbackGenerator(style: .light)
            generator.impactOccurred()
        }
)
.onChange(of: conversation?.conversation.count) { _ in
    if let proxy = scrollProxy {
        scrollToBottom(using: proxy)
    }
}
// ... rest of modifiers ...
```

**Step 2: Test tap-to-dismiss**

Run in simulator:

Verify:
- Tap input → keyboard appears
- Tap blank area in message list → keyboard dismisses with haptic
- Tap on message bubble → keyboard dismisses (expected, can refine later)
- Scrolling still works normally

**Step 3: Commit tap-to-dismiss**

```bash
git add lichunWebsocket/Features/Messaging/Views/ChatView.swift
git commit -m "feat(messaging): add tap-to-dismiss keyboard gesture

- Add tap gesture to ScrollView background
- Dismiss keyboard when tapping message area
- Add light haptic feedback on dismiss
- Preserve scroll gesture functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 8: Add Message Bubble Entry Animations

**Files:**
- Modify: `lichunWebsocket/Features/Messaging/Components/CozyMessageBubble.swift` (if exists)
- Or modify: `lichunWebsocket/Features/Messaging/Views/ChatView.swift`

**Step 1: Check if CozyMessageBubble exists**

```bash
ls -la lichunWebsocket/Features/Messaging/Components/CozyMessageBubble.swift
```

If exists, modify that file. If not, add animations directly in ChatView.

**Step 2: Add animation state tracking**

In ChatView, already has `@State private var appearedMessages: Set<String> = []`

Update the message ForEach to add entry animations:

```swift
ForEach(Array(conversation.conversation.enumerated()), id: \.element.id) { index, message in
    CozyMessageBubble(
        message: message,
        character: character,
        isFromPlayer: message.sender != character.id
    )
    .id(message.id)
    .opacity(appearedMessages.contains(message.id) ? 1 : 0)
    .offset(y: appearedMessages.contains(message.id) ? 0 : 20)
    .onAppear {
        withAnimation(.spring(response: 0.5, dampingFraction: 0.75).delay(Double(index) * 0.03)) {
            _ = appearedMessages.insert(message.id)
        }
    }
}
```

**Step 3: Test message animations**

Run in simulator:

Verify:
- Existing messages fade in on chat open
- New messages slide up and fade in
- Staggered delay creates smooth effect
- No jank or stuttering

**Step 4: Commit animations**

```bash
git add lichunWebsocket/Features/Messaging/Views/ChatView.swift
git commit -m "feat(messaging): add message bubble entry animations

- Messages fade in and slide up on appearance
- Staggered 0.03s delay for smooth cascade
- Spring animation (0.5s response, 0.75 damping)
- Apply to new messages and chat initialization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 9: Add Send Button Enhanced Animation

**Files:**
- Modify: `lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift`

**Step 1: Add send animation state**

Add state for send animation:

```swift
@State private var isSending: Bool = false
```

**Step 2: Enhance send button with scale animation**

Update the send button action:

```swift
Button(action: {
    if canSend && !text.isEmpty {
        // Haptic feedback
        let generator = UIImpactFeedbackGenerator(style: .medium)
        generator.impactOccurred()

        // Scale animation
        withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
            isSending = true
        }

        // Call send
        onSend()

        // Reset animation
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
            withAnimation {
                isSending = false
            }
        }
    }
}) {
    ZStack {
        Circle()
            .fill(
                canSend && !text.isEmpty
                    ? LinearGradient(
                        colors: [AppColors.primary, AppColors.accent],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                    : LinearGradient(
                        colors: [AppColors.disabledText],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
            )
            .frame(width: 44, height: 44)

        Image(systemName: "arrow.up")
            .font(.system(size: 18, weight: .bold))
            .foregroundColor(.white)
            .rotationEffect(.degrees(isSending ? -45 : 0))
    }
}
.disabled(!canSend || text.isEmpty || isSending)
.scaleEffect(isSending ? 0.85 : (canSend && !text.isEmpty ? 1.0 : 0.9))
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: canSend)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: text.isEmpty)
.animation(.spring(response: 0.3, dampingFraction: 0.5), value: isSending)
```

**Step 3: Test send animation**

Run in simulator:

Verify:
- Type message → send button pulses/grows when ready
- Tap send → button scales down, arrow rotates
- Button disabled briefly during animation
- Smooth spring feel

**Step 4: Commit send animation**

```bash
git add lichunWebsocket/Features/Messaging/Components/DynamicTextInput.swift
git commit -m "feat(messaging): enhance send button with scale/rotation animation

- Scale down to 0.85 on send with spring
- Rotate arrow -45° during send
- Disable button briefly to prevent double-tap
- Add smooth spring animations for all states

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 10: Final Testing and Polish

**Files:**
- None (testing phase)

**Step 1: Comprehensive functionality test**

Test matrix:

| Scenario | Expected Behavior |
|----------|------------------|
| Open chat | Input compact (~40px), messages loaded |
| Tap input | Keyboard appears, messages scroll to bottom |
| Type 1 line | Input stays ~40px |
| Type 3 lines | Input grows to ~84px |
| Type 6+ lines | Input caps at 120px, scrolls internally |
| Send message | Animation plays, text clears, input shrinks |
| Tap message area | Keyboard dismisses with haptic |
| Receive message | New message slides up and fades in |
| Low energy | Send button gray, tap shows warning |

**Step 2: Visual polish check**

Verify:
- ✅ Colors match cozy design (warm gradients)
- ✅ Corner radius consistent (20px)
- ✅ Shadows appropriate (subtle, not harsh)
- ✅ Animations smooth (no jank)
- ✅ Focus states clear (border highlights)
- ✅ Haptics present (tap, send, dismiss)

**Step 3: Edge case testing**

Test:
- Very long message (multi-paragraph)
- Emoji only messages
- Rapid typing
- Rapid send (multiple messages quickly)
- Rotate device (if supported)
- Background/foreground app
- Low energy state

**Step 4: Compare to old implementation**

Run main branch in second simulator:

```bash
# Terminal 1 - Old version
git checkout main
open lichunWebsocket.xcodeproj
# Run on iPhone 15

# Terminal 2 - New version
cd .worktrees/messaging-ux-improvements
open lichunWebsocket.xcodeproj
# Run on iPhone 15 Pro
```

Side-by-side comparison:
- Old: Input always large
- New: Input compact, grows naturally
- Old: No keyboard auto-scroll
- New: Smooth auto-scroll
- Old: No tap-to-dismiss
- New: Tap anywhere dismisses

**Step 5: Document any issues found**

If issues found, create follow-up commits to fix.

If all good, proceed to final commit.

**Step 6: Final polish commit (if needed)**

```bash
git add .
git commit -m "polish(messaging): final UX refinements

- [List any final tweaks made]
- All functionality verified in simulator
- Edge cases tested and handled
- Performance smooth across scenarios

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>"
```

---

## Task 11: Merge to Main

**Files:**
- None (git operations)

**Step 1: Switch back to main worktree**

```bash
cd /Users/craigvandergalien/Documents/GitHub/lichunWebsocket
```

**Step 2: Verify current state is clean**

```bash
git status
```

Expected: Clean working tree, or only expected modifications

**Step 3: Merge the feature branch**

```bash
git merge messaging-ux-improvements
```

Expected: Fast-forward merge or successful merge commit

**Step 4: Test on main**

```bash
open lichunWebsocket.xcodeproj
# Run in Xcode, verify everything still works
```

**Step 5: Clean up worktree**

```bash
git worktree remove .worktrees/messaging-ux-improvements
git branch -d messaging-ux-improvements
```

**Step 6: Push to remote (if applicable)**

```bash
git push origin main
```

---

## Success Criteria

### Functional Requirements
- ✅ Input field starts at ~40px (compact)
- ✅ Input grows smoothly to ~120px max
- ✅ Keyboard appearance auto-scrolls messages to bottom
- ✅ Tap message area dismisses keyboard
- ✅ Send button animations work correctly
- ✅ Message bubble entry animations smooth
- ✅ All existing message sending functionality preserved

### Visual Requirements
- ✅ Matches cozy design aesthetic
- ✅ Smooth animations (no jank)
- ✅ Focus states clear and polished
- ✅ Haptic feedback feels good
- ✅ Colors, gradients, shadows consistent

### Technical Requirements
- ✅ No regressions in WebSocket message flow
- ✅ No memory leaks (keyboard observers cleaned up)
- ✅ Works on iPhone 13/14/15 simulator sizes
- ✅ Builds without warnings

---

## Troubleshooting

### Input not sizing correctly
- Check `isScrollEnabled = false` in makeUIView
- Verify `sizeThatFits` is calculating correctly
- Check min/max height constraints

### Keyboard not auto-scrolling
- Verify NotificationCenter observers are set up in onAppear
- Check scrollProxy is being stored correctly
- Ensure "bottom" ID exists in ScrollView

### Tap gesture not working
- Verify `.contentShape(Rectangle())` on gesture
- Check gesture isn't being blocked by child views
- Ensure hideKeyboard() is being called

### Animations stuttering
- Check animation curves (.spring vs .easeOut)
- Verify state changes happening on main thread
- Look for conflicting animations on same property

### Build errors
- Clean build folder: ⌘⇧K
- Check import statements
- Verify file added to correct target
- Check for typos in AppColors/AppSpacing references
