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/ContentView.swift | |
| 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/ContentView.swift')
| -rw-r--r-- | DomainDig/ContentView.swift | 51 |
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. |
