summaryrefslogtreecommitdiff
path: root/DomainDig/ContentView.swift
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/ContentView.swift
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/ContentView.swift')
-rw-r--r--DomainDig/ContentView.swift51
1 files changed, 45 insertions, 6 deletions
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.