# Project export: Haven

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Haven, a gentle AI companion that adapts to ADHD, anxiety, medication changes, and life transitions, because everyone has their own rhythm.
- Devpost: https://devpost.com/software/haven-qhsx30
- GitHub: https://github.com/Sabrina-Xia04/Haven
- Team: 2 GitHub contributor(s) — Runling (7 commits), Sabrina Xia (3 commits)

## Devpost submission (written by the team)

### Inspiration

Most mental health apps share one assumption. There is a universal definition of normal. They compare your sleep against averages, reward unbroken streaks, and flag when you fall behind. We have a close friend with bipolar disorder. There were stretches of weeks where she would go completely silent. No messages, no updates, nothing. We would start to wonder if she was still okay. Then she would reappear, suddenly full of energy, making plans, starting projects, doing in three days what most people spread across a month. What she struggled with most was not the moods themselves. It was the uncertainty. She could not tell when a quiet period was coming or how long it would last. After taking her medication, she had no way to know whether she would sleep for four hours or twelve. When a depressive episode arrived, she did not have the energy to plan her day, let alone maintain any kind of routine. Every app she tried asked her to track herself, log her states, and stay consistent. All of that assumed she had the capacity to monitor herself in real time. During the hardest weeks, she simply did not.

### What it does

Heaven learns each person's unique rhythms and builds a personal cognitive baseline. Instead of pushing everyone toward the same definition of productivity, Haven adapts to changing energy levels, stress patterns, medication side effects, ADHD, anxiety, and life transitions. It remembers what helped users through difficult periods before, gently adjusts plans, and helps them stay connected to the version of themselves they want to protect. The patterns Haven learns are things like hyperfocus, where a user works for hours without eating or checking their phone. Or evaluation avoidance, where someone repeatedly opens and closes a calendar before a presentation without starting the work. Or medication rhythm, where afternoons are consistently clearer than mornings. Each of these is a behavioral signal, not a character flaw. Haven treats them as information. When a pattern surfaces, Haven adapts the environment around you. During a hyperfocus session, it suppresses notifications and delays non-essential reminders. On a low-energy day, it scales tasks down and protects recovery time instead of pushing you to catch up. If your medication means mornings are hard, Haven moves demanding work to the window when you are actually at your best. You never have to explain any of this. Haven learns it.

### How we built it

Claude runs at the center of the Understanding Agent. Every pattern that surfaces goes through Claude with a tightly constrained system prompt. The prompt prohibits diagnosis and shame language, requires evidence-based reasoning, and attaches a confidence score to every output. Claude does not just generate responses. It reasons about why this moment resembles past difficult periods, and what has historically worked for this specific person. Deepgram's Nova-3 model handles real-time speech-to-text over WebSocket. Haven responds through Deepgram's Aura-2 voice, a warm and consistent voice the user comes to associate with safety. The end-to-end latency is low enough that the conversation feels like talking to someone, not operating software.

### Challenges we ran into

The hardest problem we faced was not technical. We acctually spent more time on the Claude system prompt than on any single API integration. We kept testing what warm but not dismissive talking style actually sounds like. We kept tracing the line between observational and diagnostic. We built an internal review process around one question, which is "would this message make someone feel worse about themselves?" If yes, we rewrote it.

### Accomplishments we're proud of

We built something we would give to our friend. The friend whose story opens this submission is real. When we showed her Haven, she said the thing that mattered most to her was that it did not ask her to be consistent. It just remembered what worked. That was the standard we built to. We are proud that we met it. When Haven says "I remember that during finals week last semester, your energy looked exactly like this," it is not generating a plausible-sounding memory. It is retrieving a specific stored episode, matched semantically by Redis Vector Search. The distinction matters to us. Haven does not pretend to remember. It actually does.

### What we learned

The first version of Haven's daily view showed a task completion percentage. We removed it after the first test session. The person using it said she had spent the whole time watching the number in the corner instead of thinking about how she actually felt. That one metric had turned a day into something to be scored, which is exactly what every other app already does. The last thing we kept catching was the language. We kept writing words like "on track," "progress," and "goal" without noticing that each one carries an assumption about a fixed direction and a correct speed. Not everyone is pointed the same way. Cutting those words forced us to say what we actually meant, which turned out to be simpler and warmer than what we had written before.

### What's next

We believe Haven is a research direction, not only a hackathon project. Right now, Haven only knows you inside Haven. We want to change that. The patterns Haven learns about you belong to you, and we think they should be able to travel with you. If you switch to a different calendar app, a different task manager, or a different wellness tool, Haven's understanding of your rhythms should not disappear. We want to build toward a version where your personal baseline is something you own and carry, not something locked inside a single app.

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 27 recognized source files, 151 KB.
- Swift (language) — detected in the code
- Anthropic (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (35 of 35)

```
.gitignore
Haven.xcodeproj/project.pbxproj
Haven.xcodeproj/project.xcworkspace/contents.xcworkspacedata
Haven/Assets.xcassets/AppIcon.appiconset/Contents.json
Haven/Assets.xcassets/Contents.json
Haven/Assets.xcassets/HavenMascot.imageset/Contents.json
Haven/Components/AquariumView.swift
Haven/Components/JellyfishView.swift
Haven/Components/VoiceButton.swift
Haven/Config/HavenConfig.swift
Haven/Config/Secrets.template.swift
Haven/ContentView.swift
Haven/HavenApp.swift
Haven/Info.plist
Haven/Models/HavenModels.swift
Haven/Notifications/NotificationContent.swift
Haven/Notifications/NotificationManager.swift
Haven/Services/AudioCaptureService.swift
Haven/Services/AudioPlayerService.swift
Haven/Services/DeepgramSTTService.swift
Haven/Services/DeepgramTTSService.swift
Haven/Services/HavenAgentOrchestrator.swift
Haven/ViewModels/HavenViewModel.swift
Haven/Views/HomeView.swift
Haven/Views/InsightsView.swift
Haven/Views/MemoryOceanView.swift
Haven/Views/NotificationSettingsView.swift
Haven/Views/RhythmView.swift
Haven/Views/SeedsView.swift
Haven/Voice/VoiceConversationManager.swift
Haven/Voice/VoiceConversationOverlay.swift
scripts/com.haven.github-sync.plist
scripts/push_now.sh
scripts/sync_github.sh
scripts/watch_and_sync.sh
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- fix: audio capture concurrency, always-active stop button, mic error handling
- feat: add app icon
- feat: smooth background crossfade between panels on swipe
- fix: remove specular highlight ellipse, center jellyfish
- feat: live transcript display + manual send button in voice overlay
- fix: correct swipe directions and fix HintLabels rotated text overflow
- fix: add Secrets.swift to Xcode compile sources, fix AudioPlayerService concurrency
- Fix build: auto-create Secrets.swift from template if missing, fix STT concurrency
- Make Haven a real app: persistence, add/delete seeds, live memory, time-aware rhythm
- Fix swipe directions, dark mode, and entrance animations
- Secure API keys: move to gitignored Secrets.swift, add template
- Add voice system: Deepgram Nova-3 STT, Aura-2 TTS, Claude multi-agent, fix Xcode project registration
- Initial commit: Haven iOS app

## Key source files (fetched from GitHub, selected and truncated for size)

### Haven/HavenApp.swift

```swift
import SwiftUI
import UserNotifications

@main
struct HavenApp: App {
    @StateObject private var notifManager = NotificationManager.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .preferredColorScheme(.dark)
                .environmentObject(notifManager)
                .task {
                    // Re-check permission status on each launch
                    await notifManager.refreshPermissionStatus()
                }
        }
    }
}

```

### scripts/push_now.sh

```shell
#!/bin/bash
echo "=== Haven → GitHub SSH Push ==="
cd ~/Documents/Haven || { echo "ERROR: ~/Documents/Haven not found"; exit 1; }

# Switch to SSH remote
git remote set-url origin git@github.com:Sabrina-Xia04/Haven.git
echo "Remote: $(git remote get-url origin)"

# Pull latest scripts from /tmp/haven_git
rsync -a --exclude='.git' /tmp/haven_git/ ~/Documents/Haven/

# Stage and commit
git add -A
if git diff --cached --quiet; then
  echo "Nothing new to commit — pushing existing commits"
else
  git commit -m "Auto-sync: $(date '+%Y-%m-%d %H:%M')"
fi

# Push via SSH
git push origin main && echo "✓ Push successful" || echo "✗ Push failed — check SSH key"

```

### scripts/watch_and_sync.sh

```shell
#!/bin/bash
# =====================================================
# Haven — Auto-commit on file change (fswatch)
# Run once: bash ~/Documents/Haven/scripts/watch_and_sync.sh
# Keeps running in background; Ctrl+C to stop.
# =====================================================

REPO="$HOME/Documents/Haven"
LOG="$REPO/scripts/sync.log"
DEBOUNCE=3   # seconds to wait after last change before committing

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG"; }

# Check fswatch is installed
if ! command -v fswatch &>/dev/null; then
    echo "fswatch not found. Install with: brew install fswatch"
    exit 1
fi

log "👀 Watching $REPO for changes..."

# Watch directory, exclude .git and log files
fswatch -o \
    --exclude "\.git/" \
    --exclude "sync\.log" \
    --exclude "\.DS_Store" \
    "$REPO" | while read -r CHANGE_COUNT; do

    # Debounce: wait for writes to settle
    sleep "$DEBOUNCE"

    cd "$REPO" || continue

    # Stage everything
    git add -A

    # Only commit if there are actual changes
    if git diff --cached --quiet; then
        continue
    fi

    CHANGED=$(git diff --cached --name-only | wc -l | tr -d ' ')
    FILES=$(git diff --cached --name-only | head -3 | tr '\n' ' ')
    MSG="Auto: $(date '+%H:%M') · ${FILES}(+$((CHANGED - 3 > 0 ? CHANGED - 3 : 0)) more)"

    git commit -m "$MSG" --quiet
    git push origin main --quiet && log "✓ Pushed: $FILES" \
                                 || log "✗ Push failed"
done

```

### scripts/sync_github.sh

```shell
#!/bin/bash
# =============================================================
# Haven → GitHub Auto-Sync (SSH)
# Runs via launchd every 2 hours.
# Only commits when there are actual changes.
# =============================================================

REPO="$HOME/Documents/Haven"
LOG="$REPO/scripts/sync.log"
MAX_LOG_LINES=500

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG"
}

# ── Rotate log so it doesn't grow forever ────────────────────
if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt "$MAX_LOG_LINES" ]; then
    tail -n 200 "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"
fi

log "──────── sync started ────────"

# ── Ensure SSH agent has keys loaded ─────────────────────────
if ! ssh-add -l &>/dev/null; then
    # Try the default Ed25519 key first, then RSA fallback
    for KEY in "$HOME/.ssh/id_ed25519" "$HOME/.ssh/id_rsa"; do
        if [ -f "$KEY" ]; then
            ssh-add "$KEY" 2>/dev/null && log "SSH key loaded: $KEY" && break
        fi
    done
fi

# ── Verify SSH access to GitHub ──────────────────────────────
if ! ssh -T git@github.com -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new 2>&1 \
    | grep -q "successfully authenticated"; then
    log "ERROR: SSH auth to GitHub failed — skipping push"
    exit 1
fi

cd "$REPO" || { log "ERROR: Cannot find $REPO"; exit 1; }

# ── Switch remote to SSH (idempotent) ────────────────────────
CURRENT_REMOTE=$(git remote get-url origin 2>/dev/null)
SSH_REMOTE="git@github.com:Sabrina-Xia04/Haven.git"
if [ "$CURRENT_REMOTE" != "$SSH_REMOTE" ]; then
    git remote set-url origin "$SSH_REMOTE"
    log "Remote updated to SSH: $SSH_REMOTE"
fi

# ── Stage everything ──────────────────────────────────────────
git add -A

# ── Only commit if something changed ─────────────────────────
if git diff --cached --quiet; then
    log "No changes — nothing to commit"
    exit 0
fi

CHANGED=$(git diff --cached --name-only | wc -l | tr -d ' ')
COMMIT_MSG="Auto-sync: $(date '+%Y-%m-%d %H:%M') · ${CHANGED} file(s)"
git commit -m "$COMMIT_MSG"

# ── Push ──────────────────────────────────────────────────────
if git push origin main 2>> "$LOG"; then
    log "Pushed successfully — ${CHANGED} file(s)"
else
    log "ERROR: Push failed — check log above for SSH/network details"
    exit 1
fi

```

### Haven/ContentView.swift

```swift
import SwiftUI

struct ContentView: View {
    @StateObject private var vm = HavenViewModel()
    @GestureState private var drag = CGSize.zero

    var body: some View {
        GeometryReader { geo in
            let W = geo.size.width
            let H = geo.size.height

            ZStack {
                // ── Crossfading backgrounds (stay fixed, never slide) ────
                panelBackgrounds
                    .ignoresSafeArea()

                // ── Sliding panel content (transparent backgrounds) ──────
                ZStack {
                    makePanel(HomeView(vm: vm),        panelX: 0,  panelY: 0,  W: W, H: H)
                    makePanel(SeedsView(vm: vm),        panelX: 0,  panelY: -H, W: W, H: H)
                    makePanel(InsightsView(vm: vm),     panelX: 0,  panelY:  H, W: W, H: H)
                    makePanel(RhythmView(vm: vm),       panelX: -W, panelY: 0,  W: W, H: H)
                    makePanel(MemoryOceanView(vm: vm),  panelX:  W, panelY: 0,  W: W, H: H)
                }
                .clipped()
                .gesture(panGesture(W: W, H: H))
            }
        }
        .ignoresSafeArea()
    }

    // MARK: - Crossfading background layer
    @ViewBuilder
    private var panelBackgrounds: some View {
        ZStack {
            // Home — blue-teal
            RadialGradient(
                colors: [Color(hex: "1e3d52"), Color(hex: "152e3e"), Color(hex: "0e1e2c")],
                center: UnitPoint(x: 0.5, y: -0.08),
                startRadius: 0, endRadius: 600
            )
            .opacity(vm.currentPanel == .home ? 1 : 0)

            // Seeds — deep green
            RadialGradient(
                colors: [Color(hex: "163828"), Color(hex: "0f2a20"), Color(hex: "091a12")],
                center: UnitPoint(x: 0.5, y: 1.0),
                startRadius: 0, endRadius: 600
            )
            .opacity(vm.currentPanel == .seeds ? 1 : 0)

            // Insights — midnight blue
            RadialGradient(
                colors: [Color(hex: "1a2540"), Color(hex: "141c2e"), Color(hex: "0c1220")],
                center: UnitPoint(x: 0.5, y: 0),
                startRadius: 0, endRadius: 600
            )
            .opacity(vm.currentPanel == .insights ? 1 : 0)

            // Rhythm — blue-purple
            RadialGradient(
                colors: [Color(hex: "1e3050"), Color(hex: "182438"), Color(hex: "0e1828")],
                center: UnitPoint(x: 0.5, y: 0),
                startRadius: 0, endRadius: 600
            )
            .opacity(vm.currentPanel == .rhythm ? 1 : 0)

            // Memory — deep black-blue
            LinearGradient(
                colors: [Color(hex: "0e1e2c"), Color(hex: "091522"), Color(hex: "060f18")],
                startPoint: .top, endPoint: .bottom
            )
            .opacity(vm.currentPanel == .memory ? 1 : 0)
        }
        .animation(.easeInOut(duration: 0.4), value: vm.currentPanel)
    }

    // MARK: - Panel builder
    @ViewBuilder
    private func makePanel<V: View>(_ view: V, panelX: CGFloat, panelY: CGFloat,
                                    W: CGFloat, H: CGFloat) -> some View {
        view
            .frame(width: W, height: H)
            .offset(totalOffset(panelX: panelX, panelY: panelY, W: W, H: H))
            .animation(.spring(response: 0.45, dampingFraction: 0.82), value: vm.currentPanel)
    }

    // World shift to bring the active panel to (0,0), plus rubber-band drag
    private func totalOffset(panelX: CGFloat, panelY: CGFloat,
                              W: CGFloat, H: CGFloat) -> CGSize {
        let wx: CGFloat
        let wy: CGFloat
        switch vm.currentPanel {
        case .home:     wx =  0;  wy =  0
        case .seeds:    wx =  0;  wy =  H
        case .insights: wx =  0;  wy = -H
        case .rhythm:   wx =  W;  wy =  0
        case .memory:   wx = -W;  wy =  0
        }
        let resist: CGFloat = vm.currentPanel == .home ? 0.45 : 0.2
        return CGSize(width:  panelX + wx + drag.width  * resist,
                      height: panelY + wy + drag.height * resist)
    }

    // MARK: - Gesture
    private func panGesture(W: CGFloat, H: CGFloat) -> some Gesture {
        DragGesture(minimumDistance: 20)
            .updating($drag) { value, state, _ in
                let dx = abs(value.translation.width)
                let dy = abs(value.translation.height)
                state = dx > dy
                    ? CGSize(width: value.translation.width, height: 0)
                    : CGSize(width: 0, height: value.translation.height)
            }
            .onEnded { handleSwipe(value: $0) }
    }

    private func handleSwipe(value: DragGesture.Value) {
        let dx = value.translation.width
        let dy = value.translation.height
        let threshold: CGFloat = 55

        if vm.currentPanel == .home {
            if abs(dy) > abs(dx) {
                if dy >  threshold { vm.navigateTo(.seeds) }
                else if dy < -threshold { vm.navigateTo(.insights) }
            } else {
                if dx >  threshold { vm.navigateTo(.rhythm) }
                else if dx < -threshold { vm.navigateTo(.memory) }
            }
        } else {
            switch vm.currentPanel {
            case .seeds:    if dy < -threshold { vm.navigateTo(.home) }
            case .insights: if dy >  threshold { vm.navigateTo(.home) }
            case .rhythm:   if dx < -threshold { vm.navigateTo(.home) }
            case .memory:   if dx >  threshold { vm.navigateTo(.home) }
            default: break
            }
        }
    }
}

#Preview {
    ContentView()
}

```

### Haven/Config/Secrets.template.swift

```swift
// Copy this file to Secrets.swift and fill in your API keys.
// Secrets.swift is gitignored and must never be committed.
enum Secrets {
    static let deepgramAPIKey  = "YOUR_DEEPGRAM_API_KEY"
    static let anthropicAPIKey = "YOUR_ANTHROPIC_API_KEY"
}

```

### Haven/Config/HavenConfig.swift

```swift
import Foundation

enum HavenConfig {
    // Keys are loaded from Secrets.swift (gitignored — never committed)
    static let deepgramAPIKey  = Secrets.deepgramAPIKey
    static let anthropicAPIKey = Secrets.anthropicAPIKey

    // Deepgram models
    static let sttModel = "nova-3"          // lowest latency STT
    static let ttsVoice = "aura-2-luna-en"  // warm, soft female voice

    // Claude model
    static let agentModel = "claude-haiku-4-5-20251001"  // fastest Claude

    // Demo mode: auto-enabled when keys are still placeholders
    static var isDemoMode: Bool {
        deepgramAPIKey.hasPrefix("YOUR_") || anthropicAPIKey.hasPrefix("YOUR_")
    }
}

```

### Haven/Services/AudioPlayerService.swift

```swift
import AVFoundation

/// Queues and plays MP3/PCM audio data sequentially.
/// Clears the queue immediately when interrupted.
@MainActor
class AudioPlayerService: NSObject, ObservableObject, AVAudioPlayerDelegate {

    @Published var isPlaying = false

    private var queue: [Data] = []
    private var player: AVAudioPlayer?

    // MARK: - Enqueue audio data
    func enqueue(_ data: Data) {
        queue.append(data)
        if !isPlaying { playNext() }
    }

    // MARK: - Stop everything
    func stop() {
        player?.stop()
        player = nil
        queue.removeAll()
        isPlaying = false
    }

    // MARK: - Private
    private func playNext() {
        guard !queue.isEmpty else { isPlaying = false; return }
        let data = queue.removeFirst()
        do {
            player = try AVAudioPlayer(data: data)
            player?.delegate = self
            player?.play()
            isPlaying = true
        } catch {
            playNext()
        }
    }

    nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        Task { @MainActor in
            self.player = nil
            self.playNext()
        }
    }
}

```

### Haven/Services/DeepgramTTSService.swift

```swift
import Foundation
import AVFoundation

/// Converts text to speech using Deepgram Aura-2 (REST).
/// Splits Claude's streaming output at sentence boundaries for low latency.
actor DeepgramTTSService {

    private var pendingSentence = ""

    // MARK: - Synthesise full text → audio data
    func synthesize(_ text: String) async throws -> Data {
        guard !HavenConfig.isDemoMode else { throw TTSError.demoMode }

        var comps = URLComponents(string: "https://api.deepgram.com/v1/speak")!
        comps.queryItems = [.init(name: "model", value: HavenConfig.ttsVoice)]

        var req = URLRequest(url: comps.url!)
        req.httpMethod = "POST"
        req.setValue("Token \(HavenConfig.deepgramAPIKey)", forHTTPHeaderField: "Authorization")
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = try JSONSerialization.data(withJSONObject: ["text": text])

        let (data, resp) = try await URLSession.shared.data(for: req)
        guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
            throw TTSError.httpError
        }
        return data
    }

    // MARK: - Sentence-streaming helper
    /// Feed Claude streaming chunks here. Returns a complete sentence when one finishes.
    func feed(_ chunk: String) -> String? {
        pendingSentence += chunk
        // Split at sentence boundary followed by space/newline or end-of-string
        let pattern = "([.!?][\\s\\n]|[.!?]$)"
        if let range = pendingSentence.range(of: pattern, options: .regularExpression) {
            let sentence = String(pendingSentence[pendingSentence.startIndex...range.upperBound])
                .trimmingCharacters(in: .whitespacesAndNewlines)
            pendingSentence = String(pendingSentence[range.upperBound...])
            return sentence.isEmpty ? nil : sentence
        }
        return nil
    }

    func flushRemaining() -> String? {
        let s = pendingSentence.trimmingCharacters(in: .whitespacesAndNewlines)
        pendingSentence = ""
        return s.isEmpty ? nil : s
    }

    enum TTSError: Error { case demoMode, httpError }
}

```

### Haven/Components/VoiceButton.swift

```swift
import SwiftUI

/// Small circular mic button that lives at the bottom of HomeView.
struct VoiceButton: View {
    let isActive: Bool
    let action: () -> Void

    @State private var pulseScale: CGFloat = 1.0
    @State private var pulseOpacity: Double = 0.0

    var body: some View {
        Button(action: action) {
            ZStack {
                // Pulse ring (visible when active)
                Circle()
                    .stroke(Color(hex: "cde8f6").opacity(pulseOpacity), lineWidth: 1.5)
                    .frame(width: 58, height: 58)
                    .scaleEffect(pulseScale)

                // Button body
                Circle()
                    .fill(
                        RadialGradient(
                            colors: [Color(hex: "1e4a62").opacity(0.95),
                                     Color(hex: "0e1e2c").opacity(0.98)],
                            center: .center, startRadius: 0, endRadius: 24
                        )
                    )
                    .frame(width: 44, height: 44)
                    .overlay(
                        Circle().stroke(Color(hex: "cde8f6").opacity(isActive ? 0.6 : 0.28),
                                        lineWidth: 1.2)
                    )
                    .shadow(color: Color(hex: "cde8f6").opacity(isActive ? 0.35 : 0.12),
                            radius: isActive ? 12 : 6)

                // Mic icon
                Image(systemName: isActive ? "waveform" : "mic")
                    .font(.system(size: 16, weight: .medium))
                    .foregroundColor(Color(hex: "cde8f6").opacity(isActive ? 1.0 : 0.75))
                    .symbolEffect(.bounce, value: isActive)
            }
        }
        .buttonStyle(.plain)
        .onChange(of: isActive) { active in
            if active { startPulse() } else { stopPulse() }
        }
    }

    private func startPulse() {
        withAnimation(.easeOut(duration: 1.2).repeatForever(autoreverses: false)) {
            pulseScale   = 1.55
            pulseOpacity = 0.0
        }
        pulseOpacity = 0.55
    }

    private func stopPulse() {
        withAnimation(.easeOut(duration: 0.3)) {
            pulseScale   = 1.0
            pulseOpacity = 0.0
        }
    }
}

```

[17 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]