Commit dc479a3ba0
Unsigned
Layout: unified · split
Docs/ACCESSIBILITY.md +43
| @@ -208,6 +208,49 @@ pre-commit that blocks every commit. A hook routinely bypassed with | ||
| 208 | 208 | needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for |
| 209 | 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 | |
| 244 | regions, and clipping. It does **not** exercise VoiceOver speech, the More | |
| 245 | Content rotor, custom-content ordering, or announcements. Those are verified by | |
| 246 | construction and a manual VoiceOver pass (Phase 6), not by the suite. A green | |
| 247 | audit is necessary, not sufficient, for the row and speech work. | |
| 248 | ||
| 249 | Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the | |
| 250 | widget never render in the audit — the test simulator has no tracked domains or | |
| 251 | batch results. Their treatment is unverified by the suite for the same reason the | |
| 252 | Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data. | |
| 253 | ||
| 211 | 254 | ## Notes |
| 212 | 255 | |
| 213 | 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 | 116 | .frame(maxWidth: .infinity, alignment: .leading) |
| 117 | 117 | .padding(.vertical, 4) |
| 118 | 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 | 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. | |
| 205 | private 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 | 182 | Image(systemName: "xmark.circle") |
| 183 | 183 | .foregroundStyle(Color(.appTextSecondary)) |
| 184 | 184 | } |
| 185 | .accessibilityLabel("Clear results") | |
| 185 | 186 | } |
| 186 | 187 | } |
| 187 | 188 | } |
| @@ -195,6 +196,15 @@ struct ContentView: View { | ||
| 195 | 196 | .onChange(of: viewModel.searchedDomain) { _, _ in |
| 196 | 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 | 208 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
| 199 | 209 | navigationPath = NavigationPath() |
| 200 | 210 | focusedInputField = nil |
| @@ -544,6 +554,7 @@ struct ContentView: View { | ||
| 544 | 554 | .font(appDensity.font(.body, design: .default)) |
| 545 | 555 | .foregroundStyle(Color(.appTextSecondary)) |
| 546 | 556 | } |
| 557 | .accessibilityLabel("Actions") | |
| 547 | 558 | Button { |
| 548 | 559 | viewModel.toggleSavedDomain() |
| 549 | 560 | } label: { |
| @@ -551,6 +562,9 @@ struct ContentView: View { | ||
| 551 | 562 | .font(appDensity.font(.body, design: .default)) |
| 552 | 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 | 568 | Menu { |
| 555 | 569 | Button("Export TXT") { |
| 556 | 570 | shareSingleResults(format: .text) |
| @@ -583,6 +597,7 @@ struct ContentView: View { | ||
| 583 | 597 | .font(appDensity.font(.body, design: .default)) |
| 584 | 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 | 1361 | .buttonStyle(.bordered) |
| 1347 | 1362 | .font(appDensity.font(.caption)) |
| 1363 | .accessibilityLabel("Pin domain") | |
| 1364 | .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned") | |
| 1365 | .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : []) | |
| 1348 | 1366 | if let onEditNote { |
| 1349 | 1367 | Button("Note") { |
| 1350 | 1368 | onEditNote() |
| @@ -2415,6 +2433,7 @@ struct SectionTitleView: View { | ||
| 2415 | 2433 | Text(title) |
| 2416 | 2434 | .font(appDensity.font(.headline, design: .default, weight: .semibold)) |
| 2417 | 2435 | .foregroundStyle(.primary) |
| 2436 | .accessibilityAddTraits(.isHeader) | |
| 2418 | 2437 | } |
| 2419 | 2438 | } |
| 2420 | 2439 | |
| @@ -2570,12 +2589,7 @@ struct LabeledValueRow: View { | ||
| 2570 | 2589 | Text(row.label) |
| 2571 | 2590 | .font(appDensity.font(.caption2)) |
| 2572 | 2591 | .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 | |
| 2579 | 2593 | } |
| 2580 | 2594 | .frame(maxWidth: .infinity, alignment: .leading) |
| 2581 | 2595 | .layoutPriority(1) |
| @@ -2587,6 +2601,31 @@ struct LabeledValueRow: View { | ||
| 2587 | 2601 | } |
| 2588 | 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 | 2631 | /// Maps a row's semantic tone onto the app palette. |
DomainDig/DashboardView.swift +1
| @@ -166,6 +166,7 @@ struct DashboardView: View { | ||
| 166 | 166 | } label: { |
| 167 | 167 | Image(systemName: "arrow.clockwise") |
| 168 | 168 | } |
| 169 | .accessibilityLabel("Refresh all tracked domains") | |
| 169 | 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 | 263 | .buttonStyle(.plain) |
| 264 | .accessibilityAddTraits(item.isComplete ? .isSelected : []) | |
| 265 | .accessibilityHint(item.isComplete ? "Marks incomplete" : "Marks complete") | |
| 264 | 266 | } |
| 265 | 267 | } |
| 266 | 268 | .listRowBackground(Color(.appSurface)) |
| @@ -479,6 +481,7 @@ private struct AuditFindingEditorView: View { | ||
| 479 | 481 | } |
| 480 | 482 | } |
| 481 | 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 | 251 | .padding(.vertical, 5) |
| 252 | 252 | .background(model.backgroundColor) |
| 253 | 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 | |
| 312 | enum 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 | ||
| 308 | 325 | enum AppHaptics { |
| 309 | 326 | static func copy() { |
| 310 | 327 | #if canImport(UIKit) |
| @@ -438,11 +455,19 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { | ||
| 438 | 455 | Image(systemName: isCollapsed ? "chevron.down" : "chevron.up") |
| 439 | 456 | .font(.caption.weight(.semibold)) |
| 440 | 457 | .foregroundStyle(Color(.appTextSecondary)) |
| 458 | .accessibilityHidden(true) | |
| 441 | 459 | } |
| 442 | 460 | .contentShape(Rectangle()) |
| 443 | 461 | .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center) |
| 444 | 462 | } |
| 445 | 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 | 472 | if !isCollapsed { |
| 448 | 473 | content() |
DomainDig/DomainViewModel.swift +19 −3
| @@ -17,11 +17,24 @@ struct SummaryFieldViewData: Identifiable { | ||
| 17 | 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`. | |
| 25 | enum RowSpeechStyle { | |
| 26 | /// Normal prose. | |
| 27 | case plain | |
| 28 | /// Record values and identifiers: include punctuation, use code heuristics. | |
| 29 | case technical | |
| 30 | } | |
| 31 | ||
| 20 | 32 | struct InfoRowViewData: Identifiable { |
| 21 | 33 | let id = UUID() |
| 22 | 34 | let label: String |
| 23 | 35 | let value: String |
| 24 | 36 | let tone: ResultTone |
| 37 | var speechStyle: RowSpeechStyle = .plain | |
| 25 | 38 | } |
| 26 | 39 | |
| 27 | 40 | struct SectionMessageViewData { |
| @@ -3036,6 +3049,9 @@ final class DomainViewModel { | ||
| 3036 | 3049 | ) |
| 3037 | 3050 | latestBatchSweepSummary = summary |
| 3038 | 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 | 3056 | if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName { |
| 3041 | 3057 | let workflowReports: [DomainReport] = summary.results.compactMap { result in |
| @@ -4088,8 +4104,8 @@ final class DomainViewModel { | ||
| 4088 | 4104 | snapshot.dnsSections.map { section in |
| 4089 | 4105 | DNSRecordSectionViewData( |
| 4090 | 4106 | 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) }, | |
| 4093 | 4109 | wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)", |
| 4094 | 4110 | message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ?? |
| 4095 | 4111 | ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil) |
| @@ -4126,7 +4142,7 @@ final class DomainViewModel { | ||
| 4126 | 4142 | rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary)) |
| 4127 | 4143 | } |
| 4128 | 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 | 4147 | if let hstsPreloaded = snapshot.hstsPreloaded { |
| 4132 | 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 | 106 | } label: { |
| 107 | 107 | Image(systemName: "line.3.horizontal.decrease.circle") |
| 108 | 108 | } |
| 109 | .accessibilityLabel("Filter") | |
| 109 | 110 | |
| 110 | 111 | EditButton() |
| 111 | 112 | } |
DomainDig/TimelineView.swift +1
| @@ -63,6 +63,7 @@ struct TimelineView: View { | ||
| 63 | 63 | } label: { |
| 64 | 64 | Image(systemName: "line.3.horizontal.decrease.circle") |
| 65 | 65 | } |
| 66 | .accessibilityLabel("Group timeline") | |
| 66 | 67 | |
| 67 | 68 | Button("Compare") { |
| 68 | 69 | if viewModel.selectedSnapshots.count == 2 { |
DomainDig/WatchlistView.swift +31
| @@ -106,6 +106,7 @@ struct WatchlistView: View { | ||
| 106 | 106 | } label: { |
| 107 | 107 | Image(systemName: "plus") |
| 108 | 108 | } |
| 109 | .accessibilityLabel("Add domain") | |
| 109 | 110 | |
| 110 | 111 | if !viewModel.filteredTrackedDomains.isEmpty { |
| 111 | 112 | Menu { |
| @@ -181,6 +182,7 @@ struct WatchlistView: View { | ||
| 181 | 182 | } label: { |
| 182 | 183 | Image(systemName: "line.3.horizontal.decrease.circle") |
| 183 | 184 | } |
| 185 | .accessibilityLabel("Filter and sort") | |
| 184 | 186 | |
| 185 | 187 | EditButton() |
| 186 | 188 | } |
| @@ -485,6 +487,7 @@ struct WatchlistRowView: View { | ||
| 485 | 487 | } |
| 486 | 488 | .frame(maxWidth: .infinity, alignment: .leading) |
| 487 | 489 | .padding(.vertical, 4) |
| 490 | .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing)) | |
| 488 | 491 | } |
| 489 | 492 | |
| 490 | 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. | |
| 551 | private 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 | ||
| 545 | 576 | struct TrackedDomainDetailView: View { |
| 546 | 577 | @Bindable var viewModel: DomainViewModel |
| 547 | 578 | let trackedDomain: TrackedDomain |
DomainDig/WorkflowsView.swift +5
| @@ -42,6 +42,7 @@ struct WorkflowsView: View { | ||
| 42 | 42 | } label: { |
| 43 | 43 | Image(systemName: "plus.circle") |
| 44 | 44 | } |
| 45 | .accessibilityLabel("Create workflow") | |
| 45 | 46 | |
| 46 | 47 | if !viewModel.workflows.isEmpty { |
| 47 | 48 | EditButton() |
| @@ -133,6 +134,7 @@ private struct WorkflowRowView: View { | ||
| 133 | 134 | Image(systemName: "person.2.fill") |
| 134 | 135 | .font(.caption2) |
| 135 | 136 | .foregroundStyle(Color(.statusInfo)) |
| 137 | .accessibilityLabel("Shared") | |
| 136 | 138 | } |
| 137 | 139 | Text(workflow.name) |
| 138 | 140 | .font(appDensity.font(.callout, design: .default, weight: .semibold)) |
| @@ -563,6 +565,7 @@ struct WorkflowRunSummaryView: View { | ||
| 563 | 565 | } label: { |
| 564 | 566 | Image(systemName: "square.and.arrow.up") |
| 565 | 567 | } |
| 568 | .accessibilityLabel("Export summary") | |
| 566 | 569 | |
| 567 | 570 | if let workflow = viewModel.workflow(withID: summary.workflowID) { |
| 568 | 571 | Button { |
| @@ -570,6 +573,7 @@ struct WorkflowRunSummaryView: View { | ||
| 570 | 573 | } label: { |
| 571 | 574 | Image(systemName: "arrow.clockwise") |
| 572 | 575 | } |
| 576 | .accessibilityLabel("Re-run workflow") | |
| 573 | 577 | .disabled(viewModel.batchLookupRunning) |
| 574 | 578 | } |
| 575 | 579 | } |
| @@ -640,6 +644,7 @@ struct WorkflowBulkAddSheet: View { | ||
| 640 | 644 | } |
| 641 | 645 | } |
| 642 | 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 | 97 | Spacer(minLength: 0) |
| 98 | 98 | |
| 99 | 99 | 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") | |
| 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 | 108 | HStack(spacing: 3) { |
| 109 | 109 | Circle().fill(color).frame(width: 7, height: 7) |
| 110 | 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 | 117 | // MARK: Medium / Large |
| @@ -176,6 +179,31 @@ struct DomainDigWidgetView: View { | ||
| 176 | 179 | .font(.caption2) |
| 177 | 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 | 209 | private func certLabel(for domain: DomainDigWidgetDomain) -> String { |