krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

9384a668bf36f23f71e25d05b490d1040d308845

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-22T15:14:30Z

feat(v2.8.0): add workflows and repeatable inspection automation

* introduce DomainWorkflow model and persistence
* add WorkflowsView and execution flow
* support batch inspection via workflows
* add workflow-based export (TXT/CSV/JSON)
* enable bulk add from history/watchlist
* add quick actions for common operations
* integrate workflows with tracking and history
 DomainDig.xcodeproj/project.pbxproj |   8 +-
 DomainDig/AppVersion.swift          |   2 +-
 DomainDig/ContentView.swift         | 112 +++++++
 DomainDig/DomainViewModel.swift     | 207 +++++++++++-
 DomainDig/HistoryView.swift         |  20 +-
 DomainDig/Models.swift              |  45 +++
 DomainDig/WatchlistView.swift       |  12 +
 DomainDig/WorkflowsView.swift       | 634 ++++++++++++++++++++++++++++++++++++
 8 files changed, 1029 insertions(+), 11 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 3e7f5d6..345e575 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -354,7 +354,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 20;
+				CURRENT_PROJECT_VERSION = 21;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +371,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.7.0;
+				MARKETING_VERSION = 2.8.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +390,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 20;
+				CURRENT_PROJECT_VERSION = 21;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +407,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.7.0;
+				MARKETING_VERSION = 2.8.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index a1516b8..fb7481e 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -2,6 +2,6 @@ import Foundation
 
 enum AppVersion {
     static var current: String {
-        "2.7.0"
+        "2.8.0"
     }
 }
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index ed2da49..b023547 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -18,6 +18,10 @@ enum ResultSection: String, Hashable {
     case subdomains
 }
 
+private struct WorkflowNavigationTarget: Hashable {
+    let workflowID: UUID
+}
+
 struct ContentView: View {
     @Environment(\.appDensity) private var appDensity
     @State private var viewModel = DomainViewModel()
@@ -30,6 +34,8 @@ struct ContentView: View {
     @State private var showTrackLimitAlert = false
     @State private var inputMode: LookupInputMode = .single
     @State private var collapsedSections: Set<ResultSection> = [.network]
+    @State private var showingCurrentDomainWorkflowSheet = false
+    @State private var showingBatchWorkflowSheet = false
 
     var body: some View {
         NavigationStack(path: $navigationPath) {
@@ -68,6 +74,7 @@ struct ContentView: View {
                             confidence: viewModel.currentSnapshot.availabilityConfidence,
                             snapshotNote: viewModel.currentSnapshot.note,
                             trackedDomain: viewModel.currentTrackedDomain,
+                            workflows: viewModel.currentDomainWorkflows,
                             trackingLimitMessage: viewModel.trackingLimitMessage,
                             onTrack: {
                                 if !viewModel.trackCurrentDomain() {
@@ -82,6 +89,15 @@ struct ContentView: View {
                                 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
                                 trackingNoteDraft = trackedDomain.note ?? ""
                                 editingTrackedDomain = trackedDomain
+                            },
+                            onAddToWorkflow: {
+                                showingCurrentDomainWorkflowSheet = true
+                            },
+                            onOpenWorkflow: { workflow in
+                                navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
+                            },
+                            onRunWorkflow: { workflow in
+                                viewModel.rerunCurrentDomain(in: workflow)
                             }
                         )
                             .padding(.top, appDensity.metrics.sectionSpacing)
@@ -224,6 +240,12 @@ struct ContentView: View {
                             Label("Saved Domains", systemImage: "bookmark")
                         }
 
+                        NavigationLink {
+                            WorkflowsView(viewModel: viewModel)
+                        } label: {
+                            Label("Workflows", systemImage: "square.stack.3d.down.right")
+                        }
+
                         NavigationLink {
                             SettingsView(viewModel: viewModel)
                         } label: {
@@ -235,6 +257,9 @@ struct ContentView: View {
                     }
                 }
             }
+            .navigationDestination(for: WorkflowNavigationTarget.self) { target in
+                WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
+            }
         }
         .onAppear {
             domainFieldFocused = true
@@ -277,6 +302,20 @@ struct ContentView: View {
                 }
             }
         }
+        .sheet(isPresented: $showingCurrentDomainWorkflowSheet) {
+            WorkflowBulkAddSheet(
+                viewModel: viewModel,
+                title: "Add Domain to Workflow",
+                availableDomains: [viewModel.searchedDomain]
+            )
+        }
+        .sheet(isPresented: $showingBatchWorkflowSheet) {
+            WorkflowBulkAddSheet(
+                viewModel: viewModel,
+                title: "Add Batch Domains",
+                availableDomains: viewModel.batchResults.map(\.domain)
+            )
+        }
     }
 
     private var inputSection: some View {
@@ -353,6 +392,30 @@ struct ContentView: View {
         HStack {
             Spacer()
             if viewModel.resultsLoaded {
+                Menu {
+                    if !viewModel.isCurrentDomainTracked {
+                        Button("Track this domain") {
+                            if !viewModel.trackCurrentDomain() {
+                                showTrackLimitAlert = true
+                            }
+                        }
+                    }
+                    Button("Add to workflow") {
+                        showingCurrentDomainWorkflowSheet = true
+                    }
+                    Button("Copy report JSON") {
+                        guard let json = viewModel.exportJSONString() else { return }
+                        AppClipboard.copy(json)
+                        AppHaptics.copy()
+                    }
+                    Button("Export report") {
+                        shareSingleResults(format: .json)
+                    }
+                } label: {
+                    Image(systemName: "bolt.circle")
+                        .font(appDensity.font(.body, design: .default))
+                        .foregroundStyle(.secondary)
+                }
                 Button {
                     viewModel.toggleSavedDomain()
                 } label: {
@@ -392,6 +455,10 @@ struct ContentView: View {
                 }
                 if !viewModel.currentBatchResultEntries.isEmpty {
                     Menu {
+                        Button("Add to Workflow") {
+                            showingBatchWorkflowSheet = true
+                        }
+                        Divider()
                         Button("Export Batch TXT") {
                             shareBatchResults(format: .text)
                         }
@@ -982,10 +1049,14 @@ struct DomainSectionView: View {
     let confidence: ConfidenceLevel?
     let snapshotNote: String?
     let trackedDomain: TrackedDomain?
+    let workflows: [DomainWorkflow]
     let trackingLimitMessage: String?
     let onTrack: () -> Void
     let onTogglePinned: () -> Void
     let onEditNote: (() -> Void)?
+    let onAddToWorkflow: (() -> Void)?
+    let onOpenWorkflow: ((DomainWorkflow) -> Void)?
+    let onRunWorkflow: ((DomainWorkflow) -> Void)?
 
     var body: some View {
         CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
@@ -1032,6 +1103,47 @@ struct DomainSectionView: View {
                     MessageRowView(text: trackingLimitMessage, isError: false)
                         .padding(.top, 4)
                 }
+                if let onAddToWorkflow {
+                    Button {
+                        onAddToWorkflow()
+                    } label: {
+                        Label("Add to workflow", systemImage: "plus.rectangle.on.folder")
+                            .font(appDensity.font(.caption))
+                    }
+                    .buttonStyle(.bordered)
+                    .padding(.top, 4)
+                }
+                if !workflows.isEmpty {
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Part of workflow")
+                            .font(appDensity.font(.caption))
+                            .foregroundStyle(.secondary)
+
+                        ForEach(workflows) { workflow in
+                            HStack {
+                                Text(workflow.name)
+                                    .font(appDensity.font(.caption))
+                                    .foregroundStyle(.primary)
+                                Spacer()
+                                if let onOpenWorkflow {
+                                    Button("Open") {
+                                        onOpenWorkflow(workflow)
+                                    }
+                                    .buttonStyle(.bordered)
+                                    .font(appDensity.font(.caption2))
+                                }
+                                if let onRunWorkflow {
+                                    Button("Run") {
+                                        onRunWorkflow(workflow)
+                                    }
+                                    .buttonStyle(.bordered)
+                                    .font(appDensity.font(.caption2))
+                                }
+                            }
+                        }
+                    }
+                    .padding(.top, 4)
+                }
                 if availabilityLoading {
                     ProgressView("Checking availability…")
                         .appLoadingStyle()
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 3373447..f9a6523 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -175,6 +175,7 @@ final class DomainViewModel {
     private(set) var batchTotalCount = 0
     private(set) var batchLookupRunning = false
     var latestBatchSweepSummary: BatchSweepSummary?
+    var latestWorkflowRunSummary: WorkflowRunSummary?
     private(set) var notificationsAuthorized = false
 
     private var lookupTask: Task<Void, Never>?
@@ -184,6 +185,8 @@ final class DomainViewModel {
     private var lookupStartedAt: Date?
     private var activeBatchDomains: [String] = []
     private var lastBatchStartedAt: Date?
+    private var activeWorkflowRunID: UUID?
+    private var activeWorkflowRunName: String?
     private let reportBuilder = DomainReportBuilder()
     private let inspectionService = DomainInspectionService()
     private(set) var currentResultSource: LookupResultSource = .live
@@ -206,6 +209,8 @@ final class DomainViewModel {
     private static let historyKey = "lookupHistory"
     private static let maxHistory = 250
     var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries()
+    private static let workflowsKey = "domainWorkflows"
+    var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows()
     var historySearchText = ""
     var historyDateFilter: HistoryDateFilter = .all
     var historyChangeFilter: ChangeFilterOption = .all
@@ -327,6 +332,9 @@ final class DomainViewModel {
 
     var batchProgressLabel: String {
         guard batchTotalCount > 0 else { return "No active batch" }
+        if !batchLookupRunning, batchCompletedCount >= batchTotalCount {
+            return "\(batchCompletedCount)/\(batchTotalCount) • Complete"
+        }
         let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing"
         return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)"
     }
@@ -343,6 +351,11 @@ final class DomainViewModel {
         return trackedDomain(for: searchedDomain)
     }
 
+    var currentDomainWorkflows: [DomainWorkflow] {
+        guard !searchedDomain.isEmpty else { return [] }
+        return workflowsContaining(domain: searchedDomain)
+    }
+
     var isCurrentDomainTracked: Bool {
         currentTrackedDomain != nil
     }
@@ -655,6 +668,12 @@ final class DomainViewModel {
         rerunNavigationToken = UUID()
     }
 
+    func openInspection(for domain: String) {
+        self.domain = normalizedDomain(domain)
+        run()
+        rerunNavigationToken = UUID()
+    }
+
     func reset() {
         lookupTask?.cancel()
         customPortScanTask?.cancel()
@@ -717,6 +736,101 @@ final class DomainViewModel {
         }
     }
 
+    func workflow(withID id: UUID) -> DomainWorkflow? {
+        workflows.first(where: { $0.id == id })
+    }
+
+    func workflowsContaining(domain: String) -> [DomainWorkflow] {
+        let normalized = normalizedDomain(domain)
+        guard !normalized.isEmpty else { return [] }
+        return workflows.filter { workflow in
+            workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame })
+        }
+    }
+
+    @discardableResult
+    func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
+        let normalizedDomains = normalizedDomains(domains)
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
+
+        let workflow = DomainWorkflow(
+            name: trimmedName,
+            domains: normalizedDomains,
+            createdAt: Date(),
+            updatedAt: Date(),
+            notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+        )
+        workflows.insert(workflow, at: 0)
+        persistWorkflows()
+        return workflow
+    }
+
+    func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
+        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+        let normalizedDomains = normalizedDomains(domains)
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return }
+
+        workflows[index].name = trimmedName
+        workflows[index].domains = normalizedDomains
+        workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+        workflows[index].updatedAt = Date()
+        persistWorkflows()
+    }
+
+    func deleteWorkflow(_ workflow: DomainWorkflow) {
+        workflows.removeAll { $0.id == workflow.id }
+        if latestWorkflowRunSummary?.workflowID == workflow.id {
+            latestWorkflowRunSummary = nil
+        }
+        if activeWorkflowRunID == workflow.id {
+            activeWorkflowRunID = nil
+            activeWorkflowRunName = nil
+        }
+        persistWorkflows()
+    }
+
+    func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
+        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+        let mergedDomains = normalizedDomains(workflows[index].domains + domains)
+        guard mergedDomains != workflows[index].domains else { return }
+        workflows[index].domains = mergedDomains
+        workflows[index].updatedAt = Date()
+        persistWorkflows()
+    }
+
+    func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
+        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+        workflows[index].domains.remove(atOffsets: offsets)
+        workflows[index].updatedAt = Date()
+        persistWorkflows()
+    }
+
+    func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
+        guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+        workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
+        workflows[index].updatedAt = Date()
+        persistWorkflows()
+    }
+
+    func runWorkflow(_ workflow: DomainWorkflow) {
+        guard !workflow.domains.isEmpty else { return }
+        startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
+    }
+
+    func rerunCurrentDomain(in workflow: DomainWorkflow) {
+        guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else {
+            return
+        }
+        runWorkflow(workflow)
+    }
+
+    func refreshWorkflowList() async {
+        workflows = Self.loadWorkflows()
+        await Task.yield()
+    }
+
     func runCustomPortScan(ports: [UInt16]) async {
         guard !searchedDomain.isEmpty else {
             customPortScanError = "Run a domain lookup first"
@@ -760,6 +874,11 @@ final class DomainViewModel {
         return try? DomainReportExporter.data(for: currentReport, format: .json)
     }
 
+    func exportJSONString() -> String? {
+        guard let data = exportJSONData() else { return nil }
+        return String(data: data, encoding: .utf8)
+    }
+
     func exportBatchText() -> String {
         DomainReportExporter.batchText(
             for: currentBatchReports(),
@@ -798,6 +917,25 @@ final class DomainViewModel {
         )
     }
 
+    func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
+        DomainReportExporter.batchText(
+            for: workflowReports(from: summary, changedOnly: changedOnly),
+            title: "\(summary.workflowName) Workflow Export"
+        )
+    }
+
+    func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
+        DomainReportExporter.csv(for: workflowReports(from: summary, changedOnly: changedOnly))
+    }
+
+    func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? {
+        try? DomainReportExporter.data(
+            for: workflowReports(from: summary, changedOnly: changedOnly),
+            format: .json,
+            title: "\(summary.workflowName) Workflow Export"
+        )
+    }
+
     private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
         let previous = previousSnapshot(
             for: domain,
@@ -1533,7 +1671,7 @@ final class DomainViewModel {
         return lookupID
     }
 
-    private func startBatchLookup(domains: [String], source: BatchLookupSource) {
+    private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
         guard !domains.isEmpty else { return }
         guard !batchLookupRunning else { return }
 
@@ -1545,6 +1683,8 @@ final class DomainViewModel {
         lastBatchStartedAt = now
         clearBatchState()
         batchLookupSource = source
+        activeWorkflowRunID = workflow?.id
+        activeWorkflowRunName = workflow?.name
         batchTotalCount = domains.count
         batchLookupRunning = true
         batchResults = domains.map {
@@ -1578,7 +1718,10 @@ final class DomainViewModel {
         batchTotalCount = 0
         batchLookupRunning = false
         latestBatchSweepSummary = nil
+        latestWorkflowRunSummary = nil
         activeBatchDomains = []
+        activeWorkflowRunID = nil
+        activeWorkflowRunName = nil
         batchTask = nil
     }
 
@@ -1691,12 +1834,16 @@ final class DomainViewModel {
         refreshingTrackedDomainID = nil
         batchTask = nil
 
+        let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count
+        let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count
+        let warningCount = batchResults.filter { $0.certificateWarningLevel != .none }.count
+
         let summary = BatchSweepSummary(
             source: source,
             totalDomains: batchResults.count,
-            changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count,
-            unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count,
-            warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
+            changedDomains: changedCount,
+            unchangedDomains: unchangedCount,
+            warningDomains: warningCount,
             results: batchResults.sorted { lhs, rhs in
                 if lhs.status != rhs.status {
                     return lhs.status.rawValue < rhs.status.rawValue
@@ -1707,7 +1854,20 @@ final class DomainViewModel {
         )
         latestBatchSweepSummary = summary
 
-        if notificationsAuthorized {
+        if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
+            latestWorkflowRunSummary = WorkflowRunSummary(
+                workflowID: activeWorkflowRunID,
+                workflowName: activeWorkflowRunName,
+                totalDomains: batchResults.count,
+                changedDomains: changedCount,
+                unchangedDomains: unchangedCount,
+                warningDomains: warningCount,
+                results: summary.results,
+                generatedAt: summary.generatedAt
+            )
+        }
+
+        if notificationsAuthorized, source != .workflow {
             Task {
                 await LocalNotificationService.shared.notifySweepComplete(summary: summary)
             }
@@ -1895,6 +2055,14 @@ final class DomainViewModel {
         currentBatchResultEntries.map(report(for:))
     }
 
+    private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
+        let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
+        return filteredResults.compactMap { result in
+            guard let entry = historyEntry(for: result) else { return nil }
+            return report(for: entry)
+        }
+    }
+
     private func reports(for domains: [TrackedDomain]) -> [DomainReport] {
         let latestEntries = latestSnapshots(for: domains)
 
@@ -2024,6 +2192,27 @@ final class DomainViewModel {
         return []
     }
 
+    private func persistWorkflows() {
+        if let data = try? JSONEncoder().encode(workflows) {
+            UserDefaults.standard.set(data, forKey: Self.workflowsKey)
+        }
+    }
+
+    private static func loadWorkflows() -> [DomainWorkflow] {
+        let defaults = UserDefaults.standard
+        guard let data = defaults.data(forKey: workflowsKey),
+              let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
+            return []
+        }
+
+        return workflows.sorted { lhs, rhs in
+            if lhs.updatedAt != rhs.updatedAt {
+                return lhs.updatedAt > rhs.updatedAt
+            }
+            return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+        }
+    }
+
     private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
         var seen = Set<String>()
         return domains.filter { domain in
@@ -2032,6 +2221,14 @@ final class DomainViewModel {
         }
     }
 
+    private func normalizedDomains(_ domains: [String]) -> [String] {
+        var seen = Set<String>()
+        return domains
+            .map(normalizedDomain)
+            .filter { !$0.isEmpty }
+            .filter { seen.insert($0).inserted }
+    }
+
     private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool {
         switch historySortOption {
         case .newest:
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 9505c12..3bda59d 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -5,6 +5,7 @@ struct HistoryView: View {
     @Bindable var viewModel: DomainViewModel
     @Environment(\.dismiss) private var dismiss
     @State private var showClearAllConfirmation = false
+    @State private var showWorkflowAddSheet = false
 
     private var groupedHistory: [HistoryGroup] {
         HistoryGroup.groups(for: viewModel.filteredHistory)
@@ -105,6 +106,12 @@ struct HistoryView: View {
                         Button("Clear All", role: .destructive) {
                             showClearAllConfirmation = true
                         }
+
+                        if !viewModel.filteredHistory.isEmpty {
+                            Button("Add to Workflow") {
+                                showWorkflowAddSheet = true
+                            }
+                        }
                     } label: {
                         Image(systemName: "line.3.horizontal.decrease.circle")
                     }
@@ -121,6 +128,13 @@ struct HistoryView: View {
         } message: {
             Text("This will delete all saved history entries. This cannot be undone.")
         }
+        .sheet(isPresented: $showWorkflowAddSheet) {
+            WorkflowBulkAddSheet(
+                viewModel: viewModel,
+                title: "Add History Domains",
+                availableDomains: Array(Set(viewModel.filteredHistory.map(\.domain))).sorted()
+            )
+        }
         .onChange(of: viewModel.rerunNavigationToken) { _, _ in
             dismiss()
         }
@@ -170,6 +184,7 @@ struct HistoryDetailView: View {
                     confidence: snapshot.availabilityConfidence,
                     snapshotNote: entry.note,
                     trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
+                    workflows: viewModel.workflowsContaining(domain: entry.domain),
                     trackingLimitMessage: nil,
                     onTrack: {
                         _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
@@ -178,7 +193,10 @@ struct HistoryDetailView: View {
                         guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return }
                         viewModel.togglePinned(for: trackedDomain)
                     },
-                    onEditNote: nil
+                    onEditNote: nil,
+                    onAddToWorkflow: nil,
+                    onOpenWorkflow: nil,
+                    onRunWorkflow: nil
                 )
                     .padding(.top, appDensity.metrics.sectionSpacing)
                 OwnershipSectionView(
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 60e5852..0c2b4c2 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -223,6 +223,7 @@ struct DomainChangeSummary: Codable, Equatable {
 enum BatchLookupSource: String, Codable {
     case manual
     case watchlistRefresh
+    case workflow
 }
 
 enum BatchLookupStatus: String, Codable {
@@ -276,6 +277,13 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
         self.status = status
         self.errorMessage = errorMessage
     }
+
+    var hasMeaningfulChange: Bool {
+        quickStatus == "Changed"
+            || quickStatus == "High"
+            || certificateWarningLevel != .none
+            || status == .failed
+    }
 }
 
 struct BatchSweepSummary: Identifiable, Equatable {
@@ -289,6 +297,43 @@ struct BatchSweepSummary: Identifiable, Equatable {
     let generatedAt: Date
 }
 
+struct DomainWorkflow: Codable, Identifiable, Equatable {
+    let id: UUID
+    var name: String
+    var domains: [String]
+    var createdAt: Date
+    var updatedAt: Date
+    var notes: String?
+
+    init(
+        id: UUID = UUID(),
+        name: String,
+        domains: [String],
+        createdAt: Date = Date(),
+        updatedAt: Date = Date(),
+        notes: String? = nil
+    ) {
+        self.id = id
+        self.name = name
+        self.domains = domains
+        self.createdAt = createdAt
+        self.updatedAt = updatedAt
+        self.notes = notes
+    }
+}
+
+struct WorkflowRunSummary: Identifiable, Equatable {
+    let id = UUID()
+    let workflowID: UUID
+    let workflowName: String
+    let totalDomains: Int
+    let changedDomains: Int
+    let unchangedDomains: Int
+    let warningDomains: Int
+    let results: [BatchLookupResult]
+    let generatedAt: Date
+}
+
 enum DataCapability: String, Codable {
     case ownershipHistory
     case dnsHistory
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index d58061f..a434705 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -4,6 +4,7 @@ struct WatchlistView: View {
     @Environment(\.appDensity) private var appDensity
     @Bindable var viewModel: DomainViewModel
     @Environment(\.dismiss) private var dismiss
+    @State private var showWorkflowAddSheet = false
 
     private var pinnedDomains: [TrackedDomain] {
         viewModel.filteredTrackedDomains.filter(\.isPinned)
@@ -99,6 +100,10 @@ struct WatchlistView: View {
                         }
                         .disabled(viewModel.batchLookupRunning)
 
+                        Button("Add to Workflow") {
+                            showWorkflowAddSheet = true
+                        }
+
                         Button("Export TXT") {
                             shareTrackedDomains(format: .text)
                         }
@@ -124,6 +129,13 @@ struct WatchlistView: View {
         .sheet(item: batchSummaryBinding) { summary in
             BatchSweepSummaryView(viewModel: viewModel, summary: summary)
         }
+        .sheet(isPresented: $showWorkflowAddSheet) {
+            WorkflowBulkAddSheet(
+                viewModel: viewModel,
+                title: "Add Watchlist Domains",
+                availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
+            )
+        }
         .preferredColorScheme(.dark)
     }
 
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
new file mode 100644
index 0000000..ba88487
--- /dev/null
+++ b/DomainDig/WorkflowsView.swift
@@ -0,0 +1,634 @@
+import SwiftUI
+
+private enum WorkflowDestinationMode: String, CaseIterable, Identifiable {
+    case existing
+    case new
+
+    var id: String { rawValue }
+}
+
+struct WorkflowsView: View {
+    @Environment(\.appDensity) private var appDensity
+    @Bindable var viewModel: DomainViewModel
+
+    @State private var showingCreateWorkflow = false
+
+    var body: some View {
+        List {
+            if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
+                Section("Workflow Run") {
+                    VStack(alignment: .leading, spacing: 8) {
+                        ProgressView(
+                            value: Double(viewModel.batchCompletedCount),
+                            total: Double(max(viewModel.batchTotalCount, 1))
+                        )
+                        .tint(.cyan)
+
+                        HStack {
+                            Text(viewModel.batchProgressLabel)
+                                .font(appDensity.font(.caption))
+                                .foregroundStyle(.secondary)
+                            Spacer()
+                            if viewModel.batchLookupRunning {
+                                Button("Cancel") {
+                                    viewModel.cancelBatchLookup()
+                                }
+                                .buttonStyle(.bordered)
+                                .font(appDensity.font(.caption2))
+                            }
+                        }
+                    }
+                    .padding(.vertical, 4)
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            }
+
+            if viewModel.workflows.isEmpty {
+                Section {
+                    EmptyStateCardView(
+                        title: "No Workflows Yet",
+                        message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
+                        suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
+                        systemImage: "square.stack.3d.down.right"
+                    )
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            } else {
+                ForEach(viewModel.workflows) { workflow in
+                    NavigationLink {
+                        WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id)
+                    } label: {
+                        WorkflowRowView(workflow: workflow)
+                    }
+                    .listRowBackground(Color(.systemGray6).opacity(0.5))
+                }
+                .onDelete { offsets in
+                    let workflows = offsets.map { viewModel.workflows[$0] }
+                    workflows.forEach(viewModel.deleteWorkflow)
+                }
+            }
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.black)
+        .refreshable {
+            await viewModel.refreshWorkflowList()
+        }
+        .navigationTitle("Workflows")
+        .toolbar {
+            ToolbarItemGroup(placement: .topBarTrailing) {
+                Button {
+                    showingCreateWorkflow = true
+                } label: {
+                    Image(systemName: "plus.circle")
+                }
+
+                if !viewModel.workflows.isEmpty {
+                    EditButton()
+                }
+            }
+        }
+        .sheet(isPresented: $showingCreateWorkflow) {
+            WorkflowComposerView(viewModel: viewModel)
+        }
+        .sheet(item: workflowSummaryBinding) { summary in
+            WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
+        }
+        .preferredColorScheme(.dark)
+    }
+
+    private var workflowSummaryBinding: Binding<WorkflowRunSummary?> {
+        Binding(
+            get: { viewModel.latestWorkflowRunSummary },
+            set: { viewModel.latestWorkflowRunSummary = $0 }
+        )
+    }
+}
+
+private struct WorkflowRowView: View {
+    @Environment(\.appDensity) private var appDensity
+
+    let workflow: DomainWorkflow
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
+            HStack(alignment: .firstTextBaseline, spacing: 8) {
+                Text(workflow.name)
+                    .font(appDensity.font(.callout, design: .default, weight: .semibold))
+                    .foregroundStyle(.primary)
+                    .lineLimit(2)
+                Spacer(minLength: 8)
+                Text("\(workflow.domains.count) domains")
+                    .font(appDensity.font(.caption2))
+                    .foregroundStyle(.secondary)
+            }
+
+            Text("Updated \(workflow.updatedAt.formatted(date: .abbreviated, time: .shortened))")
+                .font(appDensity.font(.caption2))
+                .foregroundStyle(.secondary)
+
+            if let notes = workflow.notes, !notes.isEmpty {
+                Text(notes)
+                    .font(appDensity.font(.caption))
+                    .foregroundStyle(.secondary)
+                    .lineLimit(2)
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(.vertical, 4)
+    }
+}
+
+struct WorkflowDetailView: View {
+    @Environment(\.appDensity) private var appDensity
+    @Bindable var viewModel: DomainViewModel
+    let workflowID: UUID
+
+    @State private var showingEditor = false
+    @State private var draftDomain = ""
+    @State private var includeAllExports = false
+
+    private var workflow: DomainWorkflow? {
+        viewModel.workflow(withID: workflowID)
+    }
+
+    private var latestSummary: WorkflowRunSummary? {
+        guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil }
+        return viewModel.latestWorkflowRunSummary
+    }
+
+    var body: some View {
+        Group {
+            if let workflow {
+                List {
+                    Section("Overview") {
+                        statRow(label: "Domains", value: "\(workflow.domains.count)")
+                        statRow(label: "Created", value: workflow.createdAt.formatted(date: .abbreviated, time: .shortened))
+                        statRow(label: "Updated", value: workflow.updatedAt.formatted(date: .abbreviated, time: .shortened))
+                        if let notes = workflow.notes, !notes.isEmpty {
+                            Text(notes)
+                                .font(appDensity.font(.caption))
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+
+                    Section("Actions") {
+                        Button {
+                            viewModel.runWorkflow(workflow)
+                        } label: {
+                            Label("Run Workflow", systemImage: "play.fill")
+                        }
+                        .disabled(workflow.domains.isEmpty || viewModel.batchLookupRunning)
+
+                        if viewModel.batchLookupRunning, viewModel.batchLookupSource == .workflow {
+                            Button("Cancel Workflow Run", role: .destructive) {
+                                viewModel.cancelBatchLookup()
+                            }
+                        }
+
+                        Button {
+                            showingEditor = true
+                        } label: {
+                            Label("Edit Workflow", systemImage: "pencil")
+                        }
+
+                        if latestSummary != nil {
+                            Toggle("Export all domains", isOn: $includeAllExports)
+                            Menu {
+                                Button("Export TXT") {
+                                    shareWorkflowResults(format: .text)
+                                }
+                                Button("Export CSV") {
+                                    shareWorkflowResults(format: .csv)
+                                }
+                                Button("Export JSON") {
+                                    shareWorkflowResults(format: .json)
+                                }
+                            } label: {
+                                Label("Export Results", systemImage: "square.and.arrow.up")
+                            }
+                        }
+                    }
+
+                    if let latestSummary {
+                        Section("Latest Run") {
+                            statRow(label: "Processed", value: "\(latestSummary.totalDomains)")
+                            statRow(label: "Changed", value: "\(latestSummary.changedDomains)")
+                            statRow(label: "Warnings", value: "\(latestSummary.warningDomains)")
+                            statRow(label: "Unchanged", value: "\(latestSummary.unchangedDomains)")
+
+                            Button {
+                                viewModel.latestWorkflowRunSummary = latestSummary
+                            } label: {
+                                Label("View Run Summary", systemImage: "list.bullet.rectangle")
+                            }
+                        }
+                    }
+
+                    Section("Add Domain") {
+                        HStack {
+                            TextField("example.com", text: $draftDomain)
+                                .textInputAutocapitalization(.never)
+                                .autocorrectionDisabled()
+                            Button("Add") {
+                                guard !draftDomain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
+                                viewModel.addDomains([draftDomain], to: workflow)
+                                draftDomain = ""
+                            }
+                        }
+                    }
+
+                    Section("Domains") {
+                        if workflow.domains.isEmpty {
+                            Text("No domains in this workflow")
+                                .font(appDensity.font(.caption))
+                                .foregroundStyle(.secondary)
+                        } else {
+                            ForEach(workflow.domains, id: \.self) { domain in
+                                Button {
+                                    viewModel.openInspection(for: domain)
+                                } label: {
+                                    HStack {
+                                        Text(domain)
+                                            .font(appDensity.font(.callout))
+                                            .foregroundStyle(.primary)
+                                        Spacer()
+                                        Image(systemName: "arrow.up.right.circle")
+                                            .foregroundStyle(.secondary)
+                                    }
+                                }
+                                .buttonStyle(.plain)
+                            }
+                            .onDelete { offsets in
+                                viewModel.removeWorkflowDomains(at: offsets, from: workflow)
+                            }
+                            .onMove { offsets, destination in
+                                viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow)
+                            }
+                        }
+                    }
+                }
+                .scrollContentBackground(.hidden)
+                .background(Color.black)
+                .navigationTitle(workflow.name)
+                .toolbar {
+                    if !workflow.domains.isEmpty {
+                        EditButton()
+                    }
+                }
+                .sheet(isPresented: $showingEditor) {
+                    WorkflowComposerView(viewModel: viewModel, workflow: workflow)
+                }
+                .sheet(item: workflowSummaryBinding) { summary in
+                    WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
+                }
+            } else {
+                Text("Workflow not found")
+                    .font(appDensity.font(.callout))
+                    .foregroundStyle(.secondary)
+            }
+        }
+        .preferredColorScheme(.dark)
+    }
+
+    private func statRow(label: String, value: String) -> some View {
+        HStack {
+            Text(label)
+                .font(appDensity.font(.caption))
+                .foregroundStyle(.secondary)
+            Spacer()
+            Text(value)
+                .font(appDensity.font(.callout))
+                .foregroundStyle(.primary)
+        }
+    }
+
+    private var workflowSummaryBinding: Binding<WorkflowRunSummary?> {
+        Binding(
+            get: {
+                guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil }
+                return viewModel.latestWorkflowRunSummary
+            },
+            set: { _ in
+                viewModel.latestWorkflowRunSummary = nil
+            }
+        )
+    }
+
+    private func shareWorkflowResults(format: DomainExportFormat) {
+        guard let latestSummary else { return }
+
+        let changedOnly = !includeAllExports
+        let formatter = DateFormatter()
+        formatter.dateFormat = "yyyyMMdd_HHmmss"
+        let timestamp = formatter.string(from: Date())
+        let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)"
+        let data: Data
+
+        switch format {
+        case .text:
+            data = Data(viewModel.exportWorkflowText(summary: latestSummary, changedOnly: changedOnly).utf8)
+        case .csv:
+            data = Data(viewModel.exportWorkflowCSV(summary: latestSummary, changedOnly: changedOnly).utf8)
+        case .json:
+            data = viewModel.exportWorkflowJSONData(summary: latestSummary, changedOnly: changedOnly) ?? Data("[]".utf8)
+        }
+
+        ExportPresenter.share(filename: filename, data: data)
+    }
+}
+
+struct WorkflowComposerView: View {
+    @Environment(\.dismiss) private var dismiss
+    @Bindable var viewModel: DomainViewModel
+    let workflow: DomainWorkflow?
+
+    @State private var name = ""
+    @State private var domainsText = ""
+    @State private var notes = ""
+
+    init(viewModel: DomainViewModel, workflow: DomainWorkflow? = nil) {
+        self.viewModel = viewModel
+        self.workflow = workflow
+        _name = State(initialValue: workflow?.name ?? "")
+        _domainsText = State(initialValue: workflow?.domains.joined(separator: "\n") ?? "")
+        _notes = State(initialValue: workflow?.notes ?? "")
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section("Workflow") {
+                    TextField("Name", text: $name)
+                    TextField("Notes", text: $notes, axis: .vertical)
+                        .lineLimit(3...6)
+                }
+
+                Section("Domains") {
+                    TextField("example.com\napple.com", text: $domainsText, axis: .vertical)
+                        .textInputAutocapitalization(.never)
+                        .autocorrectionDisabled()
+                        .lineLimit(6...14)
+                }
+            }
+            .navigationTitle(workflow == nil ? "New Workflow" : "Edit Workflow")
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") {
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Save") {
+                        saveWorkflow()
+                    }
+                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || parsedDomains.isEmpty)
+                }
+            }
+        }
+        .preferredColorScheme(.dark)
+    }
+
+    private var parsedDomains: [String] {
+        domainsText
+            .components(separatedBy: CharacterSet(charactersIn: ",\n"))
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+    }
+
+    private func saveWorkflow() {
+        if let workflow {
+            viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes)
+        } else {
+            _ = viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes)
+        }
+        dismiss()
+    }
+}
+
+struct WorkflowRunSummaryView: View {
+    @Bindable var viewModel: DomainViewModel
+    let summary: WorkflowRunSummary
+
+    @State private var showAllResults = false
+
+    private var visibleResults: [BatchLookupResult] {
+        showAllResults ? summary.results : summary.results.filter(\.hasMeaningfulChange)
+    }
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section("Overview") {
+                    statRow(label: "Workflow", value: summary.workflowName)
+                    statRow(label: "Processed", value: "\(summary.totalDomains)")
+                    statRow(label: "Changed", value: "\(summary.changedDomains)")
+                    statRow(label: "Warnings", value: "\(summary.warningDomains)")
+                    statRow(label: "Unchanged", value: "\(summary.unchangedDomains)")
+                    statRow(label: "Finished", value: summary.generatedAt.formatted(date: .abbreviated, time: .shortened))
+                }
+
+                Section {
+                    Toggle("Show unchanged domains", isOn: $showAllResults)
+                }
+
+                Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") {
+                    if visibleResults.isEmpty {
+                        Text("No domains with meaningful changes or warnings")
+                            .font(.system(.caption, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                    } else {
+                        ForEach(visibleResults) { result in
+                            if let entry = viewModel.historyEntry(for: result) {
+                                NavigationLink {
+                                    HistoryDetailView(viewModel: viewModel, entry: entry)
+                                } label: {
+                                    BatchResultRowView(result: result)
+                                }
+                            } else {
+                                BatchResultRowView(result: result)
+                            }
+                        }
+                    }
+                }
+            }
+            .navigationTitle("Workflow Summary")
+            .toolbar {
+                ToolbarItemGroup(placement: .topBarTrailing) {
+                    Menu {
+                        Button("Export TXT") {
+                            share(format: .text)
+                        }
+                        Button("Export CSV") {
+                            share(format: .csv)
+                        }
+                        Button("Export JSON") {
+                            share(format: .json)
+                        }
+                    } label: {
+                        Image(systemName: "square.and.arrow.up")
+                    }
+
+                    if let workflow = viewModel.workflow(withID: summary.workflowID) {
+                        Button {
+                            viewModel.runWorkflow(workflow)
+                        } label: {
+                            Image(systemName: "arrow.clockwise")
+                        }
+                        .disabled(viewModel.batchLookupRunning)
+                    }
+                }
+            }
+        }
+        .preferredColorScheme(.dark)
+    }
+
+    private func statRow(label: String, value: String) -> some View {
+        HStack {
+            Text(label)
+                .font(.system(.caption, design: .monospaced))
+                .foregroundStyle(.secondary)
+            Spacer()
+            Text(value)
+                .font(.system(.callout, design: .monospaced))
+                .foregroundStyle(.primary)
+        }
+    }
+
+    private func share(format: DomainExportFormat) {
+        let changedOnly = !showAllResults
+        let formatter = DateFormatter()
+        formatter.dateFormat = "yyyyMMdd_HHmmss"
+        let timestamp = formatter.string(from: Date())
+        let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)"
+        let data: Data
+
+        switch format {
+        case .text:
+            data = Data(viewModel.exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8)
+        case .csv:
+            data = Data(viewModel.exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8)
+        case .json:
+            data = viewModel.exportWorkflowJSONData(summary: summary, changedOnly: changedOnly) ?? Data("[]".utf8)
+        }
+
+        ExportPresenter.share(filename: filename, data: data)
+    }
+}
+
+struct WorkflowBulkAddSheet: View {
+    @Environment(\.dismiss) private var dismiss
+    @Bindable var viewModel: DomainViewModel
+    let title: String
+    let availableDomains: [String]
+
+    @State private var mode: WorkflowDestinationMode = .existing
+    @State private var selectedDomains: Set<String>
+    @State private var selectedWorkflowID: UUID?
+    @State private var newWorkflowName = ""
+    @State private var newWorkflowNotes = ""
+
+    init(viewModel: DomainViewModel, title: String, availableDomains: [String]) {
+        self.viewModel = viewModel
+        self.title = title
+        self.availableDomains = Array(Set(availableDomains)).sorted()
+        _selectedDomains = State(initialValue: Set(availableDomains))
+        _selectedWorkflowID = State(initialValue: viewModel.workflows.first?.id)
+    }
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section("Selection") {
+                    ForEach(availableDomains, id: \.self) { domain in
+                        Button {
+                            toggle(domain)
+                        } label: {
+                            HStack {
+                                Text(domain)
+                                    .foregroundStyle(.primary)
+                                Spacer()
+                                Image(systemName: selectedDomains.contains(domain) ? "checkmark.circle.fill" : "circle")
+                                    .foregroundStyle(selectedDomains.contains(domain) ? .cyan : .secondary)
+                            }
+                        }
+                        .buttonStyle(.plain)
+                    }
+                }
+
+                Section("Destination") {
+                    Picker("Destination", selection: $mode) {
+                        Text("Existing").tag(WorkflowDestinationMode.existing)
+                        Text("New").tag(WorkflowDestinationMode.new)
+                    }
+                    .pickerStyle(.segmented)
+
+                    if mode == .existing, !viewModel.workflows.isEmpty {
+                        Picker("Workflow", selection: $selectedWorkflowID) {
+                            ForEach(viewModel.workflows) { workflow in
+                                Text(workflow.name).tag(Optional(workflow.id))
+                            }
+                        }
+                    } else {
+                        TextField("Workflow name", text: $newWorkflowName)
+                        TextField("Notes", text: $newWorkflowNotes, axis: .vertical)
+                            .lineLimit(2...5)
+                    }
+                }
+            }
+            .navigationTitle(title)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") {
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Save") {
+                        save()
+                    }
+                    .disabled(!canSave)
+                }
+            }
+            .onAppear {
+                if viewModel.workflows.isEmpty {
+                    mode = .new
+                }
+            }
+        }
+        .preferredColorScheme(.dark)
+    }
+
+    private var selectedDomainList: [String] {
+        availableDomains.filter { selectedDomains.contains($0) }
+    }
+
+    private var canSave: Bool {
+        guard !selectedDomainList.isEmpty else { return false }
+        switch mode {
+        case .existing:
+            return selectedWorkflowID != nil
+        case .new:
+            return !newWorkflowName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+        }
+    }
+
+    private func toggle(_ domain: String) {
+        if selectedDomains.contains(domain) {
+            selectedDomains.remove(domain)
+        } else {
+            selectedDomains.insert(domain)
+        }
+    }
+
+    private func save() {
+        switch mode {
+        case .existing:
+            guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return }
+            viewModel.addDomains(selectedDomainList, to: workflow)
+        case .new:
+            _ = viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes)
+        }
+
+        dismiss()
+    }
+}