diff options
| author | Christian Cleberg <[email protected]> | 2026-07-21 20:00:46 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-21 20:38:45 -0500 |
| commit | dc479a3ba0d27fe86509d0eb2d27b01132c2b37f (patch) | |
| tree | 4cc69b5fff2895e2ef794354fcdf89d56fa80e27 /DomainDig | |
| parent | e2da09fec3d3d52ac6108b9256b2419405f76dd3 (diff) | |
| download | domain-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.swift | 60 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 51 | ||||
| -rw-r--r-- | DomainDig/DashboardView.swift | 1 | ||||
| -rw-r--r-- | DomainDig/DomainDig/AuditViews.swift | 3 | ||||
| -rw-r--r-- | DomainDig/DomainDigUI.swift | 25 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 22 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 1 | ||||
| -rw-r--r-- | DomainDig/TimelineView.swift | 1 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 31 | ||||
| -rw-r--r-- | DomainDig/WorkflowsView.swift | 5 |
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 : []) } } |
