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 | 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 | ||
| 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 | ## 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. | ||
| 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 | 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 | ||
| 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 | enum AppHaptics { | 325 | enum 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`. | ||
| 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 | struct InfoRowViewData: Identifiable { | 32 | struct 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 | ||
| 27 | struct SectionMessageViewData { | 40 | struct 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. | ||
| 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 | struct TrackedDomainDetailView: View { | 576 | struct 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 { |