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/WatchlistView.swift | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) (limited to 'DomainDig/WatchlistView.swift') 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 -- cgit v1.2.3