summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-21 20:00:46 -0500
committerChristian Cleberg <[email protected]>2026-07-21 20:38:45 -0500
commitdc479a3ba0d27fe86509d0eb2d27b01132c2b37f (patch)
tree4cc69b5fff2895e2ef794354fcdf89d56fa80e27 /DomainDig
parente2da09fec3d3d52ac6108b9256b2419405f76dd3 (diff)
downloaddomain-dig-dc479a3ba0d27fe86509d0eb2d27b01132c2b37f.tar.gz
domain-dig-dc479a3ba0d27fe86509d0eb2d27b01132c2b37f.tar.bz2
domain-dig-dc479a3ba0d27fe86509d0eb2d27b01132c2b37f.zip
feat(a11y): VoiceOver labels, dense-row rotor content, announcements (#21 phase 4)
The audit count is unchanged at 11 dark, and that is the expected result: performAccessibilityAudit validates descriptions, traits, contrast, hit regions, and clipping, but exercises none of VoiceOver's speech, the More Content rotor, custom-content ordering, or announcements — which is the entire substance of this phase. It is verified by construction and stays green with no regressions; the manual VoiceOver pass is Phase 6. Icon-only controls (~14) get accessibilityLabel, obeying label-in-name: where a control has visible text the label keeps it, so Voice Control still works. The pin and bookmark toggles gain accessibilityValue and .isSelected; the audit and workflow checkboxes gain .isSelected and a hint. Decorative icons split out of Labels are hidden. AppStatusBadgeView now reads as one word ("Critical"), not "icon, Critical", via children: .ignore + label. SectionTitleView and CollapsibleSectionView headers get the .isHeader trait for rotor navigation; the collapsible header also exposes expanded/collapsed as a value with a hint. The header deliberately does NOT use children: .combine — its trailing() closure can hold Track/Pin controls, and combining would swallow them. Dense rows use combine-for-summary, custom-content-for-detail. BatchResultRowView (8 elements) and WatchlistRowView (up to 9) become a single element — domain as label, status as value — with risk, IP, timestamp, source, certificate, and monitoring on the More Content rotor, risk and certificate at .high importance. Reading all of it inline would make a long sweep unnavigable. The custom-content chains live in ViewModifiers because inlining six of them plus the layout broke the type-checker. The shorter 3-4 element portfolio rows are left to NavigationLink's automatic combine, per WWDC21-10121. Technical strings get a speechStyle field on InfoRowViewData: .technical applies speechAlwaysIncludesPunctuation and accessibilityTextContentType(.sourceCode), set on DNS record values and cipher suites so load-bearing punctuation is not swallowed. Completion announcements: the sweep posts from the view model; the single lookup posts from an onChange in the view, since resultsLoaded is derived from many loading flags and has no single view-model moment. Widget: each domain row was a silent 8pt status dot plus a bare "12d" countdown. Rows now read as one phrase ("example.com, critical, certificate expires in 12 days"); the count pills are labelled. Not verifiable by the suite: the dense rows and the widget never render in the audit (no tracked domains or batch results in the test simulator), same limit as the deferred Phase 3 row reflow. Documented in Docs/ACCESSIBILITY.md.
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/BatchResultsView.swift60
-rw-r--r--DomainDig/ContentView.swift51
-rw-r--r--DomainDig/DashboardView.swift1
-rw-r--r--DomainDig/DomainDig/AuditViews.swift3
-rw-r--r--DomainDig/DomainDigUI.swift25
-rw-r--r--DomainDig/DomainViewModel.swift22
-rw-r--r--DomainDig/HistoryView.swift1
-rw-r--r--DomainDig/TimelineView.swift1
-rw-r--r--DomainDig/WatchlistView.swift31
-rw-r--r--DomainDig/WorkflowsView.swift5
10 files changed, 191 insertions, 9 deletions
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift
index 4b4f6fd..aeae48f 100644
--- a/DomainDig/BatchResultsView.swift
+++ b/DomainDig/BatchResultsView.swift
@@ -116,6 +116,39 @@ struct BatchResultRowView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
.frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
+ // One VoiceOver stop per row: domain as the label, status as the value,
+ // everything else on the More Content rotor. Reading all eight text
+ // elements inline would make a 200-domain sweep unnavigable. `.high`
+ // importance is spoken without the rotor; the rest waits for a swipe.
+ // Extracted to a modifier — inlined, the chain broke the type-checker.
+ .modifier(BatchRowAccessibility(
+ domain: result.domain,
+ status: "\(quickStatusBadge.title), \(availabilityText)",
+ risk: riskDescription,
+ ip: result.primaryIP ?? "none",
+ checked: result.timestamp.formatted(date: .abbreviated, time: .shortened),
+ source: result.resultSource.label,
+ changeLabel: changeContentLabel,
+ changeValue: changeContentValue
+ ))
+ }
+
+ private var changeContentLabel: String {
+ result.changeClassification != nil ? "Impact" : "Status"
+ }
+
+ private var changeContentValue: String {
+ if let change = result.changeClassification {
+ return change.title
+ }
+ return result.errorMessage ?? result.summaryMessage ?? quickStatusBadge.title
+ }
+
+ private var riskDescription: String {
+ if let score = result.riskScore, let level = result.riskLevel {
+ return "\(score), \(level.title)"
+ }
+ return "not scored"
}
private var availabilityText: String {
@@ -164,3 +197,30 @@ struct BatchResultRowView: View {
}
}
}
+
+/// Row-level VoiceOver treatment for a batch result: a single element whose
+/// label is the domain and whose value is the status, with the remaining fields
+/// on the More Content rotor. Extracted from the row body because inlining the
+/// full modifier chain broke Swift's type-checker.
+private struct BatchRowAccessibility: ViewModifier {
+ let domain: String
+ let status: String
+ let risk: String
+ let ip: String
+ let checked: String
+ let source: String
+ let changeLabel: String
+ let changeValue: String
+
+ func body(content: Content) -> some View {
+ content
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(domain)
+ .accessibilityValue(status)
+ .accessibilityCustomContent("Risk", risk, importance: .high)
+ .accessibilityCustomContent("IP address", ip)
+ .accessibilityCustomContent("Checked", checked)
+ .accessibilityCustomContent("Source", source)
+ .accessibilityCustomContent(LocalizedStringResource(stringLiteral: changeLabel), changeValue)
+ }
+}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index a4800ed..bc159d9 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -182,6 +182,7 @@ struct ContentView: View {
Image(systemName: "xmark.circle")
.foregroundStyle(Color(.appTextSecondary))
}
+ .accessibilityLabel("Clear results")
}
}
}
@@ -195,6 +196,15 @@ struct ContentView: View {
.onChange(of: viewModel.searchedDomain) { _, _ in
collapsedSections = defaultCollapsedSections
}
+ .onChange(of: viewModel.resultsLoaded) { wasLoaded, isLoaded in
+ // Single-lookup completion has no single view-model moment
+ // (`resultsLoaded` is derived from many loading flags), so the
+ // announcement is posted from the view where the transition is
+ // observable. The batch path announces from the view model directly.
+ guard !wasLoaded, isLoaded, viewModel.hasRun else { return }
+ let summary = AppStatusFactory.availability(viewModel.availabilityResult?.status).title
+ AppAccessibility.announce("Lookup complete for \(viewModel.searchedDomain). \(summary).")
+ }
.onChange(of: viewModel.rerunNavigationToken) { _, _ in
navigationPath = NavigationPath()
focusedInputField = nil
@@ -544,6 +554,7 @@ struct ContentView: View {
.font(appDensity.font(.body, design: .default))
.foregroundStyle(Color(.appTextSecondary))
}
+ .accessibilityLabel("Actions")
Button {
viewModel.toggleSavedDomain()
} label: {
@@ -551,6 +562,9 @@ struct ContentView: View {
.font(appDensity.font(.body, design: .default))
.foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary)
}
+ .accessibilityLabel("Save domain")
+ .accessibilityValue(viewModel.isCurrentDomainSaved ? "Saved" : "Not saved")
+ .accessibilityAddTraits(viewModel.isCurrentDomainSaved ? .isSelected : [])
Menu {
Button("Export TXT") {
shareSingleResults(format: .text)
@@ -583,6 +597,7 @@ struct ContentView: View {
.font(appDensity.font(.body, design: .default))
.foregroundStyle(Color(.appTextSecondary))
}
+ .accessibilityLabel("Export")
}
}
}
@@ -1345,6 +1360,9 @@ struct DomainSectionView: View {
}
.buttonStyle(.bordered)
.font(appDensity.font(.caption))
+ .accessibilityLabel("Pin domain")
+ .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
+ .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
if let onEditNote {
Button("Note") {
onEditNote()
@@ -2415,6 +2433,7 @@ struct SectionTitleView: View {
Text(title)
.font(appDensity.font(.headline, design: .default, weight: .semibold))
.foregroundStyle(.primary)
+ .accessibilityAddTraits(.isHeader)
}
}
@@ -2570,12 +2589,7 @@ struct LabeledValueRow: View {
Text(row.label)
.font(appDensity.font(.caption2))
.foregroundStyle(Color(.appTextSecondary))
- Text(row.value)
- .font(appDensity.font(.caption))
- .foregroundStyle(ResultColors.color(for: row.tone))
- .lineLimit(nil)
- .fixedSize(horizontal: false, vertical: true)
- .textSelection(.enabled)
+ valueText
}
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(1)
@@ -2587,6 +2601,31 @@ struct LabeledValueRow: View {
}
.frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
}
+
+ @ViewBuilder
+ private var valueText: some View {
+ let base = Text(row.value)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(ResultColors.color(for: row.tone))
+
+ switch row.speechStyle {
+ case .plain:
+ base
+ .lineLimit(nil)
+ .fixedSize(horizontal: false, vertical: true)
+ .textSelection(.enabled)
+ case .technical:
+ // Record values and identifiers: keep punctuation audible (SPF/DMARC
+ // separators are semantically load-bearing) and let VoiceOver use its
+ // code-reading heuristics.
+ base
+ .speechAlwaysIncludesPunctuation()
+ .accessibilityTextContentType(.sourceCode)
+ .lineLimit(nil)
+ .fixedSize(horizontal: false, vertical: true)
+ .textSelection(.enabled)
+ }
+ }
}
/// Maps a row's semantic tone onto the app palette.
diff --git a/DomainDig/DashboardView.swift b/DomainDig/DashboardView.swift
index fc202d3..24955bb 100644
--- a/DomainDig/DashboardView.swift
+++ b/DomainDig/DashboardView.swift
@@ -166,6 +166,7 @@ struct DashboardView: View {
} label: {
Image(systemName: "arrow.clockwise")
}
+ .accessibilityLabel("Refresh all tracked domains")
.disabled(viewModel.batchLookupRunning)
}
}
diff --git a/DomainDig/DomainDig/AuditViews.swift b/DomainDig/DomainDig/AuditViews.swift
index 4e57837..90e64f8 100644
--- a/DomainDig/DomainDig/AuditViews.swift
+++ b/DomainDig/DomainDig/AuditViews.swift
@@ -261,6 +261,8 @@ struct AuditSessionDetailView: View {
}
}
.buttonStyle(.plain)
+ .accessibilityAddTraits(item.isComplete ? .isSelected : [])
+ .accessibilityHint(item.isComplete ? "Marks incomplete" : "Marks complete")
}
}
.listRowBackground(Color(.appSurface))
@@ -479,6 +481,7 @@ private struct AuditFindingEditorView: View {
}
}
.buttonStyle(.plain)
+ .accessibilityAddTraits(selectedAreas.contains(area) ? .isSelected : [])
}
}
diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift
index bf06b46..de93a56 100644
--- a/DomainDig/DomainDigUI.swift
+++ b/DomainDig/DomainDigUI.swift
@@ -251,6 +251,10 @@ struct AppStatusBadgeView: View {
.padding(.vertical, 5)
.background(model.backgroundColor)
.clipShape(Capsule())
+ // Read as one word ("Critical"), not "icon, Critical". The symbol
+ // duplicates the title for VoiceOver.
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(model.title)
}
}
@@ -305,6 +309,19 @@ enum AppClipboard {
}
}
+enum AppAccessibility {
+ /// Speaks a status update through VoiceOver without moving focus. Used at
+ /// lookup and sweep completion so a blind user hears the result land instead
+ /// of having to hunt for whether anything changed.
+ static func announce(_ message: String) {
+ #if canImport(UIKit)
+ var announcement = AttributedString(message)
+ announcement.accessibilitySpeechAnnouncementPriority = .high
+ AccessibilityNotification.Announcement(announcement).post()
+ #endif
+ }
+}
+
enum AppHaptics {
static func copy() {
#if canImport(UIKit)
@@ -438,11 +455,19 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.appTextSecondary))
+ .accessibilityHidden(true)
}
.contentShape(Rectangle())
.frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center)
}
.buttonStyle(.plain)
+ // A header that is also the expand/collapse control. The chevron is
+ // decorative; state and hint carry it to VoiceOver instead. No
+ // `children: .combine` here — `trailing()` may hold its own controls
+ // (Track, Pin), and combining would swallow them into the header.
+ .accessibilityAddTraits(.isHeader)
+ .accessibilityValue(isCollapsed ? "Collapsed" : "Expanded")
+ .accessibilityHint(isCollapsed ? "Expands the section" : "Collapses the section")
if !isCollapsed {
content()
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index f7d1d7e..de17cbc 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -17,11 +17,24 @@ struct SummaryFieldViewData: Identifiable {
let tone: ResultTone
}
+/// How VoiceOver should pronounce a row's value.
+///
+/// DNS records, cipher suites, and the like are read as prose by default, which
+/// mangles load-bearing punctuation (`;`, `~`, `_`) and technical tokens. See
+/// `Docs/ACCESSIBILITY.md`.
+enum RowSpeechStyle {
+ /// Normal prose.
+ case plain
+ /// Record values and identifiers: include punctuation, use code heuristics.
+ case technical
+}
+
struct InfoRowViewData: Identifiable {
let id = UUID()
let label: String
let value: String
let tone: ResultTone
+ var speechStyle: RowSpeechStyle = .plain
}
struct SectionMessageViewData {
@@ -3036,6 +3049,9 @@ final class DomainViewModel {
)
latestBatchSweepSummary = summary
SweepActivityController.shared.end(changed: changedCount, warnings: warningCount)
+ AppAccessibility.announce(
+ "Sweep complete. \(summary.results.count) domains, \(changedCount) changed, \(warningCount) warnings."
+ )
if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
let workflowReports: [DomainReport] = summary.results.compactMap { result in
@@ -4088,8 +4104,8 @@ final class DomainViewModel {
snapshot.dnsSections.map { section in
DNSRecordSectionViewData(
title: section.recordType.rawValue,
- rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
- wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
+ rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
+ wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
@@ -4126,7 +4142,7 @@ final class DomainViewModel {
rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
}
if let cipherSuite = sslInfo.cipherSuite {
- rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary))
+ rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary, speechStyle: .technical))
}
if let hstsPreloaded = snapshot.hstsPreloaded {
rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index f291c81..5f4fe94 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -106,6 +106,7 @@ struct HistoryView: View {
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
+ .accessibilityLabel("Filter")
EditButton()
}
diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift
index 5352a94..9b79adf 100644
--- a/DomainDig/TimelineView.swift
+++ b/DomainDig/TimelineView.swift
@@ -63,6 +63,7 @@ struct TimelineView: View {
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
+ .accessibilityLabel("Group timeline")
Button("Compare") {
if viewModel.selectedSnapshots.count == 2 {
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index dda97ad..72eb04e 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -106,6 +106,7 @@ struct WatchlistView: View {
} label: {
Image(systemName: "plus")
}
+ .accessibilityLabel("Add domain")
if !viewModel.filteredTrackedDomains.isEmpty {
Menu {
@@ -181,6 +182,7 @@ struct WatchlistView: View {
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
+ .accessibilityLabel("Filter and sort")
EditButton()
}
@@ -485,6 +487,7 @@ struct WatchlistRowView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
+ .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing))
}
private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
@@ -542,6 +545,34 @@ struct WatchlistRowView: View {
}
}
+/// Row-level VoiceOver treatment for a tracked domain: domain as label,
+/// availability as value, the rest on the More Content rotor. Same rationale as
+/// the batch row — up to nine text elements would be one unnavigable utterance.
+private struct WatchlistRowAccessibility: ViewModifier {
+ let trackedDomain: TrackedDomain
+ let isRefreshing: Bool
+
+ func body(content: Content) -> some View {
+ content
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(trackedDomain.domain)
+ .accessibilityValue(isRefreshing ? "Refreshing" : AppStatusFactory.availability(trackedDomain.lastKnownAvailability).title)
+ .accessibilityCustomContent("Certificate", certificateContent, importance: .high)
+ .accessibilityCustomContent("Monitoring", trackedDomain.monitoringEnabled ? "on" : "off")
+ .accessibilityCustomContent("Updated", trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))
+ .accessibilityCustomContent("Pinned", trackedDomain.isPinned ? "yes" : "no")
+ }
+
+ private var certificateContent: String {
+ let days = trackedDomain.certificateDaysRemaining.map { "\($0) days" } ?? "unknown"
+ switch trackedDomain.certificateWarningLevel {
+ case .critical: return "invalid, \(days)"
+ case .warning: return "expiring, \(days)"
+ case .none: return "valid"
+ }
+ }
+}
+
struct TrackedDomainDetailView: View {
@Bindable var viewModel: DomainViewModel
let trackedDomain: TrackedDomain
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
index d628ba4..a6799e5 100644
--- a/DomainDig/WorkflowsView.swift
+++ b/DomainDig/WorkflowsView.swift
@@ -42,6 +42,7 @@ struct WorkflowsView: View {
} label: {
Image(systemName: "plus.circle")
}
+ .accessibilityLabel("Create workflow")
if !viewModel.workflows.isEmpty {
EditButton()
@@ -133,6 +134,7 @@ private struct WorkflowRowView: View {
Image(systemName: "person.2.fill")
.font(.caption2)
.foregroundStyle(Color(.statusInfo))
+ .accessibilityLabel("Shared")
}
Text(workflow.name)
.font(appDensity.font(.callout, design: .default, weight: .semibold))
@@ -563,6 +565,7 @@ struct WorkflowRunSummaryView: View {
} label: {
Image(systemName: "square.and.arrow.up")
}
+ .accessibilityLabel("Export summary")
if let workflow = viewModel.workflow(withID: summary.workflowID) {
Button {
@@ -570,6 +573,7 @@ struct WorkflowRunSummaryView: View {
} label: {
Image(systemName: "arrow.clockwise")
}
+ .accessibilityLabel("Re-run workflow")
.disabled(viewModel.batchLookupRunning)
}
}
@@ -640,6 +644,7 @@ struct WorkflowBulkAddSheet: View {
}
}
.buttonStyle(.plain)
+ .accessibilityAddTraits(selectedDomains.contains(domain) ? .isSelected : [])
}
}