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
208208 needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for
209209 users who prefer smaller text.
210210
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
211254## Notes
212255
213256- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3
DomainDig/BatchResultsView.swift +60
@@ -116,6 +116,39 @@ struct BatchResultRowView: View {
116116 .frame(maxWidth: .infinity, alignment: .leading)
117117 .padding(.vertical, 4)
118118 .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"
119152 }
120153
121154 private var availabilityText: String {
@@ -164,3 +197,30 @@ struct BatchResultRowView: View {
164197 }
165198 }
166199}
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 {
182182 Image(systemName: "xmark.circle")
183183 .foregroundStyle(Color(.appTextSecondary))
184184 }
185 .accessibilityLabel("Clear results")
185186 }
186187 }
187188 }
@@ -195,6 +196,15 @@ struct ContentView: View {
195196 .onChange(of: viewModel.searchedDomain) { _, _ in
196197 collapsedSections = defaultCollapsedSections
197198 }
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 }
198208 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
199209 navigationPath = NavigationPath()
200210 focusedInputField = nil
@@ -544,6 +554,7 @@ struct ContentView: View {
544554 .font(appDensity.font(.body, design: .default))
545555 .foregroundStyle(Color(.appTextSecondary))
546556 }
557 .accessibilityLabel("Actions")
547558 Button {
548559 viewModel.toggleSavedDomain()
549560 } label: {
@@ -551,6 +562,9 @@ struct ContentView: View {
551562 .font(appDensity.font(.body, design: .default))
552563 .foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary)
553564 }
565 .accessibilityLabel("Save domain")
566 .accessibilityValue(viewModel.isCurrentDomainSaved ? "Saved" : "Not saved")
567 .accessibilityAddTraits(viewModel.isCurrentDomainSaved ? .isSelected : [])
554568 Menu {
555569 Button("Export TXT") {
556570 shareSingleResults(format: .text)
@@ -583,6 +597,7 @@ struct ContentView: View {
583597 .font(appDensity.font(.body, design: .default))
584598 .foregroundStyle(Color(.appTextSecondary))
585599 }
600 .accessibilityLabel("Export")
586601 }
587602 }
588603 }
@@ -1345,6 +1360,9 @@ struct DomainSectionView: View {
13451360 }
13461361 .buttonStyle(.bordered)
13471362 .font(appDensity.font(.caption))
1363 .accessibilityLabel("Pin domain")
1364 .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
1365 .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
13481366 if let onEditNote {
13491367 Button("Note") {
13501368 onEditNote()
@@ -2415,6 +2433,7 @@ struct SectionTitleView: View {
24152433 Text(title)
24162434 .font(appDensity.font(.headline, design: .default, weight: .semibold))
24172435 .foregroundStyle(.primary)
2436 .accessibilityAddTraits(.isHeader)
24182437 }
24192438}
24202439
@@ -2570,12 +2589,7 @@ struct LabeledValueRow: View {
25702589 Text(row.label)
25712590 .font(appDensity.font(.caption2))
25722591 .foregroundStyle(Color(.appTextSecondary))
2573 Text(row.value)
2574 .font(appDensity.font(.caption))
2575 .foregroundStyle(ResultColors.color(for: row.tone))
2576 .lineLimit(nil)
2577 .fixedSize(horizontal: false, vertical: true)
2578 .textSelection(.enabled)
2592 valueText
25792593 }
25802594 .frame(maxWidth: .infinity, alignment: .leading)
25812595 .layoutPriority(1)
@@ -2587,6 +2601,31 @@ struct LabeledValueRow: View {
25872601 }
25882602 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
25892603 }
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 }
25902629}
25912630
25922631/// Maps a row's semantic tone onto the app palette.
DomainDig/DashboardView.swift +1
@@ -166,6 +166,7 @@ struct DashboardView: View {
166166 } label: {
167167 Image(systemName: "arrow.clockwise")
168168 }
169 .accessibilityLabel("Refresh all tracked domains")
169170 .disabled(viewModel.batchLookupRunning)
170171 }
171172 }
DomainDig/DomainDig/AuditViews.swift +3
@@ -261,6 +261,8 @@ struct AuditSessionDetailView: View {
261261 }
262262 }
263263 .buttonStyle(.plain)
264 .accessibilityAddTraits(item.isComplete ? .isSelected : [])
265 .accessibilityHint(item.isComplete ? "Marks incomplete" : "Marks complete")
264266 }
265267 }
266268 .listRowBackground(Color(.appSurface))
@@ -479,6 +481,7 @@ private struct AuditFindingEditorView: View {
479481 }
480482 }
481483 .buttonStyle(.plain)
484 .accessibilityAddTraits(selectedAreas.contains(area) ? .isSelected : [])
482485 }
483486 }
484487
DomainDig/DomainDigUI.swift +25
@@ -251,6 +251,10 @@ struct AppStatusBadgeView: View {
251251 .padding(.vertical, 5)
252252 .background(model.backgroundColor)
253253 .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)
254258 }
255259}
256260
@@ -305,6 +309,19 @@ enum AppClipboard {
305309 }
306310}
307311
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
308325enum AppHaptics {
309326 static func copy() {
310327 #if canImport(UIKit)
@@ -438,11 +455,19 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
438455 Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
439456 .font(.caption.weight(.semibold))
440457 .foregroundStyle(Color(.appTextSecondary))
458 .accessibilityHidden(true)
441459 }
442460 .contentShape(Rectangle())
443461 .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center)
444462 }
445463 .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")
446471
447472 if !isCollapsed {
448473 content()
DomainDig/DomainViewModel.swift +19 −3
@@ -17,11 +17,24 @@ struct SummaryFieldViewData: Identifiable {
1717 let tone: ResultTone
1818}
1919
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
2032struct InfoRowViewData: Identifiable {
2133 let id = UUID()
2234 let label: String
2335 let value: String
2436 let tone: ResultTone
37 var speechStyle: RowSpeechStyle = .plain
2538}
2639
2740struct SectionMessageViewData {
@@ -3036,6 +3049,9 @@ final class DomainViewModel {
30363049 )
30373050 latestBatchSweepSummary = summary
30383051 SweepActivityController.shared.end(changed: changedCount, warnings: warningCount)
3052 AppAccessibility.announce(
3053 "Sweep complete. \(summary.results.count) domains, \(changedCount) changed, \(warningCount) warnings."
3054 )
30393055
30403056 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
30413057 let workflowReports: [DomainReport] = summary.results.compactMap { result in
@@ -4088,8 +4104,8 @@ final class DomainViewModel {
40884104 snapshot.dnsSections.map { section in
40894105 DNSRecordSectionViewData(
40904106 title: section.recordType.rawValue,
4091 rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
4092 wildcardRows: section.wildcardRecords.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) },
4108 wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
40934109 wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
40944110 message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
40954111 ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
@@ -4126,7 +4142,7 @@ final class DomainViewModel {
41264142 rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
41274143 }
41284144 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))
41304146 }
41314147 if let hstsPreloaded = snapshot.hstsPreloaded {
41324148 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 {
106106 } label: {
107107 Image(systemName: "line.3.horizontal.decrease.circle")
108108 }
109 .accessibilityLabel("Filter")
109110
110111 EditButton()
111112 }
DomainDig/TimelineView.swift +1
@@ -63,6 +63,7 @@ struct TimelineView: View {
6363 } label: {
6464 Image(systemName: "line.3.horizontal.decrease.circle")
6565 }
66 .accessibilityLabel("Group timeline")
6667
6768 Button("Compare") {
6869 if viewModel.selectedSnapshots.count == 2 {
DomainDig/WatchlistView.swift +31
@@ -106,6 +106,7 @@ struct WatchlistView: View {
106106 } label: {
107107 Image(systemName: "plus")
108108 }
109 .accessibilityLabel("Add domain")
109110
110111 if !viewModel.filteredTrackedDomains.isEmpty {
111112 Menu {
@@ -181,6 +182,7 @@ struct WatchlistView: View {
181182 } label: {
182183 Image(systemName: "line.3.horizontal.decrease.circle")
183184 }
185 .accessibilityLabel("Filter and sort")
184186
185187 EditButton()
186188 }
@@ -485,6 +487,7 @@ struct WatchlistRowView: View {
485487 }
486488 .frame(maxWidth: .infinity, alignment: .leading)
487489 .padding(.vertical, 4)
490 .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing))
488491 }
489492
490493 private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
@@ -542,6 +545,34 @@ struct WatchlistRowView: View {
542545 }
543546}
544547
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
545576struct TrackedDomainDetailView: View {
546577 @Bindable var viewModel: DomainViewModel
547578 let trackedDomain: TrackedDomain
DomainDig/WorkflowsView.swift +5
@@ -42,6 +42,7 @@ struct WorkflowsView: View {
4242 } label: {
4343 Image(systemName: "plus.circle")
4444 }
45 .accessibilityLabel("Create workflow")
4546
4647 if !viewModel.workflows.isEmpty {
4748 EditButton()
@@ -133,6 +134,7 @@ private struct WorkflowRowView: View {
133134 Image(systemName: "person.2.fill")
134135 .font(.caption2)
135136 .foregroundStyle(Color(.statusInfo))
137 .accessibilityLabel("Shared")
136138 }
137139 Text(workflow.name)
138140 .font(appDensity.font(.callout, design: .default, weight: .semibold))
@@ -563,6 +565,7 @@ struct WorkflowRunSummaryView: View {
563565 } label: {
564566 Image(systemName: "square.and.arrow.up")
565567 }
568 .accessibilityLabel("Export summary")
566569
567570 if let workflow = viewModel.workflow(withID: summary.workflowID) {
568571 Button {
@@ -570,6 +573,7 @@ struct WorkflowRunSummaryView: View {
570573 } label: {
571574 Image(systemName: "arrow.clockwise")
572575 }
576 .accessibilityLabel("Re-run workflow")
573577 .disabled(viewModel.batchLookupRunning)
574578 }
575579 }
@@ -640,6 +644,7 @@ struct WorkflowBulkAddSheet: View {
640644 }
641645 }
642646 .buttonStyle(.plain)
647 .accessibilityAddTraits(selectedDomains.contains(domain) ? .isSelected : [])
643648 }
644649 }
645650
DomainDigWidget/DomainDigPortfolioWidget.swift +32 −4
@@ -97,18 +97,21 @@ struct DomainDigWidgetView: View {
9797 Spacer(minLength: 0)
9898
9999 HStack(spacing: 10) {
100 countPill(data.healthyCount, Color(.statusPositive))
101 countPill(data.warningCount, Color(.statusWarning))
102 countPill(data.criticalCount, Color(.statusCritical))
100 countPill(data.healthyCount, Color(.statusPositive), "healthy")
101 countPill(data.warningCount, Color(.statusWarning), "warning")
102 countPill(data.criticalCount, Color(.statusCritical), "critical")
103103 }
104104 }
105105 }
106106
107 private func countPill(_ value: Int, _ color: Color) -> some View {
107 private func countPill(_ value: Int, _ color: Color, _ label: String) -> some View {
108108 HStack(spacing: 3) {
109109 Circle().fill(color).frame(width: 7, height: 7)
110110 Text("\(value)").font(.caption).fontWeight(.medium)
111111 }
112 // A coloured dot and a number say nothing on their own.
113 .accessibilityElement(children: .ignore)
114 .accessibilityLabel("\(value) \(label)")
112115 }
113116
114117 // MARK: Medium / Large
@@ -176,6 +179,31 @@ struct DomainDigWidgetView: View {
176179 .font(.caption2)
177180 .foregroundStyle(Color(.appTextSecondary))
178181 }
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")"
179207 }
180208
181209 private func certLabel(for domain: DomainDigWidgetDomain) -> String {