diff options
| -rw-r--r-- | Docs/ACCESSIBILITY.md | 43 | ||||
| -rw-r--r-- | DomainDig/BatchResultsView.swift | 60 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 51 | ||||
| -rw-r--r-- | DomainDig/DashboardView.swift | 1 | ||||
| -rw-r--r-- | DomainDig/DomainDig/AuditViews.swift | 3 | ||||
| -rw-r--r-- | DomainDig/DomainDigUI.swift | 25 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 22 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 1 | ||||
| -rw-r--r-- | DomainDig/TimelineView.swift | 1 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 31 | ||||
| -rw-r--r-- | DomainDig/WorkflowsView.swift | 5 | ||||
| -rw-r--r-- | DomainDigWidget/DomainDigPortfolioWidget.swift | 36 |
12 files changed, 266 insertions, 13 deletions
diff --git a/Docs/ACCESSIBILITY.md b/Docs/ACCESSIBILITY.md index 6032bf6..bc6f061 100644 --- a/Docs/ACCESSIBILITY.md +++ b/Docs/ACCESSIBILITY.md @@ -208,6 +208,49 @@ pre-commit that blocks every commit. A hook routinely bypassed with needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for users who prefer smaller text. +## VoiceOver conventions + +- **Dense rows use combine-for-summary, custom-content-for-detail.** + `BatchResultRowView` and `WatchlistRowView` each hold 8–9 text elements. + Reading them inline makes a long sweep unnavigable, so each row is one element: + `.accessibilityElement(children: .ignore)` + domain label + status value, with + the rest on `.accessibilityCustomContent(...)`. `.high` importance is spoken + inline; everything else reaches the More Content rotor on a vertical swipe. + Rows with only 3–4 elements (the portfolio activity/attention/expiry rows) are + left to `NavigationLink`'s automatic combine — custom content is for the dense + case, per WWDC21-10121. +- **The custom-content chain must live in a `ViewModifier`.** Inlined onto a row + body, six `.accessibilityCustomContent` calls plus the visual layout blow the + Swift type-checker's budget ("unable to type-check in reasonable time"). + `BatchRowAccessibility` / `WatchlistRowAccessibility` exist for that reason. +- **Splitting a `Label` exposes its icon; combining a header swallows its + trailing controls.** Two opposite traps. A decorative icon pulled out of a + `Label` needs `.accessibilityHidden(true)`. A header built as a `Button` must + *not* get `.accessibilityElement(children: .combine)` if its label contains + other controls (`CollapsibleSectionView`'s `trailing()` holds Track/Pin) — + combine would merge them into the header and make them unreachable. +- **Label-in-name (WCAG 2.5.3).** Every `accessibilityLabel` added to a control + with visible text keeps that text, so Voice Control still works. Free-form + labels are used only where the control is genuinely icon-only. +- **Technical strings** get `speechStyle: .technical` on `InfoRowViewData`, which + applies `.speechAlwaysIncludesPunctuation()` and + `.accessibilityTextContentType(.sourceCode)`. Set today on DNS record values + and cipher suites; extend it wherever the view model emits a fingerprint, + serial, or record string. + +### What the automated audit cannot check + +`performAccessibilityAudit()` validates descriptions, traits, contrast, hit +regions, and clipping. It does **not** exercise VoiceOver speech, the More +Content rotor, custom-content ordering, or announcements. Those are verified by +construction and a manual VoiceOver pass (Phase 6), not by the suite. A green +audit is necessary, not sufficient, for the row and speech work. + +Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the +widget never render in the audit — the test simulator has no tracked domains or +batch results. Their treatment is unverified by the suite for the same reason the +Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data. + ## Notes - **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift index 4b4f6fd..aeae48f 100644 --- a/DomainDig/BatchResultsView.swift +++ b/DomainDig/BatchResultsView.swift @@ -116,6 +116,39 @@ struct BatchResultRowView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 4) .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading) + // One VoiceOver stop per row: domain as the label, status as the value, + // everything else on the More Content rotor. Reading all eight text + // elements inline would make a 200-domain sweep unnavigable. `.high` + // importance is spoken without the rotor; the rest waits for a swipe. + // Extracted to a modifier — inlined, the chain broke the type-checker. + .modifier(BatchRowAccessibility( + domain: result.domain, + status: "\(quickStatusBadge.title), \(availabilityText)", + risk: riskDescription, + ip: result.primaryIP ?? "none", + checked: result.timestamp.formatted(date: .abbreviated, time: .shortened), + source: result.resultSource.label, + changeLabel: changeContentLabel, + changeValue: changeContentValue + )) + } + + private var changeContentLabel: String { + result.changeClassification != nil ? "Impact" : "Status" + } + + private var changeContentValue: String { + if let change = result.changeClassification { + return change.title + } + return result.errorMessage ?? result.summaryMessage ?? quickStatusBadge.title + } + + private var riskDescription: String { + if let score = result.riskScore, let level = result.riskLevel { + return "\(score), \(level.title)" + } + return "not scored" } private var availabilityText: String { @@ -164,3 +197,30 @@ struct BatchResultRowView: View { } } } + +/// Row-level VoiceOver treatment for a batch result: a single element whose +/// label is the domain and whose value is the status, with the remaining fields +/// on the More Content rotor. Extracted from the row body because inlining the +/// full modifier chain broke Swift's type-checker. +private struct BatchRowAccessibility: ViewModifier { + let domain: String + let status: String + let risk: String + let ip: String + let checked: String + let source: String + let changeLabel: String + let changeValue: String + + func body(content: Content) -> some View { + content + .accessibilityElement(children: .ignore) + .accessibilityLabel(domain) + .accessibilityValue(status) + .accessibilityCustomContent("Risk", risk, importance: .high) + .accessibilityCustomContent("IP address", ip) + .accessibilityCustomContent("Checked", checked) + .accessibilityCustomContent("Source", source) + .accessibilityCustomContent(LocalizedStringResource(stringLiteral: changeLabel), changeValue) + } +} diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index a4800ed..bc159d9 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -182,6 +182,7 @@ struct ContentView: View { Image(systemName: "xmark.circle") .foregroundStyle(Color(.appTextSecondary)) } + .accessibilityLabel("Clear results") } } } @@ -195,6 +196,15 @@ struct ContentView: View { .onChange(of: viewModel.searchedDomain) { _, _ in collapsedSections = defaultCollapsedSections } + .onChange(of: viewModel.resultsLoaded) { wasLoaded, isLoaded in + // Single-lookup completion has no single view-model moment + // (`resultsLoaded` is derived from many loading flags), so the + // announcement is posted from the view where the transition is + // observable. The batch path announces from the view model directly. + guard !wasLoaded, isLoaded, viewModel.hasRun else { return } + let summary = AppStatusFactory.availability(viewModel.availabilityResult?.status).title + AppAccessibility.announce("Lookup complete for \(viewModel.searchedDomain). \(summary).") + } .onChange(of: viewModel.rerunNavigationToken) { _, _ in navigationPath = NavigationPath() focusedInputField = nil @@ -544,6 +554,7 @@ struct ContentView: View { .font(appDensity.font(.body, design: .default)) .foregroundStyle(Color(.appTextSecondary)) } + .accessibilityLabel("Actions") Button { viewModel.toggleSavedDomain() } label: { @@ -551,6 +562,9 @@ struct ContentView: View { .font(appDensity.font(.body, design: .default)) .foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary) } + .accessibilityLabel("Save domain") + .accessibilityValue(viewModel.isCurrentDomainSaved ? "Saved" : "Not saved") + .accessibilityAddTraits(viewModel.isCurrentDomainSaved ? .isSelected : []) Menu { Button("Export TXT") { shareSingleResults(format: .text) @@ -583,6 +597,7 @@ struct ContentView: View { .font(appDensity.font(.body, design: .default)) .foregroundStyle(Color(.appTextSecondary)) } + .accessibilityLabel("Export") } } } @@ -1345,6 +1360,9 @@ struct DomainSectionView: View { } .buttonStyle(.bordered) .font(appDensity.font(.caption)) + .accessibilityLabel("Pin domain") + .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned") + .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : []) if let onEditNote { Button("Note") { onEditNote() @@ -2415,6 +2433,7 @@ struct SectionTitleView: View { Text(title) .font(appDensity.font(.headline, design: .default, weight: .semibold)) .foregroundStyle(.primary) + .accessibilityAddTraits(.isHeader) } } @@ -2570,12 +2589,7 @@ struct LabeledValueRow: View { Text(row.label) .font(appDensity.font(.caption2)) .foregroundStyle(Color(.appTextSecondary)) - Text(row.value) - .font(appDensity.font(.caption)) - .foregroundStyle(ResultColors.color(for: row.tone)) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) + valueText } .frame(maxWidth: .infinity, alignment: .leading) .layoutPriority(1) @@ -2587,6 +2601,31 @@ struct LabeledValueRow: View { } .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) } + + @ViewBuilder + private var valueText: some View { + let base = Text(row.value) + .font(appDensity.font(.caption)) + .foregroundStyle(ResultColors.color(for: row.tone)) + + switch row.speechStyle { + case .plain: + base + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + case .technical: + // Record values and identifiers: keep punctuation audible (SPF/DMARC + // separators are semantically load-bearing) and let VoiceOver use its + // code-reading heuristics. + base + .speechAlwaysIncludesPunctuation() + .accessibilityTextContentType(.sourceCode) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } } /// Maps a row's semantic tone onto the app palette. diff --git a/DomainDig/DashboardView.swift b/DomainDig/DashboardView.swift index fc202d3..24955bb 100644 --- a/DomainDig/DashboardView.swift +++ b/DomainDig/DashboardView.swift @@ -166,6 +166,7 @@ struct DashboardView: View { } label: { Image(systemName: "arrow.clockwise") } + .accessibilityLabel("Refresh all tracked domains") .disabled(viewModel.batchLookupRunning) } } diff --git a/DomainDig/DomainDig/AuditViews.swift b/DomainDig/DomainDig/AuditViews.swift index 4e57837..90e64f8 100644 --- a/DomainDig/DomainDig/AuditViews.swift +++ b/DomainDig/DomainDig/AuditViews.swift @@ -261,6 +261,8 @@ struct AuditSessionDetailView: View { } } .buttonStyle(.plain) + .accessibilityAddTraits(item.isComplete ? .isSelected : []) + .accessibilityHint(item.isComplete ? "Marks incomplete" : "Marks complete") } } .listRowBackground(Color(.appSurface)) @@ -479,6 +481,7 @@ private struct AuditFindingEditorView: View { } } .buttonStyle(.plain) + .accessibilityAddTraits(selectedAreas.contains(area) ? .isSelected : []) } } diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift index bf06b46..de93a56 100644 --- a/DomainDig/DomainDigUI.swift +++ b/DomainDig/DomainDigUI.swift @@ -251,6 +251,10 @@ struct AppStatusBadgeView: View { .padding(.vertical, 5) .background(model.backgroundColor) .clipShape(Capsule()) + // Read as one word ("Critical"), not "icon, Critical". The symbol + // duplicates the title for VoiceOver. + .accessibilityElement(children: .ignore) + .accessibilityLabel(model.title) } } @@ -305,6 +309,19 @@ enum AppClipboard { } } +enum AppAccessibility { + /// Speaks a status update through VoiceOver without moving focus. Used at + /// lookup and sweep completion so a blind user hears the result land instead + /// of having to hunt for whether anything changed. + static func announce(_ message: String) { + #if canImport(UIKit) + var announcement = AttributedString(message) + announcement.accessibilitySpeechAnnouncementPriority = .high + AccessibilityNotification.Announcement(announcement).post() + #endif + } +} + enum AppHaptics { static func copy() { #if canImport(UIKit) @@ -438,11 +455,19 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { Image(systemName: isCollapsed ? "chevron.down" : "chevron.up") .font(.caption.weight(.semibold)) .foregroundStyle(Color(.appTextSecondary)) + .accessibilityHidden(true) } .contentShape(Rectangle()) .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center) } .buttonStyle(.plain) + // A header that is also the expand/collapse control. The chevron is + // decorative; state and hint carry it to VoiceOver instead. No + // `children: .combine` here — `trailing()` may hold its own controls + // (Track, Pin), and combining would swallow them into the header. + .accessibilityAddTraits(.isHeader) + .accessibilityValue(isCollapsed ? "Collapsed" : "Expanded") + .accessibilityHint(isCollapsed ? "Expands the section" : "Collapses the section") if !isCollapsed { content() diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index f7d1d7e..de17cbc 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -17,11 +17,24 @@ struct SummaryFieldViewData: Identifiable { let tone: ResultTone } +/// How VoiceOver should pronounce a row's value. +/// +/// DNS records, cipher suites, and the like are read as prose by default, which +/// mangles load-bearing punctuation (`;`, `~`, `_`) and technical tokens. See +/// `Docs/ACCESSIBILITY.md`. +enum RowSpeechStyle { + /// Normal prose. + case plain + /// Record values and identifiers: include punctuation, use code heuristics. + case technical +} + struct InfoRowViewData: Identifiable { let id = UUID() let label: String let value: String let tone: ResultTone + var speechStyle: RowSpeechStyle = .plain } struct SectionMessageViewData { @@ -3036,6 +3049,9 @@ final class DomainViewModel { ) latestBatchSweepSummary = summary SweepActivityController.shared.end(changed: changedCount, warnings: warningCount) + AppAccessibility.announce( + "Sweep complete. \(summary.results.count) domains, \(changedCount) changed, \(warningCount) warnings." + ) if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName { let workflowReports: [DomainReport] = summary.results.compactMap { result in @@ -4088,8 +4104,8 @@ final class DomainViewModel { snapshot.dnsSections.map { section in DNSRecordSectionViewData( title: section.recordType.rawValue, - rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) }, - wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) }, + rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) }, + wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) }, wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)", message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ?? ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil) @@ -4126,7 +4142,7 @@ final class DomainViewModel { rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary)) } if let cipherSuite = sslInfo.cipherSuite { - rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary)) + rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary, speechStyle: .technical)) } if let hstsPreloaded = snapshot.hstsPreloaded { rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary)) diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index f291c81..5f4fe94 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -106,6 +106,7 @@ struct HistoryView: View { } label: { Image(systemName: "line.3.horizontal.decrease.circle") } + .accessibilityLabel("Filter") EditButton() } diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift index 5352a94..9b79adf 100644 --- a/DomainDig/TimelineView.swift +++ b/DomainDig/TimelineView.swift @@ -63,6 +63,7 @@ struct TimelineView: View { } label: { Image(systemName: "line.3.horizontal.decrease.circle") } + .accessibilityLabel("Group timeline") Button("Compare") { if viewModel.selectedSnapshots.count == 2 { diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index dda97ad..72eb04e 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -106,6 +106,7 @@ struct WatchlistView: View { } label: { Image(systemName: "plus") } + .accessibilityLabel("Add domain") if !viewModel.filteredTrackedDomains.isEmpty { Menu { @@ -181,6 +182,7 @@ struct WatchlistView: View { } label: { Image(systemName: "line.3.horizontal.decrease.circle") } + .accessibilityLabel("Filter and sort") EditButton() } @@ -485,6 +487,7 @@ struct WatchlistRowView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 4) + .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing)) } private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { @@ -542,6 +545,34 @@ struct WatchlistRowView: View { } } +/// Row-level VoiceOver treatment for a tracked domain: domain as label, +/// availability as value, the rest on the More Content rotor. Same rationale as +/// the batch row — up to nine text elements would be one unnavigable utterance. +private struct WatchlistRowAccessibility: ViewModifier { + let trackedDomain: TrackedDomain + let isRefreshing: Bool + + func body(content: Content) -> some View { + content + .accessibilityElement(children: .ignore) + .accessibilityLabel(trackedDomain.domain) + .accessibilityValue(isRefreshing ? "Refreshing" : AppStatusFactory.availability(trackedDomain.lastKnownAvailability).title) + .accessibilityCustomContent("Certificate", certificateContent, importance: .high) + .accessibilityCustomContent("Monitoring", trackedDomain.monitoringEnabled ? "on" : "off") + .accessibilityCustomContent("Updated", trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened)) + .accessibilityCustomContent("Pinned", trackedDomain.isPinned ? "yes" : "no") + } + + private var certificateContent: String { + let days = trackedDomain.certificateDaysRemaining.map { "\($0) days" } ?? "unknown" + switch trackedDomain.certificateWarningLevel { + case .critical: return "invalid, \(days)" + case .warning: return "expiring, \(days)" + case .none: return "valid" + } + } +} + struct TrackedDomainDetailView: View { @Bindable var viewModel: DomainViewModel let trackedDomain: TrackedDomain diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift index d628ba4..a6799e5 100644 --- a/DomainDig/WorkflowsView.swift +++ b/DomainDig/WorkflowsView.swift @@ -42,6 +42,7 @@ struct WorkflowsView: View { } label: { Image(systemName: "plus.circle") } + .accessibilityLabel("Create workflow") if !viewModel.workflows.isEmpty { EditButton() @@ -133,6 +134,7 @@ private struct WorkflowRowView: View { Image(systemName: "person.2.fill") .font(.caption2) .foregroundStyle(Color(.statusInfo)) + .accessibilityLabel("Shared") } Text(workflow.name) .font(appDensity.font(.callout, design: .default, weight: .semibold)) @@ -563,6 +565,7 @@ struct WorkflowRunSummaryView: View { } label: { Image(systemName: "square.and.arrow.up") } + .accessibilityLabel("Export summary") if let workflow = viewModel.workflow(withID: summary.workflowID) { Button { @@ -570,6 +573,7 @@ struct WorkflowRunSummaryView: View { } label: { Image(systemName: "arrow.clockwise") } + .accessibilityLabel("Re-run workflow") .disabled(viewModel.batchLookupRunning) } } @@ -640,6 +644,7 @@ struct WorkflowBulkAddSheet: View { } } .buttonStyle(.plain) + .accessibilityAddTraits(selectedDomains.contains(domain) ? .isSelected : []) } } diff --git a/DomainDigWidget/DomainDigPortfolioWidget.swift b/DomainDigWidget/DomainDigPortfolioWidget.swift index 15019c8..66002c5 100644 --- a/DomainDigWidget/DomainDigPortfolioWidget.swift +++ b/DomainDigWidget/DomainDigPortfolioWidget.swift @@ -97,18 +97,21 @@ struct DomainDigWidgetView: View { Spacer(minLength: 0) HStack(spacing: 10) { - countPill(data.healthyCount, Color(.statusPositive)) - countPill(data.warningCount, Color(.statusWarning)) - countPill(data.criticalCount, Color(.statusCritical)) + countPill(data.healthyCount, Color(.statusPositive), "healthy") + countPill(data.warningCount, Color(.statusWarning), "warning") + countPill(data.criticalCount, Color(.statusCritical), "critical") } } } - private func countPill(_ value: Int, _ color: Color) -> some View { + private func countPill(_ value: Int, _ color: Color, _ label: String) -> some View { HStack(spacing: 3) { Circle().fill(color).frame(width: 7, height: 7) Text("\(value)").font(.caption).fontWeight(.medium) } + // A coloured dot and a number say nothing on their own. + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(value) \(label)") } // MARK: Medium / Large @@ -176,6 +179,31 @@ struct DomainDigWidgetView: View { .font(.caption2) .foregroundStyle(Color(.appTextSecondary)) } + // The status is a silent 8pt dot and the cert countdown is bare ("12d"), + // both meaningless to VoiceOver. Collapse the row into one spoken phrase. + .accessibilityElement(children: .ignore) + .accessibilityLabel(rowAccessibilityLabel(domain)) + } + + private func rowAccessibilityLabel(_ domain: DomainDigWidgetDomain) -> String { + var parts = [domain.domain, statusLabel(domain.status)] + if domain.isPinned { parts.append("pinned") } + parts.append(certAccessibilityLabel(domain)) + return parts.joined(separator: ", ") + } + + private func statusLabel(_ status: DomainDigWidgetStatus) -> String { + switch status { + case .healthy: return "healthy" + case .warning: return "warning" + case .critical: return "critical" + } + } + + private func certAccessibilityLabel(_ domain: DomainDigWidgetDomain) -> String { + guard let days = domain.certDaysRemaining else { return "certificate status unknown" } + if days < 0 { return "certificate expired" } + return "certificate expires in \(days) day\(days == 1 ? "" : "s")" } private func certLabel(for domain: DomainDigWidgetDomain) -> String { |
