Commit dc479a3ba0

dc479a3ba0d27fe86509d0eb2d27b01132c2b37f

parent: e2da09fec3

Unsigned

cmc <hello@cleberg.net> · 2026-07-22 01:00 UTC

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.

Layout: unified · split

Docs/ACCESSIBILITY.md +43
@@ -208,6 +208,49 @@ pre-commit that blocks every commit. A hook routinely bypassed with
208 needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for 208 needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for
209 users who prefer smaller text. 209 users who prefer smaller text.
210 210
211## VoiceOver conventions
212
213- **Dense rows use combine-for-summary, custom-content-for-detail.**
214 `BatchResultRowView` and `WatchlistRowView` each hold 8–9 text elements.
215 Reading them inline makes a long sweep unnavigable, so each row is one element:
216 `.accessibilityElement(children: .ignore)` + domain label + status value, with
217 the rest on `.accessibilityCustomContent(...)`. `.high` importance is spoken
218 inline; everything else reaches the More Content rotor on a vertical swipe.
219 Rows with only 3–4 elements (the portfolio activity/attention/expiry rows) are
220 left to `NavigationLink`'s automatic combine — custom content is for the dense
221 case, per WWDC21-10121.
222- **The custom-content chain must live in a `ViewModifier`.** Inlined onto a row
223 body, six `.accessibilityCustomContent` calls plus the visual layout blow the
224 Swift type-checker's budget ("unable to type-check in reasonable time").
225 `BatchRowAccessibility` / `WatchlistRowAccessibility` exist for that reason.
226- **Splitting a `Label` exposes its icon; combining a header swallows its
227 trailing controls.** Two opposite traps. A decorative icon pulled out of a
228 `Label` needs `.accessibilityHidden(true)`. A header built as a `Button` must
229 *not* get `.accessibilityElement(children: .combine)` if its label contains
230 other controls (`CollapsibleSectionView`'s `trailing()` holds Track/Pin) —
231 combine would merge them into the header and make them unreachable.
232- **Label-in-name (WCAG 2.5.3).** Every `accessibilityLabel` added to a control
233 with visible text keeps that text, so Voice Control still works. Free-form
234 labels are used only where the control is genuinely icon-only.
235- **Technical strings** get `speechStyle: .technical` on `InfoRowViewData`, which
236 applies `.speechAlwaysIncludesPunctuation()` and
237 `.accessibilityTextContentType(.sourceCode)`. Set today on DNS record values
238 and cipher suites; extend it wherever the view model emits a fingerprint,
239 serial, or record string.
240
241### What the automated audit cannot check
242
243`performAccessibilityAudit()` validates descriptions, traits, contrast, hit
244regions, and clipping. It does **not** exercise VoiceOver speech, the More
245Content rotor, custom-content ordering, or announcements. Those are verified by
246construction and a manual VoiceOver pass (Phase 6), not by the suite. A green
247audit is necessary, not sufficient, for the row and speech work.
248
249Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the
250widget never render in the audit — the test simulator has no tracked domains or
251batch results. Their treatment is unverified by the suite for the same reason the
252Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data.
253
211## Notes 254## Notes
212 255
213- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 256- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3
DomainDig/BatchResultsView.swift +60
@@ -116,6 +116,39 @@ struct BatchResultRowView: View {
116 .frame(maxWidth: .infinity, alignment: .leading) 116 .frame(maxWidth: .infinity, alignment: .leading)
117 .padding(.vertical, 4) 117 .padding(.vertical, 4)
118 .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading) 118 .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
119 // One VoiceOver stop per row: domain as the label, status as the value,
120 // everything else on the More Content rotor. Reading all eight text
121 // elements inline would make a 200-domain sweep unnavigable. `.high`
122 // importance is spoken without the rotor; the rest waits for a swipe.
123 // Extracted to a modifier — inlined, the chain broke the type-checker.
124 .modifier(BatchRowAccessibility(
125 domain: result.domain,
126 status: "\(quickStatusBadge.title), \(availabilityText)",
127 risk: riskDescription,
128 ip: result.primaryIP ?? "none",
129 checked: result.timestamp.formatted(date: .abbreviated, time: .shortened),
130 source: result.resultSource.label,
131 changeLabel: changeContentLabel,
132 changeValue: changeContentValue
133 ))
134 }
135
136 private var changeContentLabel: String {
137 result.changeClassification != nil ? "Impact" : "Status"
138 }
139
140 private var changeContentValue: String {
141 if let change = result.changeClassification {
142 return change.title
143 }
144 return result.errorMessage ?? result.summaryMessage ?? quickStatusBadge.title
145 }
146
147 private var riskDescription: String {
148 if let score = result.riskScore, let level = result.riskLevel {
149 return "\(score), \(level.title)"
150 }
151 return "not scored"
119 } 152 }
120 153
121 private var availabilityText: String { 154 private var availabilityText: String {
@@ -164,3 +197,30 @@ struct BatchResultRowView: View {
164 } 197 }
165 } 198 }
166} 199}
200
201/// Row-level VoiceOver treatment for a batch result: a single element whose
202/// label is the domain and whose value is the status, with the remaining fields
203/// on the More Content rotor. Extracted from the row body because inlining the
204/// full modifier chain broke Swift's type-checker.
205private struct BatchRowAccessibility: ViewModifier {
206 let domain: String
207 let status: String
208 let risk: String
209 let ip: String
210 let checked: String
211 let source: String
212 let changeLabel: String
213 let changeValue: String
214
215 func body(content: Content) -> some View {
216 content
217 .accessibilityElement(children: .ignore)
218 .accessibilityLabel(domain)
219 .accessibilityValue(status)
220 .accessibilityCustomContent("Risk", risk, importance: .high)
221 .accessibilityCustomContent("IP address", ip)
222 .accessibilityCustomContent("Checked", checked)
223 .accessibilityCustomContent("Source", source)
224 .accessibilityCustomContent(LocalizedStringResource(stringLiteral: changeLabel), changeValue)
225 }
226}
DomainDig/ContentView.swift +45 −6
@@ -182,6 +182,7 @@ struct ContentView: View {
182 Image(systemName: "xmark.circle") 182 Image(systemName: "xmark.circle")
183 .foregroundStyle(Color(.appTextSecondary)) 183 .foregroundStyle(Color(.appTextSecondary))
184 } 184 }
185 .accessibilityLabel("Clear results")
185 } 186 }
186 } 187 }
187 } 188 }
@@ -195,6 +196,15 @@ struct ContentView: View {
195 .onChange(of: viewModel.searchedDomain) { _, _ in 196 .onChange(of: viewModel.searchedDomain) { _, _ in
196 collapsedSections = defaultCollapsedSections 197 collapsedSections = defaultCollapsedSections
197 } 198 }
199 .onChange(of: viewModel.resultsLoaded) { wasLoaded, isLoaded in
200 // Single-lookup completion has no single view-model moment
201 // (`resultsLoaded` is derived from many loading flags), so the
202 // announcement is posted from the view where the transition is
203 // observable. The batch path announces from the view model directly.
204 guard !wasLoaded, isLoaded, viewModel.hasRun else { return }
205 let summary = AppStatusFactory.availability(viewModel.availabilityResult?.status).title
206 AppAccessibility.announce("Lookup complete for \(viewModel.searchedDomain). \(summary).")
207 }
198 .onChange(of: viewModel.rerunNavigationToken) { _, _ in 208 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
199 navigationPath = NavigationPath() 209 navigationPath = NavigationPath()
200 focusedInputField = nil 210 focusedInputField = nil
@@ -544,6 +554,7 @@ struct ContentView: View {
544 .font(appDensity.font(.body, design: .default)) 554 .font(appDensity.font(.body, design: .default))
545 .foregroundStyle(Color(.appTextSecondary)) 555 .foregroundStyle(Color(.appTextSecondary))
546 } 556 }
557 .accessibilityLabel("Actions")
547 Button { 558 Button {
548 viewModel.toggleSavedDomain() 559 viewModel.toggleSavedDomain()
549 } label: { 560 } label: {
@@ -551,6 +562,9 @@ struct ContentView: View {
551 .font(appDensity.font(.body, design: .default)) 562 .font(appDensity.font(.body, design: .default))
552 .foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary) 563 .foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary)
553 } 564 }
565 .accessibilityLabel("Save domain")
566 .accessibilityValue(viewModel.isCurrentDomainSaved ? "Saved" : "Not saved")
567 .accessibilityAddTraits(viewModel.isCurrentDomainSaved ? .isSelected : [])
554 Menu { 568 Menu {
555 Button("Export TXT") { 569 Button("Export TXT") {
556 shareSingleResults(format: .text) 570 shareSingleResults(format: .text)
@@ -583,6 +597,7 @@ struct ContentView: View {
583 .font(appDensity.font(.body, design: .default)) 597 .font(appDensity.font(.body, design: .default))
584 .foregroundStyle(Color(.appTextSecondary)) 598 .foregroundStyle(Color(.appTextSecondary))
585 } 599 }
600 .accessibilityLabel("Export")
586 } 601 }
587 } 602 }
588 } 603 }
@@ -1345,6 +1360,9 @@ struct DomainSectionView: View {
1345 } 1360 }
1346 .buttonStyle(.bordered) 1361 .buttonStyle(.bordered)
1347 .font(appDensity.font(.caption)) 1362 .font(appDensity.font(.caption))
1363 .accessibilityLabel("Pin domain")
1364 .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
1365 .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
1348 if let onEditNote { 1366 if let onEditNote {
1349 Button("Note") { 1367 Button("Note") {
1350 onEditNote() 1368 onEditNote()
@@ -2415,6 +2433,7 @@ struct SectionTitleView: View {
2415 Text(title) 2433 Text(title)
2416 .font(appDensity.font(.headline, design: .default, weight: .semibold)) 2434 .font(appDensity.font(.headline, design: .default, weight: .semibold))
2417 .foregroundStyle(.primary) 2435 .foregroundStyle(.primary)
2436 .accessibilityAddTraits(.isHeader)
2418 } 2437 }
2419} 2438}
2420 2439
@@ -2570,12 +2589,7 @@ struct LabeledValueRow: View {
2570 Text(row.label) 2589 Text(row.label)
2571 .font(appDensity.font(.caption2)) 2590 .font(appDensity.font(.caption2))
2572 .foregroundStyle(Color(.appTextSecondary)) 2591 .foregroundStyle(Color(.appTextSecondary))
2573 Text(row.value) 2592 valueText
2574 .font(appDensity.font(.caption))
2575 .foregroundStyle(ResultColors.color(for: row.tone))
2576 .lineLimit(nil)
2577 .fixedSize(horizontal: false, vertical: true)
2578 .textSelection(.enabled)
2579 } 2593 }
2580 .frame(maxWidth: .infinity, alignment: .leading) 2594 .frame(maxWidth: .infinity, alignment: .leading)
2581 .layoutPriority(1) 2595 .layoutPriority(1)
@@ -2587,6 +2601,31 @@ struct LabeledValueRow: View {
2587 } 2601 }
2588 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) 2602 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
2589 } 2603 }
2604
2605 @ViewBuilder
2606 private var valueText: some View {
2607 let base = Text(row.value)
2608 .font(appDensity.font(.caption))
2609 .foregroundStyle(ResultColors.color(for: row.tone))
2610
2611 switch row.speechStyle {
2612 case .plain:
2613 base
2614 .lineLimit(nil)
2615 .fixedSize(horizontal: false, vertical: true)
2616 .textSelection(.enabled)
2617 case .technical:
2618 // Record values and identifiers: keep punctuation audible (SPF/DMARC
2619 // separators are semantically load-bearing) and let VoiceOver use its
2620 // code-reading heuristics.
2621 base
2622 .speechAlwaysIncludesPunctuation()
2623 .accessibilityTextContentType(.sourceCode)
2624 .lineLimit(nil)
2625 .fixedSize(horizontal: false, vertical: true)
2626 .textSelection(.enabled)
2627 }
2628 }
2590} 2629}
2591 2630
2592/// Maps a row's semantic tone onto the app palette. 2631/// Maps a row's semantic tone onto the app palette.
DomainDig/DashboardView.swift +1
@@ -166,6 +166,7 @@ struct DashboardView: View {
166 } label: { 166 } label: {
167 Image(systemName: "arrow.clockwise") 167 Image(systemName: "arrow.clockwise")
168 } 168 }
169 .accessibilityLabel("Refresh all tracked domains")
169 .disabled(viewModel.batchLookupRunning) 170 .disabled(viewModel.batchLookupRunning)
170 } 171 }
171 } 172 }
DomainDig/DomainDig/AuditViews.swift +3
@@ -261,6 +261,8 @@ struct AuditSessionDetailView: View {
261 } 261 }
262 } 262 }
263 .buttonStyle(.plain) 263 .buttonStyle(.plain)
264 .accessibilityAddTraits(item.isComplete ? .isSelected : [])
265 .accessibilityHint(item.isComplete ? "Marks incomplete" : "Marks complete")
264 } 266 }
265 } 267 }
266 .listRowBackground(Color(.appSurface)) 268 .listRowBackground(Color(.appSurface))
@@ -479,6 +481,7 @@ private struct AuditFindingEditorView: View {
479 } 481 }
480 } 482 }
481 .buttonStyle(.plain) 483 .buttonStyle(.plain)
484 .accessibilityAddTraits(selectedAreas.contains(area) ? .isSelected : [])
482 } 485 }
483 } 486 }
484 487
DomainDig/DomainDigUI.swift +25
@@ -251,6 +251,10 @@ struct AppStatusBadgeView: View {
251 .padding(.vertical, 5) 251 .padding(.vertical, 5)
252 .background(model.backgroundColor) 252 .background(model.backgroundColor)
253 .clipShape(Capsule()) 253 .clipShape(Capsule())
254 // Read as one word ("Critical"), not "icon, Critical". The symbol
255 // duplicates the title for VoiceOver.
256 .accessibilityElement(children: .ignore)
257 .accessibilityLabel(model.title)
254 } 258 }
255} 259}
256 260
@@ -305,6 +309,19 @@ enum AppClipboard {
305 } 309 }
306} 310}
307 311
312enum AppAccessibility {
313 /// Speaks a status update through VoiceOver without moving focus. Used at
314 /// lookup and sweep completion so a blind user hears the result land instead
315 /// of having to hunt for whether anything changed.
316 static func announce(_ message: String) {
317 #if canImport(UIKit)
318 var announcement = AttributedString(message)
319 announcement.accessibilitySpeechAnnouncementPriority = .high
320 AccessibilityNotification.Announcement(announcement).post()
321 #endif
322 }
323}
324
308enum AppHaptics { 325enum AppHaptics {
309 static func copy() { 326 static func copy() {
310 #if canImport(UIKit) 327 #if canImport(UIKit)
@@ -438,11 +455,19 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
438 Image(systemName: isCollapsed ? "chevron.down" : "chevron.up") 455 Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
439 .font(.caption.weight(.semibold)) 456 .font(.caption.weight(.semibold))
440 .foregroundStyle(Color(.appTextSecondary)) 457 .foregroundStyle(Color(.appTextSecondary))
458 .accessibilityHidden(true)
441 } 459 }
442 .contentShape(Rectangle()) 460 .contentShape(Rectangle())
443 .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center) 461 .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center)
444 } 462 }
445 .buttonStyle(.plain) 463 .buttonStyle(.plain)
464 // A header that is also the expand/collapse control. The chevron is
465 // decorative; state and hint carry it to VoiceOver instead. No
466 // `children: .combine` here — `trailing()` may hold its own controls
467 // (Track, Pin), and combining would swallow them into the header.
468 .accessibilityAddTraits(.isHeader)
469 .accessibilityValue(isCollapsed ? "Collapsed" : "Expanded")
470 .accessibilityHint(isCollapsed ? "Expands the section" : "Collapses the section")
446 471
447 if !isCollapsed { 472 if !isCollapsed {
448 content() 473 content()
DomainDig/DomainViewModel.swift +19 −3
@@ -17,11 +17,24 @@ struct SummaryFieldViewData: Identifiable {
17 let tone: ResultTone 17 let tone: ResultTone
18} 18}
19 19
20/// How VoiceOver should pronounce a row's value.
21///
22/// DNS records, cipher suites, and the like are read as prose by default, which
23/// mangles load-bearing punctuation (`;`, `~`, `_`) and technical tokens. See
24/// `Docs/ACCESSIBILITY.md`.
25enum RowSpeechStyle {
26 /// Normal prose.
27 case plain
28 /// Record values and identifiers: include punctuation, use code heuristics.
29 case technical
30}
31
20struct InfoRowViewData: Identifiable { 32struct InfoRowViewData: Identifiable {
21 let id = UUID() 33 let id = UUID()
22 let label: String 34 let label: String
23 let value: String 35 let value: String
24 let tone: ResultTone 36 let tone: ResultTone
37 var speechStyle: RowSpeechStyle = .plain
25} 38}
26 39
27struct SectionMessageViewData { 40struct SectionMessageViewData {
@@ -3036,6 +3049,9 @@ final class DomainViewModel {
3036 ) 3049 )
3037 latestBatchSweepSummary = summary 3050 latestBatchSweepSummary = summary
3038 SweepActivityController.shared.end(changed: changedCount, warnings: warningCount) 3051 SweepActivityController.shared.end(changed: changedCount, warnings: warningCount)
3052 AppAccessibility.announce(
3053 "Sweep complete. \(summary.results.count) domains, \(changedCount) changed, \(warningCount) warnings."
3054 )
3039 3055
3040 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName { 3056 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
3041 let workflowReports: [DomainReport] = summary.results.compactMap { result in 3057 let workflowReports: [DomainReport] = summary.results.compactMap { result in
@@ -4088,8 +4104,8 @@ final class DomainViewModel {
4088 snapshot.dnsSections.map { section in 4104 snapshot.dnsSections.map { section in
4089 DNSRecordSectionViewData( 4105 DNSRecordSectionViewData(
4090 title: section.recordType.rawValue, 4106 title: section.recordType.rawValue,
4091 rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) }, 4107 rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
4092 wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) }, 4108 wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
4093 wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)", 4109 wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
4094 message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ?? 4110 message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
4095 ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil) 4111 ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
@@ -4126,7 +4142,7 @@ final class DomainViewModel {
4126 rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary)) 4142 rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
4127 } 4143 }
4128 if let cipherSuite = sslInfo.cipherSuite { 4144 if let cipherSuite = sslInfo.cipherSuite {
4129 rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary)) 4145 rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary, speechStyle: .technical))
4130 } 4146 }
4131 if let hstsPreloaded = snapshot.hstsPreloaded { 4147 if let hstsPreloaded = snapshot.hstsPreloaded {
4132 rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary)) 4148 rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
DomainDig/HistoryView.swift +1
@@ -106,6 +106,7 @@ struct HistoryView: View {
106 } label: { 106 } label: {
107 Image(systemName: "line.3.horizontal.decrease.circle") 107 Image(systemName: "line.3.horizontal.decrease.circle")
108 } 108 }
109 .accessibilityLabel("Filter")
109 110
110 EditButton() 111 EditButton()
111 } 112 }
DomainDig/TimelineView.swift +1
@@ -63,6 +63,7 @@ struct TimelineView: View {
63 } label: { 63 } label: {
64 Image(systemName: "line.3.horizontal.decrease.circle") 64 Image(systemName: "line.3.horizontal.decrease.circle")
65 } 65 }
66 .accessibilityLabel("Group timeline")
66 67
67 Button("Compare") { 68 Button("Compare") {
68 if viewModel.selectedSnapshots.count == 2 { 69 if viewModel.selectedSnapshots.count == 2 {
DomainDig/WatchlistView.swift +31
@@ -106,6 +106,7 @@ struct WatchlistView: View {
106 } label: { 106 } label: {
107 Image(systemName: "plus") 107 Image(systemName: "plus")
108 } 108 }
109 .accessibilityLabel("Add domain")
109 110
110 if !viewModel.filteredTrackedDomains.isEmpty { 111 if !viewModel.filteredTrackedDomains.isEmpty {
111 Menu { 112 Menu {
@@ -181,6 +182,7 @@ struct WatchlistView: View {
181 } label: { 182 } label: {
182 Image(systemName: "line.3.horizontal.decrease.circle") 183 Image(systemName: "line.3.horizontal.decrease.circle")
183 } 184 }
185 .accessibilityLabel("Filter and sort")
184 186
185 EditButton() 187 EditButton()
186 } 188 }
@@ -485,6 +487,7 @@ struct WatchlistRowView: View {
485 } 487 }
486 .frame(maxWidth: .infinity, alignment: .leading) 488 .frame(maxWidth: .infinity, alignment: .leading)
487 .padding(.vertical, 4) 489 .padding(.vertical, 4)
490 .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing))
488 } 491 }
489 492
490 private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { 493 private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
@@ -542,6 +545,34 @@ struct WatchlistRowView: View {
542 } 545 }
543} 546}
544 547
548/// Row-level VoiceOver treatment for a tracked domain: domain as label,
549/// availability as value, the rest on the More Content rotor. Same rationale as
550/// the batch row — up to nine text elements would be one unnavigable utterance.
551private struct WatchlistRowAccessibility: ViewModifier {
552 let trackedDomain: TrackedDomain
553 let isRefreshing: Bool
554
555 func body(content: Content) -> some View {
556 content
557 .accessibilityElement(children: .ignore)
558 .accessibilityLabel(trackedDomain.domain)
559 .accessibilityValue(isRefreshing ? "Refreshing" : AppStatusFactory.availability(trackedDomain.lastKnownAvailability).title)
560 .accessibilityCustomContent("Certificate", certificateContent, importance: .high)
561 .accessibilityCustomContent("Monitoring", trackedDomain.monitoringEnabled ? "on" : "off")
562 .accessibilityCustomContent("Updated", trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))
563 .accessibilityCustomContent("Pinned", trackedDomain.isPinned ? "yes" : "no")
564 }
565
566 private var certificateContent: String {
567 let days = trackedDomain.certificateDaysRemaining.map { "\($0) days" } ?? "unknown"
568 switch trackedDomain.certificateWarningLevel {
569 case .critical: return "invalid, \(days)"
570 case .warning: return "expiring, \(days)"
571 case .none: return "valid"
572 }
573 }
574}
575
545struct TrackedDomainDetailView: View { 576struct TrackedDomainDetailView: View {
546 @Bindable var viewModel: DomainViewModel 577 @Bindable var viewModel: DomainViewModel
547 let trackedDomain: TrackedDomain 578 let trackedDomain: TrackedDomain
DomainDig/WorkflowsView.swift +5
@@ -42,6 +42,7 @@ struct WorkflowsView: View {
42 } label: { 42 } label: {
43 Image(systemName: "plus.circle") 43 Image(systemName: "plus.circle")
44 } 44 }
45 .accessibilityLabel("Create workflow")
45 46
46 if !viewModel.workflows.isEmpty { 47 if !viewModel.workflows.isEmpty {
47 EditButton() 48 EditButton()
@@ -133,6 +134,7 @@ private struct WorkflowRowView: View {
133 Image(systemName: "person.2.fill") 134 Image(systemName: "person.2.fill")
134 .font(.caption2) 135 .font(.caption2)
135 .foregroundStyle(Color(.statusInfo)) 136 .foregroundStyle(Color(.statusInfo))
137 .accessibilityLabel("Shared")
136 } 138 }
137 Text(workflow.name) 139 Text(workflow.name)
138 .font(appDensity.font(.callout, design: .default, weight: .semibold)) 140 .font(appDensity.font(.callout, design: .default, weight: .semibold))
@@ -563,6 +565,7 @@ struct WorkflowRunSummaryView: View {
563 } label: { 565 } label: {
564 Image(systemName: "square.and.arrow.up") 566 Image(systemName: "square.and.arrow.up")
565 } 567 }
568 .accessibilityLabel("Export summary")
566 569
567 if let workflow = viewModel.workflow(withID: summary.workflowID) { 570 if let workflow = viewModel.workflow(withID: summary.workflowID) {
568 Button { 571 Button {
@@ -570,6 +573,7 @@ struct WorkflowRunSummaryView: View {
570 } label: { 573 } label: {
571 Image(systemName: "arrow.clockwise") 574 Image(systemName: "arrow.clockwise")
572 } 575 }
576 .accessibilityLabel("Re-run workflow")
573 .disabled(viewModel.batchLookupRunning) 577 .disabled(viewModel.batchLookupRunning)
574 } 578 }
575 } 579 }
@@ -640,6 +644,7 @@ struct WorkflowBulkAddSheet: View {
640 } 644 }
641 } 645 }
642 .buttonStyle(.plain) 646 .buttonStyle(.plain)
647 .accessibilityAddTraits(selectedDomains.contains(domain) ? .isSelected : [])
643 } 648 }
644 } 649 }
645 650
DomainDigWidget/DomainDigPortfolioWidget.swift +32 −4
@@ -97,18 +97,21 @@ struct DomainDigWidgetView: View {
97 Spacer(minLength: 0) 97 Spacer(minLength: 0)
98 98
99 HStack(spacing: 10) { 99 HStack(spacing: 10) {
100 countPill(data.healthyCount, Color(.statusPositive)) 100 countPill(data.healthyCount, Color(.statusPositive), "healthy")
101 countPill(data.warningCount, Color(.statusWarning)) 101 countPill(data.warningCount, Color(.statusWarning), "warning")
102 countPill(data.criticalCount, Color(.statusCritical)) 102 countPill(data.criticalCount, Color(.statusCritical), "critical")
103 } 103 }
104 } 104 }
105 } 105 }
106 106
107 private func countPill(_ value: Int, _ color: Color) -> some View { 107 private func countPill(_ value: Int, _ color: Color, _ label: String) -> some View {
108 HStack(spacing: 3) { 108 HStack(spacing: 3) {
109 Circle().fill(color).frame(width: 7, height: 7) 109 Circle().fill(color).frame(width: 7, height: 7)
110 Text("\(value)").font(.caption).fontWeight(.medium) 110 Text("\(value)").font(.caption).fontWeight(.medium)
111 } 111 }
112 // A coloured dot and a number say nothing on their own.
113 .accessibilityElement(children: .ignore)
114 .accessibilityLabel("\(value) \(label)")
112 } 115 }
113 116
114 // MARK: Medium / Large 117 // MARK: Medium / Large
@@ -176,6 +179,31 @@ struct DomainDigWidgetView: View {
176 .font(.caption2) 179 .font(.caption2)
177 .foregroundStyle(Color(.appTextSecondary)) 180 .foregroundStyle(Color(.appTextSecondary))
178 } 181 }
182 // The status is a silent 8pt dot and the cert countdown is bare ("12d"),
183 // both meaningless to VoiceOver. Collapse the row into one spoken phrase.
184 .accessibilityElement(children: .ignore)
185 .accessibilityLabel(rowAccessibilityLabel(domain))
186 }
187
188 private func rowAccessibilityLabel(_ domain: DomainDigWidgetDomain) -> String {
189 var parts = [domain.domain, statusLabel(domain.status)]
190 if domain.isPinned { parts.append("pinned") }
191 parts.append(certAccessibilityLabel(domain))
192 return parts.joined(separator: ", ")
193 }
194
195 private func statusLabel(_ status: DomainDigWidgetStatus) -> String {
196 switch status {
197 case .healthy: return "healthy"
198 case .warning: return "warning"
199 case .critical: return "critical"
200 }
201 }
202
203 private func certAccessibilityLabel(_ domain: DomainDigWidgetDomain) -> String {
204 guard let days = domain.certDaysRemaining else { return "certificate status unknown" }
205 if days < 0 { return "certificate expired" }
206 return "certificate expires in \(days) day\(days == 1 ? "" : "s")"
179 } 207 }
180 208
181 private func certLabel(for domain: DomainDigWidgetDomain) -> String { 209 private func certLabel(for domain: DomainDigWidgetDomain) -> String {