From dc479a3ba0d27fe86509d0eb2d27b01132c2b37f Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Tue, 21 Jul 2026 20:00:46 -0500 Subject: feat(a11y): VoiceOver labels, dense-row rotor content, announcements (#21 phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DomainDig/DomainViewModel.swift | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) (limited to 'DomainDig/DomainViewModel.swift') 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)) -- cgit v1.2.3