krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.1: DomainDig/ContentView.swift · raw

   1import MapKit
   2import SwiftUI
   3import UniformTypeIdentifiers
   4
   5enum LookupInputMode: String, CaseIterable, Identifiable {
   6    case single
   7    case bulk
   8
   9    var id: String { rawValue }
  10}
  11
  12enum ResultSection: String, Hashable {
  13    case domain
  14    case intelligence
  15    case ownership
  16    case dns
  17    case web
  18    case email
  19    case network
  20    case subdomains
  21}
  22
  23private enum LookupInputField: Hashable {
  24    case singleDomain
  25    case bulkDomains
  26}
  27
  28private struct WorkflowNavigationTarget: Hashable {
  29    let workflowID: UUID
  30}
  31
  32struct ContentView: View {
  33    @Environment(\.appDensity) private var appDensity
  34    @Bindable var viewModel: DomainViewModel
  35    @State private var purchaseService = PurchaseService.shared
  36    @State private var navigationPath = NavigationPath()
  37    @FocusState private var focusedInputField: LookupInputField?
  38    @State private var customPortInput = ""
  39    @State private var customPortsExpanded = false
  40    @State private var trackingNoteDraft = ""
  41    @State private var editingTrackedDomain: TrackedDomain?
  42    @State private var inputMode: LookupInputMode = .single
  43    @State private var collapsedSections: Set<ResultSection> = [.network]
  44    @State private var showingCurrentDomainWorkflowSheet = false
  45    @State private var showingBatchWorkflowSheet = false
  46    @State private var showingTimeline = false
  47    @State private var showingAuditTimeline = false
  48    @State private var auditStartInFlight = false
  49
  50    var body: some View {
  51        let _ = purchaseService.currentTier
  52
  53        NavigationStack(path: $navigationPath) {
  54            ScrollView(.vertical) {
  55                VStack(spacing: 0) {
  56                    inputSection
  57                    if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
  58                        batchSection
  59                            .padding(.top, appDensity.metrics.cardSpacing)
  60                    }
  61                    if viewModel.hasRun {
  62                        actionButtons
  63                        if !viewModel.resultsLoaded {
  64                            LookupProgressOverviewView(steps: viewModel.activeLoadingLabels)
  65                                .padding(.top, appDensity.metrics.cardSpacing)
  66                        } else if let statusMessage = resultStatusMessage {
  67                            LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
  68                                .padding(.top, appDensity.metrics.cardSpacing)
  69                        }
  70                        if viewModel.resultsLoaded {
  71                            SummaryView(fields: viewModel.summaryFields)
  72                                .padding(.top, appDensity.metrics.cardSpacing)
  73                            if let report = viewModel.currentReport {
  74                                RiskSummaryCardView(report: report)
  75                                    .padding(.top, appDensity.metrics.cardSpacing)
  76                                InsightsSummaryCardView(insights: report.insights)
  77                                    .padding(.top, appDensity.metrics.cardSpacing)
  78                            }
  79                            if let changeSummary = viewModel.currentChangeSummary {
  80                                DomainChangeSummaryView(summary: changeSummary)
  81                                    .padding(.top, appDensity.metrics.cardSpacing)
  82                            }
  83                        }
  84                        if let report = viewModel.currentReport {
  85                            intelligenceSection(report: report)
  86                                .padding(.top, appDensity.metrics.sectionSpacing)
  87                        }
  88                        domainOverviewSection
  89                            .padding(.top, appDensity.metrics.sectionSpacing)
  90                        ownershipSection
  91                        .padding(.top, appDensity.metrics.sectionSpacing)
  92                        subdomainsSection
  93                        .padding(.top, appDensity.metrics.sectionSpacing)
  94                        if !viewModel.currentDiffSections.isEmpty {
  95                            DomainDiffView(
  96                                title: "Latest Changes",
  97                                sections: viewModel.currentDiffSections,
  98                                contextNote: viewModel.currentChangeSummary?.contextNote,
  99                                showsUnchanged: false,
 100                                highlightedSectionID: nil
 101                            )
 102                            .padding(.top, appDensity.metrics.sectionSpacing)
 103                        }
 104                        dnsSection
 105                        .padding(.top, appDensity.metrics.sectionSpacing)
 106                        WebSectionView(
 107                            isCollapsed: sectionCollapsedBinding(.web),
 108                            certificateRows: viewModel.webCertificateRows,
 109                            sslInfo: viewModel.sslInfo,
 110                            tlsSummary: viewModel.currentTLSSummary,
 111                            sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
 112                            sslError: viewModel.sslError,
 113                            tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl],
 114                            responseRows: viewModel.webResponseRows,
 115                            headers: viewModel.httpHeaders,
 116                            headersLoading: viewModel.httpHeadersLoading,
 117                            headersError: viewModel.httpHeadersError,
 118                            httpProvenance: viewModel.currentSnapshot.provenanceBySection[.httpHeaders],
 119                            redirects: viewModel.redirectRows,
 120                            redirectLoading: viewModel.redirectChainLoading,
 121                            redirectError: viewModel.redirectChainError,
 122                            redirectProvenance: viewModel.currentSnapshot.provenanceBySection[.redirectChain],
 123                            finalURL: viewModel.currentSnapshot.redirectChain.last?.url
 124                        )
 125                        .padding(.top, appDensity.metrics.sectionSpacing)
 126                        EmailSectionView(
 127                            isCollapsed: sectionCollapsedBinding(.email),
 128                            rows: viewModel.emailRows,
 129                            assessment: viewModel.currentEmailAssessment,
 130                            loading: viewModel.emailSecurityLoading,
 131                            provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity],
 132                            confidence: viewModel.currentSnapshot.emailSecurityConfidence,
 133                            error: viewModel.emailSecurityError
 134                        )
 135                        .padding(.top, appDensity.metrics.sectionSpacing)
 136                        NetworkSectionView(
 137                            isCollapsed: sectionCollapsedBinding(.network),
 138                            reachabilityRows: viewModel.reachabilityRows,
 139                            reachabilityLoading: viewModel.reachabilityLoading,
 140                            reachabilityError: viewModel.reachabilityError,
 141                            reachabilityProvenance: viewModel.currentSnapshot.provenanceBySection[.reachability],
 142                            locationRows: viewModel.locationRows,
 143                            geolocation: viewModel.ipGeolocation,
 144                            geolocationLoading: viewModel.ipGeolocationLoading,
 145                            geolocationError: viewModel.ipGeolocationError,
 146                            geolocationProvenance: viewModel.currentSnapshot.provenanceBySection[.ipGeolocation],
 147                            geolocationConfidence: viewModel.currentSnapshot.geolocationConfidence,
 148                            standardPortRows: viewModel.standardPortRows,
 149                            customPortRows: viewModel.customPortRows,
 150                            portScanLoading: viewModel.portScanLoading,
 151                            portScanError: viewModel.portScanError,
 152                            portScanProvenance: viewModel.currentSnapshot.provenanceBySection[.portScan],
 153                            customPortScanLoading: viewModel.customPortScanLoading,
 154                            customPortScanError: viewModel.customPortScanError,
 155                            isCloudflareProxied: viewModel.isCloudflareProxied,
 156                            customPortsExpanded: $customPortsExpanded,
 157                            customPortInput: $customPortInput,
 158                            onScanCustomPorts: runCustomPortScan
 159                        )
 160                        .padding(.top, appDensity.metrics.sectionSpacing)
 161                    } else if !viewModel.recentSearches.isEmpty {
 162                        recentSearchesSection
 163                    }
 164                }
 165                .padding(.horizontal)
 166                .padding(.bottom, 32)
 167            }
 168            .background(
 169                LinearGradient(
 170                    colors: [Color(.appBackground), Color(.appSurface)],
 171                    startPoint: .top,
 172                    endPoint: .bottom
 173                )
 174            )
 175            .navigationTitle("DomainDig")
 176            .toolbar {
 177                ToolbarItem(placement: .topBarTrailing) {
 178                    if viewModel.hasRun {
 179                        Button {
 180                            viewModel.reset()
 181                        } label: {
 182                            Image(systemName: "xmark.circle")
 183                                .foregroundStyle(Color(.appTextSecondary))
 184                        }
 185                        .accessibilityLabel("Clear results")
 186                    }
 187                }
 188            }
 189            .navigationDestination(for: WorkflowNavigationTarget.self) { target in
 190                WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
 191            }
 192        }
 193        .task {
 194            await viewModel.refreshUsageCredits()
 195        }
 196        .onChange(of: viewModel.searchedDomain) { _, _ in
 197            collapsedSections = defaultCollapsedSections
 198        }
 199        .onChange(of: viewModel.resultsLoaded) { wasLoaded, isLoaded in
 200            // Single-lookup completion has no single view-model moment
 201            // (`resultsLoaded` is derived from many loading flags), so the
 202            // announcement is posted from the view where the transition is
 203            // observable. The batch path announces from the view model directly.
 204            guard !wasLoaded, isLoaded, viewModel.hasRun else { return }
 205            let summary = AppStatusFactory.availability(viewModel.availabilityResult?.status).title
 206            AppAccessibility.announce("Lookup complete for \(viewModel.searchedDomain). \(summary).")
 207        }
 208        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
 209            navigationPath = NavigationPath()
 210            focusedInputField = nil
 211        }
 212        .onChange(of: inputMode) { _, newValue in
 213            viewModel.clearPresentedResults()
 214            focusedInputField = newValue == .single ? .singleDomain : .bulkDomains
 215        }
 216        .onChange(of: viewModel.domain) { _, newValue in
 217            guard inputMode == .single else { return }
 218            let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
 219            guard normalized != viewModel.searchedDomain else { return }
 220            guard viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
 221            viewModel.clearPresentedResults()
 222        }
 223        .onChange(of: viewModel.bulkInput) { _, newValue in
 224            guard inputMode == .bulk else { return }
 225            let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
 226            guard !normalized.isEmpty || viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
 227            viewModel.clearPresentedResults()
 228        }
 229        .sheet(item: $editingTrackedDomain) { trackedDomain in
 230            NavigationStack {
 231                Form {
 232                    Section("Tracking Note") {
 233                        TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
 234                            .lineLimit(3...6)
 235                            .textInputAutocapitalization(.never)
 236                            .autocorrectionDisabled()
 237                    }
 238                }
 239                .navigationTitle(trackedDomain.domain)
 240                .toolbar {
 241                    ToolbarItem(placement: .cancellationAction) {
 242                        Button("Cancel") {
 243                            editingTrackedDomain = nil
 244                        }
 245                    }
 246                    ToolbarItem(placement: .confirmationAction) {
 247                        Button("Save") {
 248                            viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
 249                            editingTrackedDomain = nil
 250                        }
 251                    }
 252                }
 253            }
 254        }
 255        .sheet(isPresented: $showingCurrentDomainWorkflowSheet) {
 256            WorkflowBulkAddSheet(
 257                viewModel: viewModel,
 258                title: "Add Domain to Workflow",
 259                availableDomains: [viewModel.searchedDomain]
 260            )
 261        }
 262        .sheet(isPresented: $showingBatchWorkflowSheet) {
 263            WorkflowBulkAddSheet(
 264                viewModel: viewModel,
 265                title: "Add Batch Domains",
 266                availableDomains: viewModel.batchResults.map(\.domain)
 267            )
 268        }
 269        .sheet(item: manualBatchSummaryBinding) { summary in
 270            BatchSweepSummaryView(viewModel: viewModel, summary: summary)
 271        }
 272        .sheet(isPresented: $showingTimeline) {
 273            NavigationStack {
 274                TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
 275            }
 276        }
 277        .sheet(isPresented: $showingAuditTimeline) {
 278            NavigationStack {
 279                AuditDomainTimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
 280            }
 281        }
 282    }
 283
 284    private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
 285        Binding(
 286            get: {
 287                guard let summary = viewModel.latestBatchSweepSummary,
 288                      summary.source == .manual else {
 289                    return nil
 290                }
 291                return summary
 292            },
 293            set: { viewModel.latestBatchSweepSummary = $0 }
 294        )
 295    }
 296
 297    private var inputSection: some View {
 298        VStack(spacing: appDensity.metrics.cardSpacing + 2) {
 299            Picker("Mode", selection: $inputMode) {
 300                Text("Single").tag(LookupInputMode.single)
 301                Text("Bulk").tag(LookupInputMode.bulk)
 302            }
 303            .pickerStyle(.segmented)
 304
 305            if inputMode == .single {
 306                TextField("e.g. cleberg.net", text: $viewModel.domain)
 307                    .font(appDensity.font(.title3, design: .monospaced))
 308                    .textInputAutocapitalization(.never)
 309                    .autocorrectionDisabled()
 310                    .keyboardType(.URL)
 311                    .padding(.horizontal, 12)
 312                    .padding(.vertical, appDensity.metrics.controlVerticalPadding)
 313                    .background(Color(.appSurfaceElevated))
 314                    .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 315                    .focused($focusedInputField, equals: .singleDomain)
 316                    .onSubmit {
 317                        focusedInputField = nil
 318                        viewModel.run()
 319                    }
 320
 321                Button {
 322                    focusedInputField = nil
 323                    viewModel.run()
 324                } label: {
 325                    Text("Run")
 326                        .font(appDensity.font(.headline, design: .default, weight: .semibold))
 327                        .frame(maxWidth: .infinity)
 328                        .frame(minHeight: appDensity.metrics.controlMinHeight)
 329                }
 330                .buttonStyle(.borderedProminent)
 331                .tint(Color(.accentFill))
 332                .disabled(viewModel.trimmedDomain.isEmpty)
 333            } else {
 334                if FeatureAccessService.hasAccess(to: .batchOperations) {
 335                    VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 336                        Text("Paste domains separated by new lines or commas.")
 337                            .font(appDensity.font(.caption))
 338                            .foregroundStyle(Color(.appTextSecondary))
 339
 340                        if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() {
 341                            Text(batchAllowanceSummary)
 342                                .font(appDensity.font(.caption2))
 343                                .foregroundStyle(Color(.appTextSecondary))
 344                        }
 345
 346                        TextField(
 347                            "example.com\napple.com, openai.com",
 348                            text: $viewModel.bulkInput,
 349                            axis: .vertical
 350                        )
 351                        .font(appDensity.font(.body))
 352                        .textInputAutocapitalization(.never)
 353                        .autocorrectionDisabled()
 354                        .keyboardType(.URL)
 355                        .lineLimit(4...10)
 356                        .padding(.horizontal, 12)
 357                        .padding(.vertical, appDensity.metrics.controlVerticalPadding)
 358                        .background(Color(.appSurfaceElevated))
 359                        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 360                        .focused($focusedInputField, equals: .bulkDomains)
 361
 362                        Button {
 363                            focusedInputField = nil
 364                            viewModel.runBulkLookup()
 365                        } label: {
 366                            Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
 367                                .font(appDensity.font(.headline, design: .default, weight: .semibold))
 368                                .frame(maxWidth: .infinity)
 369                                .frame(minHeight: appDensity.metrics.controlMinHeight)
 370                        }
 371                        .buttonStyle(.borderedProminent)
 372                        .tint(Color(.accentFill))
 373                        .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
 374                    }
 375                } else {
 376                    lockedFeatureCard(
 377                        title: "Batch Operations",
 378                        message: FeatureAccessService.upgradeMessage(for: .batchOperations)
 379                    )
 380                }
 381            }
 382        }
 383        .padding(.vertical, appDensity.metrics.sectionSpacing)
 384    }
 385
 386    private var domainOverviewSection: some View {
 387        let trackedDomain = viewModel.currentTrackedDomain
 388        let workflows = viewModel.currentDomainWorkflows
 389
 390        return DomainSectionView(
 391            isCollapsed: sectionCollapsedBinding(.domain),
 392            rows: viewModel.domainRows,
 393            suggestions: viewModel.suggestionRows,
 394            showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
 395            availabilityLoading: viewModel.availabilityLoading,
 396            suggestionsLoading: viewModel.suggestionsLoading,
 397            provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
 398            confidence: viewModel.currentSnapshot.availabilityConfidence,
 399            snapshotNote: viewModel.currentSnapshot.note,
 400            trackedDomain: trackedDomain,
 401            workflows: workflows,
 402            trackingLimitMessage: viewModel.trackingLimitMessage,
 403            pricingLoading: viewModel.domainPricingLoading,
 404            pricingError: viewModel.domainPricingError,
 405            showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
 406            onTrack: {
 407                _ = viewModel.trackCurrentDomain()
 408            },
 409            onTogglePinned: {
 410                guard let trackedDomain else { return }
 411                viewModel.togglePinned(for: trackedDomain)
 412            },
 413            onEditNote: {
 414                guard let trackedDomain else { return }
 415                trackingNoteDraft = trackedDomain.note ?? ""
 416                editingTrackedDomain = trackedDomain
 417            },
 418            onAddToWorkflow: {
 419                showingCurrentDomainWorkflowSheet = true
 420            },
 421            onOpenWorkflow: { workflow in
 422                navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
 423            },
 424            onRunWorkflow: { workflow in
 425                viewModel.rerunCurrentDomain(in: workflow)
 426            }
 427        )
 428    }
 429
 430    private func intelligenceSection(report: DomainReport) -> some View {
 431        IntelligenceSectionView(
 432            isCollapsed: sectionCollapsedBinding(.intelligence),
 433            report: report,
 434            showsPlaceholder: FeatureAccessService.currentTier != .proPlus
 435        )
 436    }
 437
 438    private var ownershipSection: some View {
 439        OwnershipSectionView(
 440            isCollapsed: sectionCollapsedBinding(.ownership),
 441            rows: viewModel.ownershipRows,
 442            loading: viewModel.ownershipLoading,
 443            error: viewModel.ownershipError,
 444            provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
 445            confidence: viewModel.currentSnapshot.ownershipConfidence,
 446            showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory),
 447            history: viewModel.ownershipHistory,
 448            historyLoading: viewModel.ownershipHistoryLoading,
 449            historyError: viewModel.ownershipHistoryError,
 450            historyCreditStatus: viewModel.ownershipHistoryCreditStatus,
 451            onLoadHistory: {
 452                Task {
 453                    await viewModel.loadOwnershipHistory()
 454                }
 455            }
 456        )
 457    }
 458
 459    private var subdomainsSection: some View {
 460        SubdomainsSectionView(
 461            isCollapsed: sectionCollapsedBinding(.subdomains),
 462            rows: viewModel.subdomainRows,
 463            groups: viewModel.currentSubdomainGroups,
 464            loading: viewModel.subdomainsLoading,
 465            error: viewModel.subdomainsError,
 466            provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
 467            confidence: viewModel.currentSnapshot.subdomainConfidence,
 468            showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains),
 469            extendedCount: viewModel.extendedSubdomains.count,
 470            extendedLoading: viewModel.extendedSubdomainsLoading,
 471            extendedError: viewModel.extendedSubdomainsError,
 472            extendedCreditStatus: viewModel.extendedSubdomainsCreditStatus,
 473            onLoadExtended: {
 474                Task {
 475                    await viewModel.loadExtendedSubdomains()
 476                }
 477            }
 478        )
 479    }
 480
 481    private var dnsSection: some View {
 482        DNSSectionView(
 483            isCollapsed: sectionCollapsedBinding(.dns),
 484            dnssecLabel: viewModel.dnssecLabel,
 485            patternSummary: viewModel.currentDNSPatterns,
 486            sections: viewModel.dnsRows,
 487            ptrMessage: viewModel.ptrMessage,
 488            loading: viewModel.dnsLoading || viewModel.ptrLoading,
 489            dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
 490            ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
 491            sectionError: viewModel.dnsError,
 492            history: viewModel.dnsHistory,
 493            historyLoading: viewModel.dnsHistoryLoading,
 494            historyError: viewModel.dnsHistoryError,
 495            showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .dnsHistory),
 496            historyCreditStatus: viewModel.dnsHistoryCreditStatus,
 497            onLoadHistory: {
 498                Task {
 499                    await viewModel.loadDNSHistory()
 500                }
 501            }
 502        )
 503    }
 504
 505    private var actionButtons: some View {
 506        HStack {
 507            Spacer()
 508            if viewModel.resultsLoaded {
 509                Menu {
 510                    if !viewModel.isCurrentDomainTracked {
 511                        Button("Track this domain") {
 512                            _ = viewModel.trackCurrentDomain()
 513                        }
 514                    }
 515                    Button("Add to workflow") {
 516                        showingCurrentDomainWorkflowSheet = true
 517                    }
 518                    Button(auditStartInFlight ? "Starting audit…" : "Start audit") {
 519                        Task {
 520                            auditStartInFlight = true
 521                            if await viewModel.startAudit(for: viewModel.searchedDomain) != nil {
 522                                showingAuditTimeline = true
 523                            }
 524                            auditStartInFlight = false
 525                        }
 526                    }
 527                    .disabled(auditStartInFlight)
 528                    if !viewModel.audits(for: viewModel.searchedDomain).isEmpty {
 529                        Button("View audits") {
 530                            showingAuditTimeline = true
 531                        }
 532                    }
 533                    if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
 534                        Button("Open timeline") {
 535                            showingTimeline = true
 536                        }
 537                    }
 538                    if FeatureAccessService.hasAccess(to: .advancedExports) {
 539                        Button("Copy report JSON") {
 540                            guard let json = viewModel.exportJSONString() else { return }
 541                            AppClipboard.copy(json)
 542                            AppHaptics.copy()
 543                        }
 544                    } else {
 545                        Button("Copy report JSON") {
 546                            viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports)
 547                        }
 548                    }
 549                    Button("Export report") {
 550                        shareSingleResults(format: .text)
 551                    }
 552                } label: {
 553                    Image(systemName: "bolt.circle")
 554                        .font(appDensity.font(.body, design: .default))
 555                        .foregroundStyle(Color(.appTextSecondary))
 556                }
 557                .accessibilityLabel("Actions")
 558                Button {
 559                    viewModel.toggleSavedDomain()
 560                } label: {
 561                    Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
 562                        .font(appDensity.font(.body, design: .default))
 563                        .foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary)
 564                }
 565                .accessibilityLabel("Save domain")
 566                .accessibilityValue(viewModel.isCurrentDomainSaved ? "Saved" : "Not saved")
 567                .accessibilityAddTraits(viewModel.isCurrentDomainSaved ? .isSelected : [])
 568                Menu {
 569                    Button("Export TXT") {
 570                        shareSingleResults(format: .text)
 571                    }
 572                    if FeatureAccessService.hasAccess(to: .advancedExports) {
 573                        Button("Export CSV") {
 574                            shareSingleResults(format: .csv)
 575                        }
 576                        Button("Export JSON") {
 577                            shareSingleResults(format: .json)
 578                        }
 579                        Button("Export Markdown") {
 580                            shareSingleResults(format: .markdown)
 581                        }
 582                        Button("Export PDF") {
 583                            shareSingleResults(format: .pdf)
 584                        }
 585                    } else {
 586                        Button("CSV Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 587                            .disabled(true)
 588                        Button("JSON Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 589                            .disabled(true)
 590                        Button("Markdown Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 591                            .disabled(true)
 592                        Button("PDF Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 593                            .disabled(true)
 594                    }
 595                } label: {
 596                    Image(systemName: "square.and.arrow.up")
 597                        .font(appDensity.font(.body, design: .default))
 598                        .foregroundStyle(Color(.appTextSecondary))
 599                }
 600                .accessibilityLabel("Export")
 601            }
 602        }
 603    }
 604
 605    private var batchSection: some View {
 606        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 607            HStack {
 608                Spacer()
 609                if viewModel.batchLookupRunning {
 610                    Button("Cancel") {
 611                        viewModel.cancelBatchLookup()
 612                    }
 613                    .buttonStyle(.bordered)
 614                    .font(appDensity.font(.caption))
 615                }
 616                if !viewModel.currentBatchResultEntries.isEmpty {
 617                    Menu {
 618                        Button("Add to Workflow") {
 619                            showingBatchWorkflowSheet = true
 620                        }
 621                        Divider()
 622                        Button("Export Batch TXT") {
 623                            shareBatchResults(format: .text)
 624                        }
 625                        if FeatureAccessService.hasAccess(to: .advancedExports) {
 626                            Button("Export Batch CSV") {
 627                                shareBatchResults(format: .csv)
 628                            }
 629                            Button("Export Batch JSON") {
 630                                shareBatchResults(format: .json)
 631                            }
 632                            Button("Export Batch Markdown") {
 633                                shareBatchResults(format: .markdown)
 634                            }
 635                            Button("Export Batch PDF") {
 636                                shareBatchResults(format: .pdf)
 637                            }
 638                        } else {
 639                            Button("Batch CSV • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 640                                .disabled(true)
 641                            Button("Batch JSON • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 642                                .disabled(true)
 643                            Button("Batch Markdown • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 644                                .disabled(true)
 645                            Button("Batch PDF • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
 646                                .disabled(true)
 647                        }
 648                    } label: {
 649                        Label("Export", systemImage: "square.and.arrow.up")
 650                            .font(appDensity.font(.caption))
 651                    }
 652                    .buttonStyle(.bordered)
 653                }
 654            }
 655
 656            BatchResultsView(
 657                viewModel: viewModel,
 658                title: viewModel.batchLookupSource == .watchlistRefresh ? "Tracked Domain Refresh" : "Batch Results"
 659            )
 660        }
 661    }
 662
 663    private var recentSearchesSection: some View {
 664        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 665            HStack {
 666                Text("RECENT")
 667                    .font(appDensity.font(.caption2))
 668                    .foregroundStyle(Color(.appTextSecondary))
 669                Spacer()
 670                Button("Clear") {
 671                    viewModel.clearRecentSearches()
 672                }
 673                .font(appDensity.font(.caption2))
 674                .foregroundStyle(Color(.appTextSecondary))
 675            }
 676
 677            ForEach(viewModel.recentSearches, id: \.self) { domain in
 678                Button {
 679                    viewModel.domain = domain
 680                    focusedInputField = nil
 681                    viewModel.run()
 682                } label: {
 683                    Text(domain)
 684                        .font(appDensity.font(.callout))
 685                        .foregroundStyle(.primary)
 686                        .frame(maxWidth: .infinity, alignment: .leading)
 687                        .padding(.vertical, 8)
 688                        .padding(.horizontal, 10)
 689                        .background(Color(.appSurface))
 690                        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 691                }
 692            }
 693        }
 694        .padding(.top, appDensity.metrics.cardSpacing)
 695    }
 696
 697    private func runCustomPortScan() {
 698        let ports = parsedCustomPorts(from: customPortInput)
 699        Task {
 700            await viewModel.runCustomPortScan(ports: ports)
 701        }
 702    }
 703
 704    private func lockedFeatureCard(title: String, message: String) -> some View {
 705        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 706            Text(title)
 707                .font(appDensity.font(.headline, design: .default, weight: .semibold))
 708            Text(message)
 709                .font(appDensity.font(.callout, design: .default))
 710                .foregroundStyle(Color(.appTextSecondary))
 711        }
 712        .frame(maxWidth: .infinity, alignment: .leading)
 713        .padding(appDensity.metrics.cardPadding)
 714        .background(Color(.appSurfaceElevated))
 715        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 716    }
 717
 718    private func parsedCustomPorts(from input: String) -> [UInt16] {
 719        let parts = input.split(separator: ",", omittingEmptySubsequences: true)
 720        var seen = Set<UInt16>()
 721        var ports: [UInt16] = []
 722
 723        for part in parts {
 724            let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
 725            guard let value = UInt16(trimmed), seen.insert(value).inserted else {
 726                continue
 727            }
 728            ports.append(value)
 729            if ports.count == 20 {
 730                break
 731            }
 732        }
 733
 734        return ports
 735    }
 736
 737    private func shareSingleResults(format: DomainExportFormat) {
 738        guard let data = viewModel.exportSingleReportData(format: format) else { return }
 739        ExportPresenter.share(filename: exportFilename(prefix: "domaindig_single", format: format), data: data)
 740    }
 741
 742    private func shareBatchResults(format: DomainExportFormat) {
 743        guard let data = viewModel.exportBatchReportData(format: format) else { return }
 744        ExportPresenter.share(filename: exportFilename(prefix: "domaindig_batch", format: format), data: data)
 745    }
 746
 747    private func exportFilename(prefix: String, format: DomainExportFormat) -> String {
 748        let formatter = DateFormatter()
 749        formatter.dateFormat = "yyyyMMdd_HHmmss"
 750        let timestamp = formatter.string(from: Date())
 751        return "\(timestamp)_\(prefix).\(format.fileExtension)"
 752    }
 753
 754    private var defaultCollapsedSections: Set<ResultSection> {
 755        []
 756    }
 757
 758    private var currentPrimaryIP: String? {
 759        viewModel.currentSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
 760    }
 761
 762    private var resultStatusMessage: String? {
 763        if let currentStatusMessage = viewModel.currentStatusMessage {
 764            return currentStatusMessage
 765        }
 766
 767        if viewModel.currentResultSource != .live {
 768            return viewModel.currentResultSource.label
 769        }
 770
 771        return nil
 772    }
 773
 774    private func sectionCollapsedBinding(_ section: ResultSection) -> Binding<Bool> {
 775        Binding(
 776            get: { collapsedSections.contains(section) },
 777            set: { isCollapsed in
 778                if isCollapsed {
 779                    collapsedSections.insert(section)
 780                } else {
 781                    collapsedSections.remove(section)
 782                }
 783            }
 784        )
 785    }
 786}
 787
 788struct SummaryView: View {
 789    @Environment(\.appDensity) private var appDensity
 790    let fields: [SummaryFieldViewData]
 791
 792    var body: some View {
 793        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 794            SectionTitleView(title: "Summary")
 795            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) {
 796                ForEach(fields) { field in
 797                    VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) {
 798                        Text(field.label)
 799                            .font(appDensity.font(.caption2))
 800                            .foregroundStyle(Color(.appTextSecondary))
 801                        Text(field.value)
 802                            .font(appDensity.font(.caption))
 803                            .foregroundStyle(ResultColors.color(for: field.tone))
 804                            .lineLimit(2)
 805                            .textSelection(.enabled)
 806                    }
 807                    .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
 808                    .frame(maxWidth: .infinity, alignment: .leading)
 809                    .padding(appDensity.metrics.cardPadding)
 810                    .background(Color(.appSurface))
 811                    .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 812                }
 813            }
 814        }
 815    }
 816}
 817
 818struct RiskSummaryCardView: View {
 819    @Environment(\.appDensity) private var appDensity
 820    let report: DomainReport
 821
 822    private var topFactors: [RiskFactor] {
 823        Array(report.riskAssessment.factors.prefix(3))
 824    }
 825
 826    var body: some View {
 827        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 828            SectionTitleView(title: "Risk")
 829            CardView(allowsHorizontalScroll: false) {
 830                HStack(alignment: .firstTextBaseline) {
 831                    VStack(alignment: .leading, spacing: 4) {
 832                        Text("\(report.riskAssessment.score)")
 833                            .font(appDensity.font(.largeTitle, weight: .bold))
 834                            .foregroundStyle(levelColor)
 835                        Text(report.riskAssessment.level.title)
 836                            .font(appDensity.font(.caption))
 837                            .foregroundStyle(levelColor)
 838                    }
 839                    Spacer()
 840                    Text("Deterministic")
 841                        .font(appDensity.font(.caption2))
 842                        .foregroundStyle(Color(.appTextSecondary))
 843                }
 844
 845                if topFactors.isEmpty {
 846                    Text("No major risk factors identified")
 847                        .font(appDensity.font(.caption))
 848                        .foregroundStyle(Color(.appTextSecondary))
 849                } else {
 850                    ForEach(Array(topFactors.enumerated()), id: \.offset) { _, factor in
 851                        HStack(alignment: .top, spacing: 8) {
 852                            Circle()
 853                                .fill(factorColor(factor.impact))
 854                                .frame(width: 8, height: 8)
 855                                .padding(.top, 5)
 856                            Text(factor.description)
 857                                .font(appDensity.font(.caption))
 858                                .foregroundStyle(.primary)
 859                        }
 860                    }
 861                }
 862            }
 863        }
 864    }
 865
 866    private var levelColor: Color {
 867        switch report.riskAssessment.level {
 868        case .low:
 869            return Color(.statusPositive)
 870        case .medium:
 871            return Color(.statusWarning)
 872        case .high:
 873            return Color(.statusCritical)
 874        }
 875    }
 876
 877    private func factorColor(_ impact: RiskImpact) -> Color {
 878        switch impact {
 879        case .positive:
 880            return Color(.statusPositive)
 881        case .neutral:
 882            return .secondary
 883        case .negative:
 884            return Color(.statusCritical)
 885        }
 886    }
 887}
 888
 889struct InsightsSummaryCardView: View {
 890    @Environment(\.appDensity) private var appDensity
 891    let insights: [String]
 892
 893    var body: some View {
 894        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 895            SectionTitleView(title: "Insights")
 896            CardView(allowsHorizontalScroll: false) {
 897                if insights.isEmpty {
 898                    Text("No deterministic insights triggered")
 899                        .font(appDensity.font(.caption))
 900                        .foregroundStyle(Color(.appTextSecondary))
 901                } else {
 902                    ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
 903                        HStack(alignment: .top, spacing: 8) {
 904                            Image(systemName: "chart.line.uptrend.xyaxis")
 905                                .font(appDensity.font(.caption2))
 906                                .foregroundStyle(Color(.statusInfo))
 907                                .padding(.top, 2)
 908                            Text(insight)
 909                                .font(appDensity.font(.caption))
 910                                .foregroundStyle(.primary)
 911                        }
 912                    }
 913                }
 914            }
 915        }
 916    }
 917}
 918
 919struct StickyLookupSummaryView: View {
 920    @Environment(\.appDensity) private var appDensity
 921
 922    let domain: String
 923    let availability: DomainAvailabilityStatus?
 924    let primaryIP: String?
 925    let sslInfo: SSLCertificateInfo?
 926    let sslError: String?
 927    let emailSecurity: EmailSecurityResult?
 928    let emailError: String?
 929    let changeSummary: DomainChangeSummary?
 930
 931    var body: some View {
 932        CardView(allowsHorizontalScroll: false) {
 933            VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
 934                HStack(alignment: .center, spacing: 10) {
 935                    Text(domain)
 936                        .font(appDensity.font(.headline, weight: .semibold))
 937                        .foregroundStyle(.primary)
 938                        .lineLimit(1)
 939                    Spacer(minLength: 6)
 940                    AppCopyButton(value: domain, label: "Copy domain")
 941                }
 942
 943                ScrollView(.horizontal, showsIndicators: false) {
 944                    HStack(spacing: 8) {
 945                        AppStatusBadgeView(model: AppStatusFactory.availability(availability))
 946                        AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
 947                        AppStatusBadgeView(model: AppStatusFactory.email(emailSecurity, error: emailError))
 948                        AppStatusBadgeView(model: AppStatusFactory.change(changeSummary))
 949                    }
 950                }
 951
 952                if let primaryIP {
 953                    HStack(spacing: 8) {
 954                        Label(primaryIP, systemImage: "network")
 955                            .font(appDensity.font(.caption))
 956                            .foregroundStyle(Color(.appTextSecondary))
 957                        Spacer(minLength: 6)
 958                        AppCopyButton(value: primaryIP, label: "Copy IP")
 959                    }
 960                }
 961            }
 962        }
 963        .shadow(color: .black.opacity(0.12), radius: 14, y: 6)
 964    }
 965}
 966
 967struct LookupProgressOverviewView: View {
 968    @Environment(\.appDensity) private var appDensity
 969    let steps: [String]
 970
 971    var body: some View {
 972        CardView(allowsHorizontalScroll: false) {
 973            HStack(spacing: 8) {
 974                ProgressView()
 975                    .controlSize(.small)
 976                VStack(alignment: .leading, spacing: 4) {
 977                    Text("Running lookup…")
 978                        .font(appDensity.font(.caption))
 979                        .foregroundStyle(.primary)
 980                    Text(steps.isEmpty ? "Preparing requests" : steps.joined(separator: ""))
 981                        .font(appDensity.font(.caption2))
 982                        .foregroundStyle(Color(.appTextSecondary))
 983                }
 984                Spacer()
 985            }
 986        }
 987    }
 988}
 989
 990struct LookupStatusBannerView: View {
 991    @Environment(\.appDensity) private var appDensity
 992    let message: String
 993    let resultSource: LookupResultSource
 994
 995    var body: some View {
 996        HStack(spacing: 8) {
 997            Image(systemName: iconName)
 998                .font(.caption)
 999            Text(message)
1000                .font(appDensity.font(.caption))
1001            Spacer()
1002        }
1003        .foregroundStyle(color)
1004        .padding(appDensity.metrics.cardPadding - 2)
1005        .frame(maxWidth: .infinity, alignment: .leading)
1006        .background(color.opacity(0.12))
1007        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1008    }
1009
1010    private var color: Color {
1011        switch resultSource {
1012        case .live:
1013            return Color(.statusPositive)
1014        case .cached:
1015            return .secondary
1016        case .mixed:
1017            return Color(.statusWarning)
1018        case .snapshot:
1019            return Color(.statusWarning)
1020        }
1021    }
1022
1023    private var iconName: String {
1024        switch resultSource {
1025        case .live:
1026            return "bolt.horizontal"
1027        case .cached:
1028            return "clock.arrow.trianglehead.counterclockwise.rotate.90"
1029        case .mixed:
1030            return "arrow.triangle.branch"
1031        case .snapshot:
1032            return "archivebox"
1033        }
1034    }
1035}
1036
1037struct DomainChangeSummaryView: View {
1038    @Environment(\.appDensity) private var appDensity
1039    let summary: DomainChangeSummary
1040    @State private var showsDetails = false
1041
1042    var body: some View {
1043        CardView(allowsHorizontalScroll: false) {
1044            HStack {
1045                Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
1046                    .font(appDensity.font(.caption))
1047                    .foregroundStyle(summary.hasChanges ? severityTone(summary.severity).foreground : Color(.statusPositive))
1048                Spacer()
1049                Text(summary.severity.title.uppercased())
1050                    .font(appDensity.font(.caption2))
1051                    .foregroundStyle(summary.hasChanges ? severityTone(summary.severity).foreground : Color(.appTextSecondary))
1052                    .padding(.horizontal, 8)
1053                    .padding(.vertical, 4)
1054                    .background((summary.hasChanges ? severityTone(summary.severity) : AppStatusTone.neutral).surface)
1055                    .clipShape(Capsule())
1056                Text(summary.impactClassification.title.uppercased())
1057                    .font(appDensity.font(.caption2))
1058                    .foregroundStyle(summary.impactClassification.tone.foreground)
1059                    .padding(.horizontal, 8)
1060                    .padding(.vertical, 4)
1061                    .background(summary.impactClassification.tone.surface)
1062                    .clipShape(Capsule())
1063                Text(summary.generatedAt, style: .time)
1064                    .font(appDensity.font(.caption2))
1065                    .foregroundStyle(Color(.appTextSecondary))
1066            }
1067
1068            VStack(alignment: .leading, spacing: 4) {
1069                Text("Inference")
1070                    .font(appDensity.font(.caption2))
1071                    .foregroundStyle(Color(.appTextSecondary))
1072                Text(summary.message)
1073                    .font(appDensity.font(.caption))
1074                    .foregroundStyle(.primary)
1075                    .lineLimit(2)
1076            }
1077
1078            if !summary.observedFacts.isEmpty || summary.contextNote != nil {
1079                DisclosureGroup(showsDetails ? "Hide Details" : "Show Details", isExpanded: $showsDetails) {
1080                    VStack(alignment: .leading, spacing: 8) {
1081                        if !summary.observedFacts.isEmpty {
1082                            VStack(alignment: .leading, spacing: 4) {
1083                                Text("Observed")
1084                                    .font(appDensity.font(.caption2))
1085                                    .foregroundStyle(Color(.appTextSecondary))
1086                                ForEach(Array(summary.observedFacts.enumerated()), id: \.offset) { _, fact in
1087                                    Text(fact)
1088                                        .font(appDensity.font(.caption))
1089                                        .foregroundStyle(.primary)
1090                                }
1091                            }
1092                        }
1093
1094                        if let riskScoreDelta = summary.riskScoreDelta {
1095                            Text("Risk delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
1096                                .font(appDensity.font(.caption2))
1097                                .foregroundStyle(riskScoreDelta > 0 ? Color(.statusWarning) : .secondary)
1098                        }
1099
1100                        if let contextNote = summary.contextNote {
1101                            Text(contextNote)
1102                                .font(appDensity.font(.caption2))
1103                                .foregroundStyle(Color(.statusWarning))
1104                        }
1105                    }
1106                    .padding(.top, 4)
1107                }
1108                .font(appDensity.font(.caption))
1109                .tint(.secondary)
1110            }
1111        }
1112    }
1113
1114    private func severityTone(_ severity: ChangeSeverity) -> AppStatusTone {
1115        switch severity {
1116        case .low:
1117            return .neutral
1118        case .medium:
1119            return .warning
1120        case .high:
1121            return .critical
1122        }
1123    }
1124
1125}
1126
1127struct DomainDiffView: View {
1128    let title: String
1129    let sections: [DomainDiffSection]
1130    let contextNote: String?
1131    let showsUnchanged: Bool
1132    let highlightedSectionID: String?
1133
1134    @State private var collapsedSections = Set<String>()
1135    @State private var showsLowSeverity = false
1136
1137    private var filteredSections: [DomainDiffSection] {
1138        sections
1139            .map { section in
1140                let items = section.items.filter { item in
1141                    if !showsUnchanged, !item.hasChanges {
1142                        return false
1143                    }
1144                    if showsLowSeverity {
1145                        return true
1146                    }
1147                    return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
1148                }
1149                return DomainDiffSection(id: section.id, title: section.title, items: items)
1150            }
1151            .filter { !$0.items.isEmpty }
1152    }
1153
1154    private var hasLowSeverityChanges: Bool {
1155        sections.flatMap(\.items).contains { $0.hasChanges && $0.severity == .low }
1156    }
1157
1158    var body: some View {
1159        VStack(alignment: .leading, spacing: 12) {
1160            HStack {
1161                SectionTitleView(title: title)
1162                Spacer()
1163                if hasLowSeverityChanges {
1164                    Button(showsLowSeverity ? "Hide Low" : "Show Low") {
1165                        showsLowSeverity.toggle()
1166                    }
1167                    .buttonStyle(.bordered)
1168                    .font(.system(.caption, design: .monospaced))
1169                }
1170            }
1171            if let contextNote {
1172                MessageCardView(text: contextNote, isError: false)
1173            }
1174            if filteredSections.isEmpty {
1175                MessageCardView(text: "No comparison data available", isError: false)
1176            } else {
1177                ForEach(filteredSections) { section in
1178                    CardView(allowsHorizontalScroll: false) {
1179                        DisclosureGroup(isExpanded: binding(for: section)) {
1180                            let visibleItems = showsUnchanged ? section.items : section.items.filter(\.hasChanges)
1181
1182                            ForEach(visibleItems) { item in
1183                                VStack(alignment: .leading, spacing: 6) {
1184                                    HStack {
1185                                        Text(item.label)
1186                                            .font(.system(.caption, design: .monospaced))
1187                                            .foregroundStyle(Color(.appTextSecondary))
1188                                        Spacer()
1189                                        Text("\(item.changeType.marker) \(item.severity.title)\(changeLabel(for: item.changeType))")
1190                                            .font(.system(.caption2, design: .monospaced))
1191                                            .foregroundStyle(changeTone(for: item).foreground)
1192                                            .padding(.horizontal, 8)
1193                                            .padding(.vertical, 4)
1194                                            .background(changeTone(for: item).surface)
1195                                            .clipShape(Capsule())
1196                                    }
1197
1198                                    if let oldValue = item.oldValue {
1199                                        VStack(alignment: .leading, spacing: 2) {
1200                                            Text("Old")
1201                                                .font(.system(.caption2, design: .monospaced))
1202                                                .foregroundStyle(Color(.appTextSecondary))
1203                                            Text(oldValue)
1204                                                .font(.system(.caption2, design: .monospaced))
1205                                                .foregroundStyle(Color(.appTextSecondary))
1206                                                .textSelection(.enabled)
1207                                        }
1208                                    }
1209
1210                                    if let newValue = item.newValue {
1211                                        VStack(alignment: .leading, spacing: 2) {
1212                                            Text("New")
1213                                                .font(.system(.caption2, design: .monospaced))
1214                                                .foregroundStyle(Color(.appTextSecondary))
1215                                            Text(newValue)
1216                                                .font(.system(.caption, design: .monospaced))
1217                                                .foregroundStyle(item.hasChanges ? .primary : .secondary)
1218                                                .textSelection(.enabled)
1219                                        }
1220                                    }
1221                                }
1222                                .padding(10)
1223                                .background(item.hasChanges ? changeTone(for: item).surface : Color(.appSurface))
1224                                .cornerRadius(8)
1225                            }
1226                        } label: {
1227                            HStack {
1228                                Text(section.title)
1229                                    .font(.system(.subheadline, design: .monospaced))
1230                                    .fontWeight(.semibold)
1231                                    .foregroundStyle(sectionColor(section))
1232                                Spacer()
1233                                Text(section.severity.title)
1234                                    .font(.system(.caption2, design: .monospaced))
1235                                    .foregroundStyle(sectionColor(section))
1236                            }
1237                        }
1238                    }
1239                    .id(section.id)
1240                    .overlay {
1241                        if highlightedSectionID == section.id {
1242                            RoundedRectangle(cornerRadius: 12)
1243                                .stroke(Color(.statusInfo).opacity(0.55), lineWidth: 1)
1244                        }
1245                    }
1246                }
1247            }
1248        }
1249        .onAppear {
1250            collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id))
1251        }
1252    }
1253
1254    private func changeLabel(for changeType: DiffChangeType) -> String {
1255        switch changeType {
1256        case .added:
1257            return "Added"
1258        case .removed:
1259            return "Removed"
1260        case .changed:
1261            return "Changed"
1262        case .unchanged:
1263            return "Unchanged"
1264        }
1265    }
1266
1267    private func changeTone(for item: DomainDiffItem) -> AppStatusTone {
1268        if item.changeType == .unchanged {
1269            return .neutral
1270        }
1271
1272        switch item.severity {
1273        case .low:
1274            return .info
1275        case .medium:
1276            return .warning
1277        case .high:
1278            return .critical
1279        }
1280    }
1281
1282    private func sectionColor(_ section: DomainDiffSection) -> Color {
1283        switch section.severity {
1284        case .low:
1285            return .blue
1286        case .medium:
1287            return Color(.statusWarning)
1288        case .high:
1289            return Color(.statusCritical)
1290        }
1291    }
1292
1293    private func binding(for section: DomainDiffSection) -> Binding<Bool> {
1294        Binding(
1295            get: { !collapsedSections.contains(section.id) },
1296            set: { isExpanded in
1297                if isExpanded {
1298                    collapsedSections.remove(section.id)
1299                } else {
1300                    collapsedSections.insert(section.id)
1301                }
1302            }
1303        )
1304    }
1305}
1306
1307struct TrackedDomainDetailHeaderView: View {
1308    let trackedDomain: TrackedDomain
1309
1310    var body: some View {
1311        VStack(alignment: .leading, spacing: 4) {
1312            if let note = trackedDomain.note?.nilIfEmpty {
1313                LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
1314            }
1315            HStack(spacing: 8) {
1316                if trackedDomain.isPinned {
1317                    Label("Pinned", systemImage: "pin.fill")
1318                }
1319                Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
1320            }
1321            .font(.system(.caption2, design: .monospaced))
1322            .foregroundStyle(Color(.appTextSecondary))
1323        }
1324    }
1325}
1326
1327struct SectionTitleView: View {
1328    @Environment(\.appDensity) private var appDensity
1329    let title: String
1330
1331    var body: some View {
1332        Text(title)
1333            .font(appDensity.font(.headline, design: .default, weight: .semibold))
1334            .foregroundStyle(.primary)
1335            .accessibilityAddTraits(.isHeader)
1336    }
1337}
1338
1339/// A card that wraps its content by default.
1340///
1341/// `allowsHorizontalScroll` used to default to `true`, so nine call sites put
1342/// their content behind a horizontal gesture instead of letting it wrap  a
1343/// WCAG 1.4.10 (Reflow) failure, and the mechanism behind clipped rows at large
1344/// text sizes. It also forced VoiceOver and Switch Control users onto a nested
1345/// scroll axis to reach data.
1346///
1347/// The default is now `false`. Where horizontal scrolling genuinely suits wide
1348/// tabular content, it is still opt-in  but it is suppressed at accessibility
1349/// text sizes, where wrapping always beats a hidden axis.
1350struct CardView<Content: View>: View {
1351    @Environment(\.appDensity) private var appDensity
1352    @Environment(\.dynamicTypeSize) private var dynamicTypeSize
1353    let allowsHorizontalScroll: Bool
1354    let content: Content
1355
1356    init(allowsHorizontalScroll: Bool = false, @ViewBuilder content: () -> Content) {
1357        self.allowsHorizontalScroll = allowsHorizontalScroll
1358        self.content = content()
1359    }
1360
1361    var body: some View {
1362        Group {
1363            if allowsHorizontalScroll, !dynamicTypeSize.isAccessibilitySize {
1364                ScrollView(.horizontal) {
1365                    cardContent
1366                        .scrollTargetLayout()
1367                }
1368                .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
1369            } else {
1370                cardContent
1371                    .frame(maxWidth: .infinity, alignment: .leading)
1372            }
1373        }
1374        .frame(maxWidth: .infinity, alignment: .leading)
1375        .padding(appDensity.metrics.cardPadding)
1376        .background(Color(.appSurface))
1377        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1378    }
1379
1380    private var cardContent: some View {
1381        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
1382            content
1383        }
1384    }
1385}
1386
1387struct LoadingCardView: View {
1388    let text: String
1389
1390    var body: some View {
1391        CardView {
1392            ProgressView(text)
1393                .appLoadingStyle()
1394                .frame(maxWidth: .infinity, alignment: .center)
1395        }
1396    }
1397}
1398
1399struct MessageCardView: View {
1400    let text: String
1401    let isError: Bool
1402
1403    var body: some View {
1404        CardView {
1405            MessageRowView(text: text, isError: isError)
1406        }
1407    }
1408}
1409
1410struct MessageRowView: View {
1411    @Environment(\.appDensity) private var appDensity
1412    let text: String
1413    let isError: Bool
1414
1415    var body: some View {
1416        Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
1417            .font(appDensity.font(.caption))
1418            .foregroundStyle(isError ? Color(.statusCritical) : .secondary)
1419            .lineLimit(nil)
1420            .fixedSize(horizontal: false, vertical: true)
1421    }
1422}
1423
1424struct SectionTrustMetadataView: View {
1425    @Environment(\.appDensity) private var appDensity
1426    let provenance: SectionProvenance?
1427    let confidence: ConfidenceLevel?
1428    let note: String?
1429
1430    init(provenance: SectionProvenance?, confidence: ConfidenceLevel?, note: String? = nil) {
1431        self.provenance = provenance
1432        self.confidence = confidence
1433        self.note = note
1434    }
1435
1436    var body: some View {
1437        if provenance != nil || confidence != nil || note != nil {
1438            VStack(alignment: .leading, spacing: 6) {
1439                HStack(spacing: 8) {
1440                    if let confidence {
1441                        Text("Confidence \(confidence.title)")
1442                            .font(appDensity.font(.caption2))
1443                            .foregroundStyle(Color(.appTextSecondary))
1444                    }
1445                    if let provenance {
1446                        Text(provenance.provider ?? provenance.source)
1447                            .font(appDensity.font(.caption2))
1448                            .foregroundStyle(Color(.appTextSecondary))
1449                        Text(provenance.resultSource.label)
1450                            .font(appDensity.font(.caption2))
1451                            .foregroundStyle(Color(.appTextSecondary))
1452                    }
1453                }
1454                DisclosureGroup("Details") {
1455                    VStack(alignment: .leading, spacing: 4) {
1456                        if let provenance {
1457                            LabeledValueRow(row: .init(label: "Method", value: provenance.source, tone: .secondary))
1458                            if let provider = provenance.provider {
1459                                LabeledValueRow(row: .init(label: "Provider", value: provider, tone: .secondary))
1460                            }
1461                            if let resolver = provenance.resolver {
1462                                LabeledValueRow(row: .init(label: "Resolver", value: resolver, tone: .secondary))
1463                            }
1464                            LabeledValueRow(row: .init(label: "Collected", value: provenance.collectedAt.formatted(date: .abbreviated, time: .shortened), tone: .secondary))
1465                            LabeledValueRow(row: .init(label: "Mode", value: provenance.resultSource.label, tone: .secondary))
1466                        }
1467                        if let note {
1468                            LabeledValueRow(row: .init(label: "Note", value: note, tone: .secondary))
1469                        }
1470                    }
1471                    .padding(.top, 4)
1472                }
1473                .font(appDensity.font(.caption))
1474                .tint(.secondary)
1475            }
1476        }
1477    }
1478}
1479
1480struct LabeledValueRow: View {
1481    @Environment(\.appDensity) private var appDensity
1482    @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
1483    let row: InfoRowViewData
1484
1485    var body: some View {
1486        VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1487            HStack(alignment: .top, spacing: 8) {
1488                VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1489                    Text(row.label)
1490                        .font(appDensity.font(.caption2))
1491                        .foregroundStyle(Color(.appTextSecondary))
1492                    HStack(alignment: .firstTextBaseline, spacing: 4) {
1493                        if differentiateWithoutColor, let symbol = toneSymbol {
1494                            Image(systemName: symbol)
1495                                .font(appDensity.font(.caption2))
1496                                .foregroundStyle(ResultColors.color(for: row.tone))
1497                                .accessibilityHidden(true)
1498                        }
1499                        valueText
1500                    }
1501                }
1502                .frame(maxWidth: .infinity, alignment: .leading)
1503                .layoutPriority(1)
1504                Spacer(minLength: 6)
1505                if !row.value.isEmpty, row.value != "Unavailable" {
1506                    AppCopyButton(value: row.value, label: "Copy \(row.label)")
1507                }
1508            }
1509        }
1510        .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
1511    }
1512
1513    /// A leading symbol for warning/failure tones, shown only under Differentiate
1514    /// Without Color so tone is not conveyed by text colour alone. Hidden from
1515    /// VoiceOver  the value text already carries the meaning.
1516    private var toneSymbol: String? {
1517        switch row.tone {
1518        case .warning: return "exclamationmark.triangle.fill"
1519        case .failure: return "xmark.octagon.fill"
1520        default: return nil
1521        }
1522    }
1523
1524    @ViewBuilder
1525    private var valueText: some View {
1526        let base = Text(row.value)
1527            .font(appDensity.font(.caption))
1528            .foregroundStyle(ResultColors.color(for: row.tone))
1529
1530        switch row.speechStyle {
1531        case .plain:
1532            base
1533                .lineLimit(nil)
1534                .fixedSize(horizontal: false, vertical: true)
1535                .textSelection(.enabled)
1536        case .technical:
1537            // Record values and identifiers: keep punctuation audible (SPF/DMARC
1538            // separators are semantically load-bearing) and let VoiceOver use its
1539            // code-reading heuristics.
1540            base
1541                .speechAlwaysIncludesPunctuation()
1542                .accessibilityTextContentType(.sourceCode)
1543                .lineLimit(nil)
1544                .fixedSize(horizontal: false, vertical: true)
1545                .textSelection(.enabled)
1546        }
1547    }
1548}
1549
1550/// Maps a row's semantic tone onto the app palette.
1551///
1552/// See `Docs/ACCESSIBILITY.md` for the measured contrast ratios behind these
1553/// colours. Never reach for a literal (`Color(.statusCritical)`, `Color(.statusWarning)`, )  the system
1554/// palette fails WCAG AA badly in light mode (systemYellow is 1.28:1 on white).
1555enum ResultColors {
1556    static func color(for tone: ResultTone) -> Color {
1557        switch tone {
1558        case .primary:
1559            return .primary
1560        case .secondary:
1561            return .secondary
1562        case .success:
1563            return Color(.statusPositive)
1564        case .warning:
1565            return Color(.statusWarning)
1566        case .failure:
1567            return Color(.statusCritical)
1568        }
1569    }
1570}
1571
1572extension DateFormatter {
1573    static let certDate: DateFormatter = {
1574        let formatter = DateFormatter()
1575        formatter.dateStyle = .medium
1576        formatter.timeStyle = .short
1577        return formatter
1578    }()
1579}
1580
1581extension View {
1582    func appLoadingStyle() -> some View {
1583        font(.system(.caption, design: .monospaced))
1584    }
1585}
1586
1587private extension String {
1588    var nilIfEmpty: String? {
1589        isEmpty ? nil : self
1590    }
1591}
1592
1593extension ChangeImpactClassification {
1594    var tone: AppStatusTone {
1595        switch self {
1596        case .informational: return .neutral
1597        case .warning: return .warning
1598        case .critical: return .critical
1599        }
1600    }
1601}
1602
1603extension TLSGrade {
1604    var tone: ResultTone {
1605        switch self {
1606        case .a: return .success
1607        case .f: return .failure
1608        default: return .warning
1609        }
1610    }
1611}
1612
1613extension EmailSecurityGrade {
1614    var tone: ResultTone {
1615        switch self {
1616        case .a: return .success
1617        case .f: return .failure
1618        default: return .warning
1619        }
1620    }
1621}
1622
1623#Preview {
1624    ContentView(viewModel: DomainViewModel())
1625}