Breathe — complete source
All application code, build script, Info.plist, and chime generator.
Binary assets (AppIcon.png, chime.wav) are in Source/Resources/ and inside Breathe.app.


================================================================================
FILE: Sources/BreatheApp.swift
================================================================================

import AppKit
import Observation
import SwiftUI

@main
enum BreatheMain {
    static func main() {
        let app = NSApplication.shared
        let delegate = AppDelegate()
        app.delegate = delegate
        withExtendedLifetime(delegate) {
            app.setActivationPolicy(.accessory)
            app.run()
        }
    }
}

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
    private let model = AppModel.shared
    private var widgetPanel: NSPanel?
    private var settingsPanel: NSPanel?
    private var statusItem: NSStatusItem?
    private var observing = false

    func applicationDidFinishLaunching(_ notification: Notification) {
        let extras = NSRunningApplication.runningApplications(withBundleIdentifier: "com.local.breathe")
        if extras.count > 1 {
            NSApp.terminate(nil)
            return
        }

        model.start()
        setupStatusItem()
        setupWidget()
        setupSettings()
        observeModel()
        NSApp.activate(ignoringOtherApps: false)

        if CommandLine.arguments.contains("--preview") {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
                self?.model.breatheNow()
            }
        }
        if CommandLine.arguments.contains("--settings") {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
                self?.openSettings()
            }
        }
    }

    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
        false
    }

    private func setupWidget() {
        let size = model.widgetSize
        let panel = WidgetPanel(
            contentRect: NSRect(x: 0, y: 0, width: size, height: size),
            styleMask: [.borderless, .nonactivatingPanel],
            backing: .buffered,
            defer: false
        )
        panel.isOpaque = false
        panel.backgroundColor = .clear
        panel.hasShadow = false
        panel.level = .floating
        panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
        panel.isMovableByWindowBackground = false
        panel.hidesOnDeactivate = false
        panel.becomesKeyOnlyIfNeeded = true
        panel.acceptsMouseMovedEvents = true
        panel.isFloatingPanel = true

        let root = WidgetView()
            .environment(model)
        let container = BlobContainerView(frame: NSRect(x: 0, y: 0, width: size, height: size))
        let hosting = BlobHostingView(rootView: root)
        container.embed(hosting)
        panel.contentView = container
        panel.setFrame(NSRect(origin: defaultOrigin(size: size), size: NSSize(width: size, height: size)), display: true)
        panel.orderFrontRegardless()
        widgetPanel = panel
    }

    private func defaultOrigin(size: CGFloat) -> CGPoint {
        if model.hasSavedOrigin {
            return CGPoint(x: model.widgetOriginX, y: model.widgetOriginY)
        }
        if let vis = NSScreen.main?.visibleFrame {
            return CGPoint(x: vis.maxX - size - 28, y: vis.minY + 36)
        }
        return CGPoint(x: 80, y: 80)
    }

    private func setupSettings() {
        let panel = SettingsPanel(
            contentRect: NSRect(x: 0, y: 0, width: 340, height: 720),
            styleMask: [.titled, .closable, .fullSizeContentView],
            backing: .buffered,
            defer: false
        )
        panel.title = "Breathe"
        panel.titlebarAppearsTransparent = true
        panel.titleVisibility = .hidden
        panel.isOpaque = false
        panel.backgroundColor = NSColor(calibratedRed: 0.93, green: 0.94, blue: 0.90, alpha: 1)
        panel.level = .floating
        panel.isReleasedWhenClosed = false
        panel.hidesOnDeactivate = false
        panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
        let root = SettingsView()
            .environment(model)
        let hosting = NSHostingView(rootView: root)
        hosting.sizingOptions = [.preferredContentSize, .intrinsicContentSize]
        panel.contentView = hosting
        DispatchQueue.main.async {
            let fitted = hosting.fittingSize
            if fitted.height > 120 {
                panel.setContentSize(NSSize(width: 340, height: fitted.height))
            }
        }
        settingsPanel = panel
    }

    private func setupStatusItem() {
        let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
        if let button = item.button {
            let image = NSImage(systemSymbolName: "wind", accessibilityDescription: "Breathe")
            image?.isTemplate = true
            button.image = image ?? Self.statusImage()
            button.toolTip = "Breathe"
        }
        let menu = NSMenu()
        menu.delegate = self
        item.menu = menu
        statusItem = item
        rebuildMenu(menu)
    }

    func menuNeedsUpdate(_ menu: NSMenu) {
        rebuildMenu(menu)
    }

    private func rebuildMenu(_ menu: NSMenu) {
        menu.removeAllItems()
        let pause = NSMenuItem(
            title: model.paused ? "Resume Reminders" : "Pause Reminders",
            action: #selector(togglePaused),
            keyEquivalent: "p"
        )
        pause.target = self
        menu.addItem(pause)

        let now = NSMenuItem(title: "Breathe Now", action: #selector(breatheNow), keyEquivalent: "b")
        now.target = self
        menu.addItem(now)

        menu.addItem(.separator())

        let settings = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",")
        settings.target = self
        menu.addItem(settings)

        menu.addItem(.separator())

        let quit = NSMenuItem(title: "Quit Breathe", action: #selector(quit), keyEquivalent: "q")
        quit.target = self
        menu.addItem(quit)
    }

    @objc private func togglePaused() {
        model.togglePaused()
    }

    @objc private func breatheNow() {
        model.breatheNow()
    }

    @objc private func openSettings() {
        model.showingSettings = true
        showSettings()
    }

    @objc private func quit() {
        NSApp.terminate(nil)
    }

    private func observeModel() {
        guard !observing else { return }
        observing = true
        watch()
    }

    private func watch() {
        withObservationTracking {
            _ = model.showingSettings
            _ = model.widgetSize
        } onChange: { [weak self] in
            DispatchQueue.main.async {
                self?.syncSettingsVisibility()
                self?.syncWidgetFrame()
                self?.observing = false
                self?.observeModel()
            }
        }
    }

    private func syncWidgetFrame() {
        guard let panel = widgetPanel else { return }
        let size = model.widgetSize
        guard abs(panel.frame.width - size) > 0.5 else { return }
        let center = CGPoint(x: panel.frame.midX, y: panel.frame.midY)
        panel.setFrame(
            NSRect(x: center.x - size / 2, y: center.y - size / 2, width: size, height: size),
            display: true
        )
    }

    private func syncSettingsVisibility() {
        if model.showingSettings {
            showSettings()
        } else {
            settingsPanel?.orderOut(nil)
        }
    }

    private func showSettings() {
        guard let panel = settingsPanel else { return }
        positionSettings(panel)
        NSApp.activate(ignoringOtherApps: true)
        panel.makeKeyAndOrderFront(nil)
        model.showingSettings = true
    }

    private func positionSettings(_ panel: NSPanel) {
        let size = panel.frame.size
        if let widget = widgetPanel {
            let wf = widget.frame
            var x = wf.minX - size.width - 16
            var y = wf.midY - size.height / 2
            if let screen = widget.screen ?? NSScreen.main {
                let vis = screen.visibleFrame
                if x < vis.minX {
                    x = wf.maxX + 16
                }
                y = min(max(y, vis.minY + 12), vis.maxY - size.height - 12)
            }
            panel.setFrameOrigin(NSPoint(x: x, y: y))
        } else if let vis = NSScreen.main?.visibleFrame {
            panel.setFrameOrigin(NSPoint(x: vis.midX - size.width / 2, y: vis.midY - size.height / 2))
        }
    }

    private static func statusImage() -> NSImage {
        let size = NSSize(width: 18, height: 18)
        let image = NSImage(size: size, flipped: false) { rect in
            let outer = rect.insetBy(dx: 2.2, dy: 2.2)
            let path = NSBezierPath(ovalIn: outer)
            path.lineWidth = 1.5
            NSColor.black.setStroke()
            path.stroke()
            let inner = rect.insetBy(dx: 6.2, dy: 6.2)
            NSBezierPath(ovalIn: inner).fill()
            return true
        }
        image.isTemplate = true
        return image
    }
}

final class WidgetPanel: NSPanel {
    override var canBecomeKey: Bool { true }
    override var canBecomeMain: Bool { false }
}

final class SettingsPanel: NSPanel {
    override var canBecomeKey: Bool { true }
    override var canBecomeMain: Bool { true }

    override func close() {
        MainActor.assumeIsolated {
            AppModel.shared.showingSettings = false
        }
        orderOut(nil)
    }
}

final class BlobContainerView: NSView {
    let resizeKnob = ResizeHandleView()
    private var hosting: NSView?

    override var isOpaque: Bool { false }
    override var mouseDownCanMoveWindow: Bool { false }

    override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }

    func embed(_ hosting: NSView) {
        self.hosting?.removeFromSuperview()
        hosting.frame = bounds
        hosting.autoresizingMask = [.width, .height]
        addSubview(hosting, positioned: .below, relativeTo: resizeKnob)
        self.hosting = hosting
        if resizeKnob.superview != self {
            addSubview(resizeKnob, positioned: .above, relativeTo: hosting)
        }
        layoutKnob()
    }

    override func viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        wantsLayer = true
        layer?.isOpaque = false
        layer?.backgroundColor = NSColor.clear.cgColor
        window?.isOpaque = false
        window?.backgroundColor = .clear
        layoutKnob()
    }

    override func setFrameSize(_ newSize: NSSize) {
        super.setFrameSize(newSize)
        hosting?.frame = bounds
        layoutKnob()
    }

    override func layout() {
        super.layout()
        hosting?.frame = bounds
        layoutKnob()
    }

    private func layoutKnob() {
        let side = max(44, min(bounds.width, bounds.height) * 0.30)
        resizeKnob.frame = NSRect(
            x: bounds.maxX - side - 1,
            y: 1,
            width: side,
            height: side
        )
    }

    override func hitTest(_ point: NSPoint) -> NSView? {
        if resizeKnob.frame.contains(point) {
            return resizeKnob
        }
        let cx = bounds.midX
        let cy = bounds.midY
        let r = min(bounds.width, bounds.height) * 0.52
        if hypot(point.x - cx, point.y - cy) > r {
            return nil
        }
        return hosting?.hitTest(point) ?? super.hitTest(point)
    }

    override func scrollWheel(with event: NSEvent) {
        MainActor.assumeIsolated {
            guard let window else { return }
            let delta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY * 0.6 : event.scrollingDeltaY * 8
            AppModel.shared.applySize(AppModel.shared.widgetSize + delta, to: window)
        }
    }

    override func magnify(with event: NSEvent) {
        MainActor.assumeIsolated {
            guard let window else { return }
            let model = AppModel.shared
            model.applySize(model.widgetSize * (1 + event.magnification), to: window)
        }
    }
}

final class BlobHostingView<Content: View>: NSHostingView<Content> {
    override var isOpaque: Bool { false }
    override var mouseDownCanMoveWindow: Bool { false }
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }

    override func viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        wantsLayer = true
        layer?.isOpaque = false
        layer?.backgroundColor = NSColor.clear.cgColor
    }

    override func scrollWheel(with event: NSEvent) {
        nextResponder?.scrollWheel(with: event)
    }

    override func magnify(with event: NSEvent) {
        nextResponder?.magnify(with: event)
    }
}

final class ResizeHandleView: NSView {
    private var hovering = false
    private var dragging = false
    private var startSize: CGFloat = 0
    private var startScreen = NSPoint.zero
    private var tracking: NSTrackingArea?

    override var isOpaque: Bool { false }
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }

    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        if let tracking {
            removeTrackingArea(tracking)
        }
        let area = NSTrackingArea(
            rect: bounds,
            options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
            owner: self,
            userInfo: nil
        )
        addTrackingArea(area)
        tracking = area
    }

    override func resetCursorRects() {
        addCursorRect(bounds, cursor: Self.resizeCursor)
    }

    override func mouseEntered(with event: NSEvent) {
        hovering = true
        NSCursor.setHiddenUntilMouseMoves(false)
        Self.resizeCursor.set()
        needsDisplay = true
    }

    override func mouseExited(with event: NSEvent) {
        hovering = false
        if !dragging {
            NSCursor.arrow.set()
        }
        needsDisplay = true
    }

    override func mouseDown(with event: NSEvent) {
        guard let window else { return }
        MainActor.assumeIsolated {
            startSize = AppModel.shared.widgetSize
        }
        startScreen = window.convertPoint(toScreen: event.locationInWindow)
        dragging = true
        Self.resizeCursor.set()
        needsDisplay = true
    }

    override func mouseDragged(with event: NSEvent) {
        guard let window else { return }
        let now = window.convertPoint(toScreen: event.locationInWindow)
        let delta = (now.x - startScreen.x) - (now.y - startScreen.y)
        MainActor.assumeIsolated {
            AppModel.shared.applySize(startSize + delta, to: window)
        }
    }

    override func mouseUp(with event: NSEvent) {
        dragging = false
        if !hovering {
            NSCursor.arrow.set()
        }
        needsDisplay = true
    }

    override func draw(_ dirtyRect: NSRect) {
        let active = hovering || dragging
        let alpha: CGFloat = active ? 0.98 : 0.72
        let inset = bounds.insetBy(dx: bounds.width * 0.28, dy: bounds.height * 0.28)

        NSColor.black.withAlphaComponent(active ? 0.18 : 0.08).setFill()
        NSBezierPath(ovalIn: bounds.insetBy(dx: 4, dy: 4)).fill()

        let path = NSBezierPath()
        path.lineWidth = max(2.4, bounds.width * 0.07)
        path.lineCapStyle = .round
        path.lineJoinStyle = .round
        path.move(to: CGPoint(x: inset.minX, y: inset.minY + 2))
        path.line(to: CGPoint(x: inset.maxX, y: inset.minY + 2))
        path.line(to: CGPoint(x: inset.maxX, y: inset.maxY))
        NSColor.white.withAlphaComponent(alpha).setStroke()
        path.stroke()
    }

    private static var resizeCursor: NSCursor {
        if #available(macOS 15.0, *) {
            return NSCursor.frameResize(position: .bottomRight, directions: .all)
        }
        return NSCursor.crosshair
    }
}

struct WindowReader: NSViewRepresentable {
    var onChange: (NSWindow?) -> Void

    func makeNSView(context: Context) -> ReaderView {
        ReaderView(onChange: onChange)
    }

    func updateNSView(_ nsView: ReaderView, context: Context) {
        nsView.onChange = onChange
        DispatchQueue.main.async { onChange(nsView.window) }
    }

    final class ReaderView: NSView {
        var onChange: (NSWindow?) -> Void

        init(onChange: @escaping (NSWindow?) -> Void) {
            self.onChange = onChange
            super.init(frame: .zero)
        }

        required init?(coder: NSCoder) { nil }

        override func viewDidMoveToWindow() {
            onChange(window)
        }
    }
}


================================================================================
FILE: Sources/AppModel.swift
================================================================================

import AppKit
import Foundation
import Observation

@MainActor
@Observable
final class AppModel {
    static let shared = AppModel()

    var intervalMinutes: Int {
        didSet {
            guard !isSettingUp else { return }
            remainingSeconds = min(remainingSeconds, intervalMinutes * 60)
            persist()
        }
    }
    var startMinutes: Int {
        didSet { persistIfReady() }
    }
    var endMinutes: Int {
        didSet { persistIfReady() }
    }
    var allDay: Bool {
        didSet { persistIfReady() }
    }
    var soundEnabled: Bool {
        didSet { persistIfReady() }
    }
    var soundVolume: Double {
        didSet { persistIfReady() }
    }
    var paused: Bool {
        didSet { persistIfReady() }
    }
    var openAtLogin: Bool {
        didSet {
            guard !isSettingUp else { return }
            persist()
            LoginItem.apply(enabled: openAtLogin)
        }
    }
    var widgetSize: CGFloat {
        didSet { persistIfReady() }
    }
    var widgetOriginX: Double {
        didSet { persistIfReady() }
    }
    var widgetOriginY: Double {
        didSet { persistIfReady() }
    }
    var hasSavedOrigin: Bool

    var remainingSeconds: Int
    var isReminding = false
    var showingSettings = false
    var hoverControls = false

    private var timer: Timer?
    private var isSettingUp = true
    private let defaults = UserDefaults.standard

    private enum Key {
        static let interval = "intervalMinutes"
        static let start = "startMinutes"
        static let end = "endMinutes"
        static let allDay = "allDay"
        static let sound = "soundEnabled"
        static let volume = "soundVolume"
        static let paused = "paused"
        static let login = "openAtLogin"
        static let size = "widgetSize"
        static let originX = "widgetOriginX"
        static let originY = "widgetOriginY"
        static let hasOrigin = "hasSavedOrigin"
    }

    private init() {
        let d = UserDefaults.standard
        let interval = d.object(forKey: Key.interval) as? Int ?? 15
        intervalMinutes = interval
        startMinutes = d.object(forKey: Key.start) as? Int ?? 8 * 60
        endMinutes = d.object(forKey: Key.end) as? Int ?? 22 * 60
        allDay = d.object(forKey: Key.allDay) as? Bool ?? true
        soundEnabled = d.object(forKey: Key.sound) as? Bool ?? true
        soundVolume = d.object(forKey: Key.volume) as? Double ?? 0.55
        paused = d.bool(forKey: Key.paused)
        openAtLogin = d.bool(forKey: Key.login)
        widgetSize = d.object(forKey: Key.size) as? Double ?? 128
        widgetOriginX = d.double(forKey: Key.originX)
        widgetOriginY = d.double(forKey: Key.originY)
        hasSavedOrigin = d.bool(forKey: Key.hasOrigin)
        remainingSeconds = interval * 60
        isSettingUp = false
    }

    private func persistIfReady() {
        guard !isSettingUp else { return }
        persist()
    }

    func start() {
        timer?.invalidate()
        let t = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
            Task { @MainActor in
                self?.tick()
            }
        }
        RunLoop.main.add(t, forMode: .common)
        timer = t
        if openAtLogin {
            LoginItem.apply(enabled: true)
        }
    }

    var inActiveHours: Bool {
        if allDay { return true }
        let now = Calendar.current.dateComponents([.hour, .minute], from: Date())
        let mins = (now.hour ?? 0) * 60 + (now.minute ?? 0)
        if startMinutes == endMinutes { return true }
        if startMinutes < endMinutes {
            return mins >= startMinutes && mins < endMinutes
        }
        return mins >= startMinutes || mins < endMinutes
    }

    var alertsArmed: Bool {
        !paused && inActiveHours && !isReminding
    }

    var statusLine: String {
        if paused { return "Paused" }
        if !inActiveHours { return "Resting until \(Self.formatMinutes(startMinutes))" }
        let m = remainingSeconds / 60
        let s = remainingSeconds % 60
        if isReminding { return "Breathe" }
        if m <= 0 { return "Next in \(s)s" }
        return "Next in \(m)m"
    }

    func tick() {
        guard alertsArmed else { return }
        remainingSeconds -= 1
        if remainingSeconds <= 0 {
            beginReminder()
        }
    }

    func beginReminder() {
        guard !isReminding else { return }
        isReminding = true
        remainingSeconds = intervalMinutes * 60
        if soundEnabled {
            ChimePlayer.shared.play(volume: soundVolume)
        }
    }

    func completeReminder() {
        isReminding = false
        remainingSeconds = intervalMinutes * 60
    }

    func togglePaused() {
        paused.toggle()
        if paused {
            isReminding = false
        }
    }

    func breatheNow() {
        if paused { paused = false }
        beginReminder()
    }

    func saveOrigin(_ origin: CGPoint) {
        widgetOriginX = origin.x
        widgetOriginY = origin.y
        hasSavedOrigin = true
        persist()
    }

    func maxWidgetSize(for screen: NSScreen?) -> CGFloat {
        let frame = screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
        return max(frame.width, frame.height) * 0.22
    }

    func minWidgetSize() -> CGFloat { 72 }

    func clampSize(_ size: CGFloat, screen: NSScreen?) -> CGFloat {
        min(max(size, minWidgetSize()), maxWidgetSize(for: screen))
    }

    func applySize(_ size: CGFloat, to window: NSWindow) {
        let next = clampSize(size, screen: window.screen)
        let center = CGPoint(x: window.frame.midX, y: window.frame.midY)
        widgetSize = next
        let frame = NSRect(x: center.x - next / 2, y: center.y - next / 2, width: next, height: next)
        window.setFrame(frame, display: true)
        saveOrigin(frame.origin)
    }

    static func formatMinutes(_ minutes: Int) -> String {
        let h = ((minutes / 60) % 24 + 24) % 24
        let m = ((minutes % 60) + 60) % 60
        var comps = DateComponents()
        comps.hour = h
        comps.minute = m
        let date = Calendar.current.date(from: comps) ?? Date()
        let f = DateFormatter()
        f.timeStyle = .short
        return f.string(from: date)
    }

    private func persist() {
        defaults.set(intervalMinutes, forKey: Key.interval)
        defaults.set(startMinutes, forKey: Key.start)
        defaults.set(endMinutes, forKey: Key.end)
        defaults.set(allDay, forKey: Key.allDay)
        defaults.set(soundEnabled, forKey: Key.sound)
        defaults.set(soundVolume, forKey: Key.volume)
        defaults.set(paused, forKey: Key.paused)
        defaults.set(openAtLogin, forKey: Key.login)
        defaults.set(Double(widgetSize), forKey: Key.size)
        defaults.set(widgetOriginX, forKey: Key.originX)
        defaults.set(widgetOriginY, forKey: Key.originY)
        defaults.set(hasSavedOrigin, forKey: Key.hasOrigin)
    }
}

enum LoginItem {
    static let label = "com.local.breathe"

    static func apply(enabled: Bool) {
        let agents = FileManager.default.homeDirectoryForCurrentUser
            .appendingPathComponent("Library/LaunchAgents")
        let plist = agents.appendingPathComponent("\(label).plist")
        if enabled {
            try? FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
            let appPath = Bundle.main.bundlePath
            let dict: [String: Any] = [
                "Label": label,
                "ProgramArguments": ["/usr/bin/open", "-a", appPath],
                "RunAtLoad": true
            ]
            if let data = try? PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) {
                try? data.write(to: plist)
            }
        } else {
            try? FileManager.default.removeItem(at: plist)
        }
    }
}


================================================================================
FILE: Sources/WidgetView.swift
================================================================================

import AppKit
import SwiftUI

struct WidgetView: View {
    @Environment(AppModel.self) private var model

    @State private var window: NSWindow?
    @State private var hovering = false
    @State private var dragOrigin: CGPoint?
    @State private var resizeStart: CGFloat?
    @State private var dragging = false
    @State private var resizing = false
    @State private var showWord = false

    var body: some View {
        ZStack {
            TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: false)) { timeline in
                blobDrawing(at: timeline.date.timeIntervalSinceReferenceDate)
            }
            controls
                .opacity(hovering || dragging || resizing ? 1 : 0)
                .allowsHitTesting(hovering || dragging || resizing)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .contentShape(Circle())
        .background(WindowReader { window = $0 })
        .onHover { inside in
            hovering = inside
            model.hoverControls = inside
        }
        .gesture(dragGesture)
        .onAppear { showWord = model.isReminding }
        .onChange(of: model.isReminding) { _, reminding in
            withAnimation(.easeInOut(duration: 0.6)) {
                showWord = reminding
            }
        }
        .task(id: model.isReminding) {
            guard model.isReminding else { return }
            try? await Task.sleep(for: .seconds(29))
            if !Task.isCancelled, model.isReminding {
                model.completeReminder()
            }
        }
        .containerBackground(.clear, for: .window)
        .background(Color.clear)
        .accessibilityElement(children: .combine)
        .accessibilityLabel("Breathing reminder")
        .accessibilityValue(model.statusLine)
    }

    @ViewBuilder
    private func blobDrawing(at time: TimeInterval) -> some View {
        let phase = time * 0.32
        let breath = breathScale(at: time)
        let size = model.widgetSize
        let palette = colors
        let halo = reminderHalo(at: time)

        ZStack {
            OrganicBlob(phase: phase + 0.4)
                .fill(palette.glow.opacity(model.paused ? 0.18 : 0.42))
                .blur(radius: size * 0.07)
                .scaleEffect(1.06 * breath)

            if halo > 0 {
                OrganicBlob(phase: phase)
                    .stroke(palette.warm.opacity(0.55 * (1 - halo)), lineWidth: max(2, size * 0.016))
                    .scaleEffect(0.72 + 0.38 * halo)
                    .blur(radius: 0.5)
            }

            OrganicBlob(phase: phase)
                .fill(
                    RadialGradient(
                        colors: [palette.inner, palette.mid, palette.outer],
                        center: UnitPoint(x: 0.36, y: 0.30),
                        startRadius: 0,
                        endRadius: size * 0.42
                    )
                )
                .scaleEffect(breath)
                .shadow(color: .black.opacity(0.22), radius: size * 0.06, y: size * 0.03)

            OrganicBlob(phase: phase)
                .stroke(palette.outer.opacity(0.35), lineWidth: 0.8)
                .scaleEffect(breath)

            OrganicBlob(phase: phase + 0.55)
                .fill(Color.white.opacity(model.paused ? 0.12 : 0.34))
                .scaleEffect(0.38 * breath)
                .offset(x: -size * 0.08, y: -size * 0.10)
                .blur(radius: size * 0.04)

            if showWord || hovering {
                VStack(spacing: size * 0.02) {
                    Text(centerWord)
                        .font(.system(size: max(11, size * 0.11), weight: .light, design: .rounded))
                        .foregroundStyle(Color.white.opacity(0.92))
                        .shadow(color: .black.opacity(0.22), radius: 4, y: 1)
                    if hovering && !model.isReminding {
                        Text(model.statusLine)
                            .font(.system(size: max(8, size * 0.065), weight: .regular, design: .rounded))
                            .foregroundStyle(Color.white.opacity(0.72))
                    }
                }
                .offset(y: hovering ? -size * 0.04 : 0)
                .allowsHitTesting(false)
            }
        }
        .opacity(model.inActiveHours || model.paused ? 1 : 0.78)
        .animation(.easeInOut(duration: 0.45), value: model.paused)
        .animation(.easeInOut(duration: 0.45), value: model.isReminding)
    }

    private var centerWord: String {
        if model.isReminding { return "breathe" }
        if model.paused { return "paused" }
        if !model.inActiveHours { return "resting" }
        return "relax"
    }

    private func breathScale(at time: TimeInterval) -> CGFloat {
        if model.paused { return 0.96 }
        let period: Double = model.isReminding ? 10.0 : 10.5
        let amp: Double = model.isReminding ? 0.16 : 0.045
        let p = time.truncatingRemainder(dividingBy: period) / period
        let eased: Double
        if p < 0.42 {
            eased = Self.smoothstep(p / 0.42)
        } else if p < 0.52 {
            eased = 1
        } else {
            eased = 1 - Self.smoothstep((p - 0.52) / 0.48)
        }
        return CGFloat(1.0 + amp * eased)
    }

    private func reminderHalo(at time: TimeInterval) -> CGFloat {
        guard model.isReminding else { return 0 }
        let period = 10.0
        let p = time.truncatingRemainder(dividingBy: period) / period
        if p > 0.45 { return 0 }
        return CGFloat(Self.smoothstep(p / 0.45))
    }

    private var colors: (inner: Color, mid: Color, outer: Color, glow: Color, warm: Color) {
        if model.paused {
            return (
                Color(red: 0.62, green: 0.66, blue: 0.64),
                Color(red: 0.48, green: 0.54, blue: 0.52),
                Color(red: 0.36, green: 0.42, blue: 0.40),
                Color(red: 0.70, green: 0.74, blue: 0.72),
                Color(red: 0.78, green: 0.76, blue: 0.70)
            )
        }
        if model.isReminding {
            return (
                Color(red: 0.98, green: 0.90, blue: 0.72),
                Color(red: 0.86, green: 0.62, blue: 0.48),
                Color(red: 0.72, green: 0.42, blue: 0.40),
                Color(red: 1.0, green: 0.78, blue: 0.55),
                Color(red: 1.0, green: 0.86, blue: 0.62)
            )
        }
        if !model.inActiveHours {
            return (
                Color(red: 0.55, green: 0.62, blue: 0.70),
                Color(red: 0.38, green: 0.46, blue: 0.56),
                Color(red: 0.26, green: 0.32, blue: 0.42),
                Color(red: 0.50, green: 0.58, blue: 0.70),
                Color(red: 0.70, green: 0.78, blue: 0.88)
            )
        }
        return (
            Color(red: 0.90, green: 0.97, blue: 0.90),
            Color(red: 0.42, green: 0.74, blue: 0.58),
            Color(red: 0.18, green: 0.52, blue: 0.46),
            Color(red: 0.55, green: 0.88, blue: 0.70),
            Color(red: 0.95, green: 0.86, blue: 0.55)
        )
    }

    private var controls: some View {
        let size = model.widgetSize
        return VStack {
            Spacer()
            HStack(spacing: size * 0.06) {
                controlButton(system: model.paused ? "play.fill" : "pause.fill") {
                    model.togglePaused()
                }
                controlButton(system: "slider.horizontal.3") {
                    model.showingSettings.toggle()
                }
            }
            .padding(.bottom, size * 0.22)
        }
        .animation(.easeInOut(duration: 0.2), value: hovering)
    }

    private func controlButton(system: String, action: @escaping () -> Void) -> some View {
        let size = model.widgetSize
        return Button(action: action) {
            Image(systemName: system)
                .font(.system(size: max(10, size * 0.09), weight: .medium))
                .foregroundStyle(.white.opacity(0.92))
                .frame(width: max(22, size * 0.18), height: max(22, size * 0.18))
                .background(.white.opacity(0.16), in: Circle())
        }
        .buttonStyle(.plain)
        .help(system.contains("pause") || system.contains("play") ? (model.paused ? "Resume" : "Pause") : "Settings")
    }

    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 2)
            .onChanged { value in
                guard let window else { return }
                let optionResize = NSEvent.modifierFlags.contains(.option)
                if !dragging && !resizing {
                    if optionResize {
                        resizing = true
                        resizeStart = model.widgetSize
                    } else {
                        dragging = true
                        dragOrigin = window.frame.origin
                    }
                }
                if resizing {
                    guard let start = resizeStart else { return }
                    let delta = value.translation.width + value.translation.height
                    model.applySize(start + delta, to: window)
                } else if dragging {
                    guard let origin = dragOrigin else { return }
                    var next = CGPoint(
                        x: origin.x + value.translation.width,
                        y: origin.y - value.translation.height
                    )
                    if let screen = window.screen ?? NSScreen.main {
                        let vis = screen.visibleFrame
                        let w = window.frame.width
                        let h = window.frame.height
                        next.x = min(max(next.x, vis.minX - w * 0.35), vis.maxX - w * 0.65)
                        next.y = min(max(next.y, vis.minY - h * 0.35), vis.maxY - h * 0.65)
                    }
                    window.setFrameOrigin(next)
                }
            }
            .onEnded { _ in
                if let window {
                    model.saveOrigin(window.frame.origin)
                }
                dragOrigin = nil
                resizeStart = nil
                dragging = false
                resizing = false
            }
    }

    private static func smoothstep(_ x: Double) -> Double {
        let t = max(0, min(1, x))
        return t * t * (3 - 2 * t)
    }
}


================================================================================
FILE: Sources/SettingsView.swift
================================================================================

import AppKit
import SwiftUI

struct SettingsView: View {
    @Environment(AppModel.self) private var model
    @State private var startDate: Date = Date()
    @State private var endDate: Date = Date()

    private let sage = Color(red: 0.32, green: 0.56, blue: 0.48)
    private let ink = Color(red: 0.18, green: 0.26, blue: 0.23)

    var body: some View {
        @Bindable var model = model
        VStack(alignment: .leading, spacing: 14) {
            Text("A quiet nudge to unclench, inhale, and come back.")
                .font(.system(size: 12, weight: .regular, design: .rounded))
                .foregroundStyle(ink.opacity(0.7))
                .padding(.top, 6)

            group("Interval") {
                HStack {
                    Text("Every")
                        .foregroundStyle(ink)
                    Spacer()
                    Text("\(model.intervalMinutes) min")
                        .font(.system(size: 15, weight: .medium, design: .rounded))
                        .foregroundStyle(ink)
                        .monospacedDigit()
                }
                Slider(
                    value: Binding(
                        get: { Double(model.intervalMinutes) },
                        set: { model.intervalMinutes = Int($0.rounded()) }
                    ),
                    in: 2...60,
                    step: 1
                )
                .tint(sage)
                HStack {
                    ForEach([5, 10, 15, 20, 30, 45], id: \.self) { minutes in
                        Button("\(minutes)m") {
                            model.intervalMinutes = minutes
                            model.remainingSeconds = minutes * 60
                        }
                        .buttonStyle(.plain)
                        .font(.system(size: 11, weight: .medium, design: .rounded))
                        .foregroundStyle(ink.opacity(0.85))
                        .padding(.horizontal, 7)
                        .padding(.vertical, 4)
                        .background(
                            Capsule().fill(
                                model.intervalMinutes == minutes
                                    ? sage.opacity(0.22)
                                    : Color.black.opacity(0.06)
                            )
                        )
                    }
                }
            }

            group("Active hours") {
                Toggle("All day", isOn: $model.allDay)
                    .toggleStyle(.switch)
                    .tint(sage)
                    .foregroundStyle(ink)
                HStack {
                    VStack(alignment: .leading, spacing: 4) {
                        Text("From").font(.caption).foregroundStyle(ink.opacity(0.55))
                        DatePicker("", selection: $startDate, displayedComponents: .hourAndMinute)
                            .labelsHidden()
                            .datePickerStyle(.compact)
                            .disabled(model.allDay)
                    }
                    Spacer()
                    VStack(alignment: .leading, spacing: 4) {
                        Text("To").font(.caption).foregroundStyle(ink.opacity(0.55))
                        DatePicker("", selection: $endDate, displayedComponents: .hourAndMinute)
                            .labelsHidden()
                            .datePickerStyle(.compact)
                            .disabled(model.allDay)
                    }
                }
                .opacity(model.allDay ? 0.45 : 1)
            }

            group("Alert") {
                Toggle("Gentle chime", isOn: $model.soundEnabled)
                    .toggleStyle(.switch)
                    .tint(sage)
                    .foregroundStyle(ink)
                if model.soundEnabled {
                    HStack {
                        Image(systemName: "speaker.fill")
                            .font(.caption)
                            .foregroundStyle(ink.opacity(0.5))
                        Slider(value: $model.soundVolume, in: 0.1...1)
                            .tint(sage)
                        Image(systemName: "speaker.wave.2.fill")
                            .font(.caption)
                            .foregroundStyle(ink.opacity(0.5))
                    }
                    Button("Preview reminder") {
                        model.breatheNow()
                    }
                    .font(.system(size: 12, weight: .medium, design: .rounded))
                    .foregroundStyle(sage)
                    .buttonStyle(.plain)
                }
            }

            group("Size") {
                HStack {
                    Text("Orb")
                        .foregroundStyle(ink)
                    Spacer()
                    Text("\(Int(model.widgetSize)) pt")
                        .font(.system(size: 13, weight: .medium, design: .rounded))
                        .foregroundStyle(ink)
                        .monospacedDigit()
                }
                Slider(
                    value: Binding(
                        get: { Double(model.widgetSize) },
                        set: { model.widgetSize = CGFloat($0) }
                    ),
                    in: Double(model.minWidgetSize())...Double(model.maxWidgetSize(for: NSScreen.main)),
                    step: 1
                )
                .tint(sage)
                Text("Or drag the corner grip on the orb, pinch, or scroll over it.")
                    .font(.system(size: 11, design: .rounded))
                    .foregroundStyle(ink.opacity(0.5))
            }

            group("General") {
                Toggle("Open at login", isOn: $model.openAtLogin)
                    .toggleStyle(.switch)
                    .tint(sage)
                    .foregroundStyle(ink)
                Toggle("Pause reminders", isOn: $model.paused)
                    .toggleStyle(.switch)
                    .tint(sage)
                    .foregroundStyle(ink)
            }

            Text(summary)
                .font(.system(size: 11, weight: .regular, design: .rounded))
                .foregroundStyle(ink.opacity(0.6))
                .fixedSize(horizontal: false, vertical: true)

            HStack {
                Button("Breathe now") { model.breatheNow() }
                    .buttonStyle(.borderedProminent)
                    .tint(sage)
                Spacer()
                Button("Quit") { NSApp.terminate(nil) }
                    .buttonStyle(.plain)
                    .foregroundStyle(ink.opacity(0.55))
            }
            .font(.system(size: 13, weight: .medium, design: .rounded))
        }
        .padding(.horizontal, 20)
        .padding(.bottom, 18)
        .padding(.top, 28)
        .frame(width: 340)
        .foregroundStyle(ink)
        .background(Color(red: 0.93, green: 0.94, blue: 0.90))
        .onAppear { syncDatesFromModel() }
        .onChange(of: startDate) { _, date in
            model.startMinutes = Self.minutes(from: date)
        }
        .onChange(of: endDate) { _, date in
            model.endMinutes = Self.minutes(from: date)
        }
    }

    private var summary: String {
        let interval = "every \(model.intervalMinutes) minutes"
        if model.allDay {
            return "Reminds you \(interval), all day. Drag the orb to move it. Drag the corner to resize."
        }
        return "Reminds you \(interval), from \(AppModel.formatMinutes(model.startMinutes)) to \(AppModel.formatMinutes(model.endMinutes)). Drag the orb to move it. Drag the corner to resize."
    }

    @ViewBuilder
    private func group(_ title: String, @ViewBuilder content: () -> some View) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title.uppercased())
                .font(.system(size: 10, weight: .semibold, design: .rounded))
                .foregroundStyle(ink.opacity(0.45))
                .tracking(0.6)
            content()
        }
        .padding(12)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: 14, style: .continuous)
                .fill(Color.white.opacity(0.72))
        )
    }

    private func syncDatesFromModel() {
        startDate = Self.date(fromMinutes: model.startMinutes)
        endDate = Self.date(fromMinutes: model.endMinutes)
    }

    private static func minutes(from date: Date) -> Int {
        let c = Calendar.current.dateComponents([.hour, .minute], from: date)
        return (c.hour ?? 0) * 60 + (c.minute ?? 0)
    }

    private static func date(fromMinutes minutes: Int) -> Date {
        let h = ((minutes / 60) % 24 + 24) % 24
        let m = ((minutes % 60) + 60) % 60
        var comps = Calendar.current.dateComponents([.year, .month, .day], from: Date())
        comps.hour = h
        comps.minute = m
        return Calendar.current.date(from: comps) ?? Date()
    }
}


================================================================================
FILE: Sources/BlobShape.swift
================================================================================

import SwiftUI

struct OrganicBlob: Shape {
    var phase: Double

    var animatableData: Double {
        get { phase }
        set { phase = newValue }
    }

    func path(in rect: CGRect) -> Path {
        let cx = rect.midX
        let cy = rect.midY
        let base = min(rect.width, rect.height) / 2
        let count = 8
        var points: [CGPoint] = []
        for i in 0..<count {
            let t = (Double(i) / Double(count)) * .pi * 2 - .pi / 2
            let n1 = sin(t * 2 + phase)
            let n2 = cos(t * 3 - phase * 0.73)
            let n3 = sin(t * 5 + phase * 1.21)
            let r = base * (0.62 + 0.12 * n1 + 0.07 * n2 + 0.04 * n3)
            points.append(CGPoint(x: cx + cos(t) * r, y: cy + sin(t) * r))
        }
        return Self.smoothClosed(points)
    }

    private static func smoothClosed(_ pts: [CGPoint]) -> Path {
        let n = pts.count
        guard n >= 3 else { return Path() }
        func pt(_ i: Int) -> CGPoint { pts[(i % n + n) % n] }

        var path = Path()
        let tension: CGFloat = 1.0 / 6.0
        path.move(to: pt(0))
        for i in 0..<n {
            let p0 = pt(i - 1)
            let p1 = pt(i)
            let p2 = pt(i + 1)
            let p3 = pt(i + 2)
            let c1 = CGPoint(
                x: p1.x + (p2.x - p0.x) * tension,
                y: p1.y + (p2.y - p0.y) * tension
            )
            let c2 = CGPoint(
                x: p2.x - (p3.x - p1.x) * tension,
                y: p2.y - (p3.y - p1.y) * tension
            )
            path.addCurve(to: p2, control1: c1, control2: c2)
        }
        path.closeSubpath()
        return path
    }
}


================================================================================
FILE: Sources/ChimePlayer.swift
================================================================================

import AppKit
import AVFoundation
import Foundation

@MainActor
final class ChimePlayer {
    static let shared = ChimePlayer()

    private var player: AVAudioPlayer?

    func play(volume: Double) {
        guard let url = Bundle.main.url(forResource: "chime", withExtension: "wav") else {
            NSSound.beep()
            return
        }
        do {
            let p = try AVAudioPlayer(contentsOf: url)
            p.volume = Float(max(0, min(1, volume)))
            p.prepareToPlay()
            p.play()
            player = p
        } catch {
            NSSound.beep()
        }
    }
}


================================================================================
FILE: Resources/Info.plist
================================================================================

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDevelopmentRegion</key>
	<string>en</string>
	<key>CFBundleExecutable</key>
	<string>Breathe</string>
	<key>CFBundleIconFile</key>
	<string>AppIcon</string>
	<key>CFBundleIdentifier</key>
	<string>com.local.breathe</string>
	<key>CFBundleInfoDictionaryVersion</key>
	<string>6.0</string>
	<key>CFBundleName</key>
	<string>Breathe</string>
	<key>CFBundleDisplayName</key>
	<string>Breathe</string>
	<key>CFBundlePackageType</key>
	<string>APPL</string>
	<key>CFBundleShortVersionString</key>
	<string>1.0</string>
	<key>CFBundleVersion</key>
	<string>1</string>
	<key>LSMinimumSystemVersion</key>
	<string>14.0</string>
	<key>LSUIElement</key>
	<true/>
	<key>NSHighResolutionCapable</key>
	<true/>
	<key>NSPrincipalClass</key>
	<string>NSApplication</string>
</dict>
</plist>


================================================================================
FILE: build.sh
================================================================================

#!/bin/zsh
set -euo pipefail

ROOT="$(cd "$(dirname "$0")" && pwd)"
APP="$ROOT/dist/Breathe.app"
BIN="$APP/Contents/MacOS/Breathe"
SDK="$(xcrun --show-sdk-path)"
TARGET="arm64-apple-macosx15.0"

mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"

python3 "$ROOT/scripts/generate_chime.py"

ICON_SRC="$ROOT/Resources/AppIcon.png"
if [[ -f "$ICON_SRC" ]]; then
  ICONSET="$ROOT/dist/AppIcon.iconset"
  rm -rf "$ICONSET"
  mkdir -p "$ICONSET"
  for sz in 16 32 64 128 256 512; do
    sips -z $sz $sz "$ICON_SRC" --out "$ICONSET/icon_${sz}x${sz}.png" >/dev/null
    dub=$((sz * 2))
    sips -z $dub $dub "$ICON_SRC" --out "$ICONSET/icon_${sz}x${sz}@2x.png" >/dev/null
  done
  if command -v iconutil >/dev/null 2>&1; then
    iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" || true
  fi
  cp "$ICON_SRC" "$APP/Contents/Resources/AppIcon.png"
fi

cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist"
cp "$ROOT/Resources/chime.wav" "$APP/Contents/Resources/chime.wav"

echo "Compiling Breathe…"
swiftc -parse-as-library -O \
  -target "$TARGET" \
  -sdk "$SDK" \
  -framework SwiftUI \
  -framework AppKit \
  -framework AVFoundation \
  -o "$BIN" \
  "$ROOT"/Sources/*.swift

chmod +x "$BIN"
codesign --force --sign - --deep "$APP" 2>/dev/null || true

mkdir -p "$HOME/Applications"
rm -rf "$HOME/Applications/Breathe.app"
cp -R "$APP" "$HOME/Applications/Breathe.app"

echo "Built $APP"
echo "Installed to $HOME/Applications/Breathe.app"
echo "Open with:  open ~/Applications/Breathe.app"


================================================================================
FILE: scripts/generate_chime.py
================================================================================

#!/usr/bin/env python3
"""Generate a quiet singing-bowl chime for the Breathe widget."""

import math
import os
import struct
import wave

SR = 44100
DUR = 3.2


def bowl(t: float, f0: float, amp: float) -> float:
    # Inharmonic partials, closer to a real bowl than integer harmonics.
    partials = (
        (1.00, 1.00, 1.7),
        (2.01, 0.40, 2.3),
        (2.76, 0.26, 2.9),
        (4.07, 0.13, 4.1),
        (6.54, 0.06, 5.4),
    )
    s = 0.0
    for ratio, a, decay in partials:
        s += a * math.exp(-t * decay) * math.sin(2 * math.pi * f0 * ratio * t)
    attack = min(1.0, t / 0.014)
    return amp * attack * s


def main() -> None:
    n = int(SR * DUR)
    samples = []
    for i in range(n):
        t = i / SR
        s = bowl(t, 392.00, 0.50)  # G4
        if t >= 0.16:
            s += bowl(t - 0.16, 587.33, 0.22)  # D5, a soft second strike
        if t > DUR - 0.55:
            s *= (DUR - t) / 0.55
        s *= 0.48
        s = max(-0.95, min(0.95, s))
        samples.append(s)

    out = os.path.normpath(
        os.path.join(os.path.dirname(__file__), "..", "Resources", "chime.wav")
    )
    os.makedirs(os.path.dirname(out), exist_ok=True)
    with wave.open(out, "w") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(SR)
        packed = b"".join(
            struct.pack("<h", int(max(-1.0, min(1.0, v)) * 32767)) for v in samples
        )
        wf.writeframes(packed)
    print(f"Wrote {out}")


if __name__ == "__main__":
    main()

