Commit c220e26bb4

c220e26bb496e455a5bdb6a8dc22abf09ecfed5f

parent: dc479a3ba0

Unsigned

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

feat(a11y): color independence, reduce motion, reduce transparency (#21 phase 5)

Audit unchanged at 11 dark / 14 light — expected, as none of these
settings are exercised by performAccessibilityAudit, and simctl can
toggle only Increase Contrast, not Differentiate Without Color, Reduce
Motion, or Reduce Transparency. Correct by construction and build-clean;
runtime behaviour is verified in the Phase 6 manual pass.

Color independence:

- Widget status is now an SF Symbol (checkmark.circle.fill /
  exclamationmark.triangle.fill / exclamationmark.octagon.fill), the same
  vocabulary as the in-app badges, replacing a silent colour-only dot on
  both the domain rows and the small-view count pills. Status now survives
  greyscale and reads consistently across surfaces.
- Under accessibilityDifferentiateWithoutColor: the Dashboard
  summary-card dot becomes a per-filter symbol, the selected quick-filter
  chip gains a checkmark and a border (selection was fill-colour only, and
  also gains the .isSelected trait), and LabeledValueRow prefixes a
  warning/failure symbol. All gated on the setting so the default UI stays
  uncluttered.

Reduce motion: all five withAnimation/.animation sites now pass nil under
accessibilityReduceMotion — AppCopyButton's check cross-fade,
CollapsibleSectionView's expand/collapse, TimelineDiffView's scroll, and
WatchlistView's list reorder.

Reduce transparency: the single .thinMaterial capsule falls back to an
opaque AppSurfaceElevated fill under accessibilityReduceTransparency.

The SweepActivityController item from the plan is dropped: it is pure
ActivityKit lifecycle with no animation, confirmed back in the issue
triage.

Layout: unified · split

Docs/ACCESSIBILITY.md +25
@@ -238,6 +238,31 @@ pre-commit that blocks every commit. A hook routinely bypassed with
238 and cipher suites; extend it wherever the view model emits a fingerprint, 238 and cipher suites; extend it wherever the view model emits a fingerprint,
239 serial, or record string. 239 serial, or record string.
240 240
241## Color independence, motion, transparency
242
243- **Status is never colour-only.** In-app badges already pair a symbol with the
244 colour. The widget status dot is now an SF Symbol
245 (`checkmark.circle.fill` / `exclamationmark.triangle.fill` /
246 `exclamationmark.octagon.fill`) — the same vocabulary as the badges, so a
247 status reads consistently across surfaces and survives greyscale.
248- **`accessibilityDifferentiateWithoutColor`** adds redundant shape only when the
249 user asks for it, avoiding clutter otherwise: the Dashboard summary-card dot
250 becomes a per-filter symbol, the selected quick-filter chip gains a checkmark
251 and border (selection was fill-colour only), and `LabeledValueRow` prefixes a
252 warning/failure symbol.
253- **`accessibilityReduceMotion`** guards all five animation sites via
254 `withAnimation(reduceMotion ? nil : …)` / `.animation(reduceMotion ? nil : …)`:
255 `AppCopyButton`'s check cross-fade, `CollapsibleSectionView`'s expand/collapse,
256 `TimelineDiffView`'s scroll, and `WatchlistView`'s list reorder.
257- **`accessibilityReduceTransparency`** swaps the single `.thinMaterial` for an
258 opaque `AppSurfaceElevated` capsule.
259
260These cannot be verified by `simctl`, which toggles only Increase Contrast — the
261other three settings live in the simulator's Settings app. They are correct by
262construction and build-clean; their runtime behaviour is part of the Phase 6
263manual pass. `SweepActivityController` was dropped from the motion list: it is
264pure ActivityKit lifecycle with no animation to guard.
265
241### What the automated audit cannot check 266### What the automated audit cannot check
242 267
243`performAccessibilityAudit()` validates descriptions, traits, contrast, hit 268`performAccessibilityAudit()` validates descriptions, traits, contrast, hit
DomainDig/ContentView.swift +28 −2
@@ -2580,6 +2580,7 @@ struct SectionTrustMetadataView: View {
2580 2580
2581struct LabeledValueRow: View { 2581struct LabeledValueRow: View {
2582 @Environment(\.appDensity) private var appDensity 2582 @Environment(\.appDensity) private var appDensity
2583 @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
2583 let row: InfoRowViewData 2584 let row: InfoRowViewData
2584 2585
2585 var body: some View { 2586 var body: some View {
@@ -2589,7 +2590,15 @@ struct LabeledValueRow: View {
2589 Text(row.label) 2590 Text(row.label)
2590 .font(appDensity.font(.caption2)) 2591 .font(appDensity.font(.caption2))
2591 .foregroundStyle(Color(.appTextSecondary)) 2592 .foregroundStyle(Color(.appTextSecondary))
2592 valueText 2593 HStack(alignment: .firstTextBaseline, spacing: 4) {
2594 if differentiateWithoutColor, let symbol = toneSymbol {
2595 Image(systemName: symbol)
2596 .font(appDensity.font(.caption2))
2597 .foregroundStyle(ResultColors.color(for: row.tone))
2598 .accessibilityHidden(true)
2599 }
2600 valueText
2601 }
2593 } 2602 }
2594 .frame(maxWidth: .infinity, alignment: .leading) 2603 .frame(maxWidth: .infinity, alignment: .leading)
2595 .layoutPriority(1) 2604 .layoutPriority(1)
@@ -2602,6 +2611,17 @@ struct LabeledValueRow: View {
2602 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) 2611 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
2603 } 2612 }
2604 2613
2614 /// A leading symbol for warning/failure tones, shown only under Differentiate
2615 /// Without Color so tone is not conveyed by text colour alone. Hidden from
2616 /// VoiceOver — the value text already carries the meaning.
2617 private var toneSymbol: String? {
2618 switch row.tone {
2619 case .warning: return "exclamationmark.triangle.fill"
2620 case .failure: return "xmark.octagon.fill"
2621 default: return nil
2622 }
2623 }
2624
2605 @ViewBuilder 2625 @ViewBuilder
2606 private var valueText: some View { 2626 private var valueText: some View {
2607 let base = Text(row.value) 2627 let base = Text(row.value)
@@ -3588,6 +3608,7 @@ private struct DataPortabilitySettingsView: View {
3588 3608
3589private struct DataManagementSettingsView: View { 3609private struct DataManagementSettingsView: View {
3590 @Bindable var viewModel: DomainViewModel 3610 @Bindable var viewModel: DomainViewModel
3611 @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
3591 3612
3592 @State private var showClearHistoryConfirmation = false 3613 @State private var showClearHistoryConfirmation = false
3593 @State private var showClearCacheConfirmation = false 3614 @State private var showClearCacheConfirmation = false
@@ -3695,7 +3716,12 @@ private struct DataManagementSettingsView: View {
3695 .foregroundStyle(Color(.appTextSecondary)) 3716 .foregroundStyle(Color(.appTextSecondary))
3696 .padding(.horizontal, 14) 3717 .padding(.horizontal, 14)
3697 .padding(.vertical, 10) 3718 .padding(.vertical, 10)
3698 .background(.thinMaterial, in: Capsule()) 3719 // Reduce Transparency swaps the blur for an opaque surface.
3720 // On iOS 26+ the system also composites its own translucency
3721 // that the app cannot declare — verify there too (Phase 6).
3722 .background(
3723 Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial))
3724 )
3699 .padding(.bottom, 8) 3725 .padding(.bottom, 8)
3700 .transition(.move(edge: .bottom).combined(with: .opacity)) 3726 .transition(.move(edge: .bottom).combined(with: .opacity))
3701 } 3727 }
DomainDig/DashboardView.swift +50 −11
@@ -2,6 +2,7 @@ import SwiftUI
2 2
3struct DashboardView: View { 3struct DashboardView: View {
4 @Environment(\.appDensity) private var appDensity 4 @Environment(\.appDensity) private var appDensity
5 @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
5 @Bindable var viewModel: DomainViewModel 6 @Bindable var viewModel: DomainViewModel
6 @State private var collapsedGroups = Set<String>() 7 @State private var collapsedGroups = Set<String>()
7 8
@@ -44,15 +45,33 @@ struct DashboardView: View {
44 Button { 45 Button {
45 viewModel.dashboardFilter = filter 46 viewModel.dashboardFilter = filter
46 } label: { 47 } label: {
47 Text(filter.title) 48 let isSelected = viewModel.dashboardFilter == filter
48 .font(appDensity.font(.caption, weight: .semibold)) 49 HStack(spacing: 4) {
49 .foregroundStyle(viewModel.dashboardFilter == filter ? Color(.appOnAccent) : Color.primary) 50 // Selection is a fill-colour change; under
50 .padding(.horizontal, 12) 51 // Differentiate Without Color add a
51 .padding(.vertical, 8) 52 // checkmark + border so it does not depend
52 .background(viewModel.dashboardFilter == filter ? Color(.statusInfo) : Color(.appSurfaceElevated)) 53 // on hue alone.
53 .clipShape(Capsule()) 54 if isSelected, differentiateWithoutColor {
55 Image(systemName: "checkmark")
56 .font(.caption2.weight(.bold))
57 }
58 Text(filter.title)
59 .font(appDensity.font(.caption, weight: .semibold))
60 }
61 .foregroundStyle(isSelected ? Color(.appOnAccent) : Color.primary)
62 .padding(.horizontal, 12)
63 .padding(.vertical, 8)
64 .background(isSelected ? Color(.statusInfo) : Color(.appSurfaceElevated))
65 .overlay(
66 Capsule().strokeBorder(
67 Color.primary,
68 lineWidth: isSelected && differentiateWithoutColor ? 1.5 : 0
69 )
70 )
71 .clipShape(Capsule())
54 } 72 }
55 .buttonStyle(.plain) 73 .buttonStyle(.plain)
74 .accessibilityAddTraits(viewModel.dashboardFilter == filter ? .isSelected : [])
56 } 75 }
57 } 76 }
58 .padding(.vertical, 4) 77 .padding(.vertical, 4)
@@ -198,10 +217,18 @@ struct DashboardView: View {
198 // Was a fixed 28pt, which ignored Dynamic Type entirely. 217 // Was a fixed 28pt, which ignored Dynamic Type entirely.
199 .font(.system(.title, design: .rounded, weight: .bold)) 218 .font(.system(.title, design: .rounded, weight: .bold))
200 .foregroundStyle(.primary) 219 .foregroundStyle(.primary)
201 HStack { 220 HStack(spacing: 5) {
202 Circle() 221 // A symbol under Differentiate Without Color (where a bare
203 .fill(tint) 222 // colour dot conveys nothing), a plain dot otherwise.
204 .frame(width: 8, height: 8) 223 if differentiateWithoutColor {
224 Image(systemName: symbol(for: filter))
225 .font(.caption2)
226 .foregroundStyle(tint)
227 } else {
228 Circle()
229 .fill(tint)
230 .frame(width: 8, height: 8)
231 }
205 Text(filter.title) 232 Text(filter.title)
206 .font(appDensity.font(.caption2, design: .default, weight: .semibold)) 233 .font(appDensity.font(.caption2, design: .default, weight: .semibold))
207 .foregroundStyle(tint) 234 .foregroundStyle(tint)
@@ -215,6 +242,18 @@ struct DashboardView: View {
215 .buttonStyle(.plain) 242 .buttonStyle(.plain)
216 } 243 }
217 244
245 private func symbol(for filter: PortfolioFilterOption) -> String {
246 switch filter {
247 case .all: return "square.grid.2x2.fill"
248 case .healthy: return "checkmark.circle.fill"
249 case .warning: return "exclamationmark.triangle.fill"
250 case .critical: return "exclamationmark.octagon.fill"
251 case .changed: return "arrow.triangle.2.circlepath"
252 case .expiring: return "clock.badge.exclamationmark.fill"
253 case .unreachable: return "wifi.slash"
254 }
255 }
256
218 private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle { 257 private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle {
219 if viewModel.dashboardFilter == filter { 258 if viewModel.dashboardFilter == filter {
220 // Uses the authored info surface rather than a translucent wash of 259 // Uses the authored info surface rather than a translucent wash of
DomainDig/DomainDigUI.swift +5 −3
@@ -260,6 +260,7 @@ struct AppStatusBadgeView: View {
260 260
261struct AppCopyButton: View { 261struct AppCopyButton: View {
262 @Environment(\.appDensity) private var appDensity 262 @Environment(\.appDensity) private var appDensity
263 @Environment(\.accessibilityReduceMotion) private var reduceMotion
263 @State private var didCopy = false 264 @State private var didCopy = false
264 265
265 /// Grows with Dynamic Type. The `max(_, minimumTapTarget)` floor matters 266 /// Grows with Dynamic Type. The `max(_, minimumTapTarget)` floor matters
@@ -274,13 +275,13 @@ struct AppCopyButton: View {
274 Button { 275 Button {
275 AppClipboard.copy(value) 276 AppClipboard.copy(value)
276 AppHaptics.copy() 277 AppHaptics.copy()
277 withAnimation(.easeInOut(duration: 0.18)) { 278 withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) {
278 didCopy = true 279 didCopy = true
279 } 280 }
280 Task { 281 Task {
281 try? await Task.sleep(nanoseconds: 900_000_000) 282 try? await Task.sleep(nanoseconds: 900_000_000)
282 await MainActor.run { 283 await MainActor.run {
283 withAnimation(.easeInOut(duration: 0.18)) { 284 withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) {
284 didCopy = false 285 didCopy = false
285 } 286 }
286 } 287 }
@@ -411,6 +412,7 @@ struct EmptyStateCardView: View {
411 412
412struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { 413struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
413 @Environment(\.appDensity) private var appDensity 414 @Environment(\.appDensity) private var appDensity
415 @Environment(\.accessibilityReduceMotion) private var reduceMotion
414 416
415 let title: String 417 let title: String
416 @Binding var isCollapsed: Bool 418 @Binding var isCollapsed: Bool
@@ -435,7 +437,7 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
435 var body: some View { 437 var body: some View {
436 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { 438 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
437 Button { 439 Button {
438 withAnimation(.easeInOut(duration: 0.2)) { 440 withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) {
439 isCollapsed.toggle() 441 isCollapsed.toggle()
440 } 442 }
441 } label: { 443 } label: {
DomainDig/TimelineView.swift +2 −1
@@ -168,6 +168,7 @@ private struct TimelineRow: View {
168} 168}
169 169
170struct TimelineDiffView: View { 170struct TimelineDiffView: View {
171 @Environment(\.accessibilityReduceMotion) private var reduceMotion
171 @Bindable var viewModel: DomainViewModel 172 @Bindable var viewModel: DomainViewModel
172 let diff: DomainDiff 173 let diff: DomainDiff
173 @Binding var focusedSectionID: String? 174 @Binding var focusedSectionID: String?
@@ -218,7 +219,7 @@ struct TimelineDiffView: View {
218 219
219 private func scroll(proxy: ScrollViewProxy) { 220 private func scroll(proxy: ScrollViewProxy) {
220 guard let focusedSectionID else { return } 221 guard let focusedSectionID else { return }
221 withAnimation { 222 withAnimation(reduceMotion ? nil : .default) {
222 proxy.scrollTo(focusedSectionID, anchor: .top) 223 proxy.scrollTo(focusedSectionID, anchor: .top)
223 } 224 }
224 } 225 }
DomainDig/WatchlistView.swift +2 −1
@@ -2,6 +2,7 @@ import SwiftUI
2 2
3struct WatchlistView: View { 3struct WatchlistView: View {
4 @Environment(\.appDensity) private var appDensity 4 @Environment(\.appDensity) private var appDensity
5 @Environment(\.accessibilityReduceMotion) private var reduceMotion
5 @Bindable var viewModel: DomainViewModel 6 @Bindable var viewModel: DomainViewModel
6 @Environment(\.dismiss) private var dismiss 7 @Environment(\.dismiss) private var dismiss
7 @State private var purchaseService = PurchaseService.shared 8 @State private var purchaseService = PurchaseService.shared
@@ -92,7 +93,7 @@ struct WatchlistView: View {
92 } 93 }
93 } 94 }
94 } 95 }
95 .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id)) 96 .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id))
96 .scrollContentBackground(.hidden) 97 .scrollContentBackground(.hidden)
97 .background(Color(.appBackground)) 98 .background(Color(.appBackground))
98 .navigationTitle("Watchlist") 99 .navigationTitle("Watchlist")
DomainDigWidget/DomainDigPortfolioWidget.swift +21 −8
@@ -97,16 +97,18 @@ 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), "healthy") 100 countPill(data.healthyCount, .healthy, "healthy")
101 countPill(data.warningCount, Color(.statusWarning), "warning") 101 countPill(data.warningCount, .warning, "warning")
102 countPill(data.criticalCount, Color(.statusCritical), "critical") 102 countPill(data.criticalCount, .critical, "critical")
103 } 103 }
104 } 104 }
105 } 105 }
106 106
107 private func countPill(_ value: Int, _ color: Color, _ label: String) -> some View { 107 private func countPill(_ value: Int, _ status: DomainDigWidgetStatus, _ label: String) -> some View {
108 HStack(spacing: 3) { 108 HStack(spacing: 3) {
109 Circle().fill(color).frame(width: 7, height: 7) 109 Image(systemName: symbol(for: status))
110 .font(.caption2)
111 .foregroundStyle(color(for: status))
110 Text("\(value)").font(.caption).fontWeight(.medium) 112 Text("\(value)").font(.caption).fontWeight(.medium)
111 } 113 }
112 // A coloured dot and a number say nothing on their own. 114 // A coloured dot and a number say nothing on their own.
@@ -163,9 +165,9 @@ struct DomainDigWidgetView: View {
163 165
164 private func domainRow(_ domain: DomainDigWidgetDomain) -> some View { 166 private func domainRow(_ domain: DomainDigWidgetDomain) -> some View {
165 HStack(spacing: 6) { 167 HStack(spacing: 6) {
166 Circle() 168 Image(systemName: symbol(for: domain.status))
167 .fill(color(for: domain.status)) 169 .font(.caption2)
168 .frame(width: 8, height: 8) 170 .foregroundStyle(color(for: domain.status))
169 if domain.isPinned { 171 if domain.isPinned {
170 Image(systemName: "pin.fill") 172 Image(systemName: "pin.fill")
171 .font(.caption2) 173 .font(.caption2)
@@ -219,4 +221,15 @@ struct DomainDigWidgetView: View {
219 case .critical: return Color(.statusCritical) 221 case .critical: return Color(.statusCritical)
220 } 222 }
221 } 223 }
224
225 /// Same symbol vocabulary as the in-app badges, so status survives without
226 /// colour (Differentiate Without Color, greyscale, colour-blind viewers) and
227 /// reads consistently across surfaces.
228 private func symbol(for status: DomainDigWidgetStatus) -> String {
229 switch status {
230 case .healthy: return "checkmark.circle.fill"
231 case .warning: return "exclamationmark.triangle.fill"
232 case .critical: return "exclamationmark.octagon.fill"
233 }
234 }
222} 235}