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/ContentView.swift | 51 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 6 deletions(-) (limited to 'DomainDig/ContentView.swift') 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. -- cgit v1.2.3