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
238238 and cipher suites; extend it wherever the view model emits a fingerprint,
239239 serial, or record string.
240240
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
241266### What the automated audit cannot check
242267
243268`performAccessibilityAudit()` validates descriptions, traits, contrast, hit
DomainDig/ContentView.swift +28 −2
@@ -2580,6 +2580,7 @@ struct SectionTrustMetadataView: View {
25802580
25812581struct LabeledValueRow: View {
25822582 @Environment(\.appDensity) private var appDensity
2583 @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
25832584 let row: InfoRowViewData
25842585
25852586 var body: some View {
@@ -2589,7 +2590,15 @@ struct LabeledValueRow: View {
25892590 Text(row.label)
25902591 .font(appDensity.font(.caption2))
25912592 .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 }
25932602 }
25942603 .frame(maxWidth: .infinity, alignment: .leading)
25952604 .layoutPriority(1)
@@ -2602,6 +2611,17 @@ struct LabeledValueRow: View {
26022611 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
26032612 }
26042613
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
26052625 @ViewBuilder
26062626 private var valueText: some View {
26072627 let base = Text(row.value)
@@ -3588,6 +3608,7 @@ private struct DataPortabilitySettingsView: View {
35883608
35893609private struct DataManagementSettingsView: View {
35903610 @Bindable var viewModel: DomainViewModel
3611 @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
35913612
35923613 @State private var showClearHistoryConfirmation = false
35933614 @State private var showClearCacheConfirmation = false
@@ -3695,7 +3716,12 @@ private struct DataManagementSettingsView: View {
36953716 .foregroundStyle(Color(.appTextSecondary))
36963717 .padding(.horizontal, 14)
36973718 .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 )
36993725 .padding(.bottom, 8)
37003726 .transition(.move(edge: .bottom).combined(with: .opacity))
37013727 }
DomainDig/DashboardView.swift +50 −11
@@ -2,6 +2,7 @@ import SwiftUI
22
33struct DashboardView: View {
44 @Environment(\.appDensity) private var appDensity
5 @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
56 @Bindable var viewModel: DomainViewModel
67 @State private var collapsedGroups = Set<String>()
78
@@ -44,15 +45,33 @@ struct DashboardView: View {
4445 Button {
4546 viewModel.dashboardFilter = filter
4647 } label: {
47 Text(filter.title)
48 .font(appDensity.font(.caption, weight: .semibold))
49 .foregroundStyle(viewModel.dashboardFilter == filter ? Color(.appOnAccent) : Color.primary)
50 .padding(.horizontal, 12)
51 .padding(.vertical, 8)
52 .background(viewModel.dashboardFilter == filter ? Color(.statusInfo) : Color(.appSurfaceElevated))
53 .clipShape(Capsule())
48 let isSelected = viewModel.dashboardFilter == filter
49 HStack(spacing: 4) {
50 // Selection is a fill-colour change; under
51 // Differentiate Without Color add a
52 // checkmark + border so it does not depend
53 // on hue alone.
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())
5472 }
5573 .buttonStyle(.plain)
74 .accessibilityAddTraits(viewModel.dashboardFilter == filter ? .isSelected : [])
5675 }
5776 }
5877 .padding(.vertical, 4)
@@ -198,10 +217,18 @@ struct DashboardView: View {
198217 // Was a fixed 28pt, which ignored Dynamic Type entirely.
199218 .font(.system(.title, design: .rounded, weight: .bold))
200219 .foregroundStyle(.primary)
201 HStack {
202 Circle()
203 .fill(tint)
204 .frame(width: 8, height: 8)
220 HStack(spacing: 5) {
221 // A symbol under Differentiate Without Color (where a bare
222 // colour dot conveys nothing), a plain dot otherwise.
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 }
205232 Text(filter.title)
206233 .font(appDensity.font(.caption2, design: .default, weight: .semibold))
207234 .foregroundStyle(tint)
@@ -215,6 +242,18 @@ struct DashboardView: View {
215242 .buttonStyle(.plain)
216243 }
217244
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
218257 private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle {
219258 if viewModel.dashboardFilter == filter {
220259 // Uses the authored info surface rather than a translucent wash of
DomainDig/DomainDigUI.swift +5 −3
@@ -260,6 +260,7 @@ struct AppStatusBadgeView: View {
260260
261261struct AppCopyButton: View {
262262 @Environment(\.appDensity) private var appDensity
263 @Environment(\.accessibilityReduceMotion) private var reduceMotion
263264 @State private var didCopy = false
264265
265266 /// Grows with Dynamic Type. The `max(_, minimumTapTarget)` floor matters
@@ -274,13 +275,13 @@ struct AppCopyButton: View {
274275 Button {
275276 AppClipboard.copy(value)
276277 AppHaptics.copy()
277 withAnimation(.easeInOut(duration: 0.18)) {
278 withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) {
278279 didCopy = true
279280 }
280281 Task {
281282 try? await Task.sleep(nanoseconds: 900_000_000)
282283 await MainActor.run {
283 withAnimation(.easeInOut(duration: 0.18)) {
284 withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) {
284285 didCopy = false
285286 }
286287 }
@@ -411,6 +412,7 @@ struct EmptyStateCardView: View {
411412
412413struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
413414 @Environment(\.appDensity) private var appDensity
415 @Environment(\.accessibilityReduceMotion) private var reduceMotion
414416
415417 let title: String
416418 @Binding var isCollapsed: Bool
@@ -435,7 +437,7 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
435437 var body: some View {
436438 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
437439 Button {
438 withAnimation(.easeInOut(duration: 0.2)) {
440 withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) {
439441 isCollapsed.toggle()
440442 }
441443 } label: {
DomainDig/TimelineView.swift +2 −1
@@ -168,6 +168,7 @@ private struct TimelineRow: View {
168168}
169169
170170struct TimelineDiffView: View {
171 @Environment(\.accessibilityReduceMotion) private var reduceMotion
171172 @Bindable var viewModel: DomainViewModel
172173 let diff: DomainDiff
173174 @Binding var focusedSectionID: String?
@@ -218,7 +219,7 @@ struct TimelineDiffView: View {
218219
219220 private func scroll(proxy: ScrollViewProxy) {
220221 guard let focusedSectionID else { return }
221 withAnimation {
222 withAnimation(reduceMotion ? nil : .default) {
222223 proxy.scrollTo(focusedSectionID, anchor: .top)
223224 }
224225 }
DomainDig/WatchlistView.swift +2 −1
@@ -2,6 +2,7 @@ import SwiftUI
22
33struct WatchlistView: View {
44 @Environment(\.appDensity) private var appDensity
5 @Environment(\.accessibilityReduceMotion) private var reduceMotion
56 @Bindable var viewModel: DomainViewModel
67 @Environment(\.dismiss) private var dismiss
78 @State private var purchaseService = PurchaseService.shared
@@ -92,7 +93,7 @@ struct WatchlistView: View {
9293 }
9394 }
9495 }
95 .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id))
96 .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id))
9697 .scrollContentBackground(.hidden)
9798 .background(Color(.appBackground))
9899 .navigationTitle("Watchlist")
DomainDigWidget/DomainDigPortfolioWidget.swift +21 −8
@@ -97,16 +97,18 @@ struct DomainDigWidgetView: View {
9797 Spacer(minLength: 0)
9898
9999 HStack(spacing: 10) {
100 countPill(data.healthyCount, Color(.statusPositive), "healthy")
101 countPill(data.warningCount, Color(.statusWarning), "warning")
102 countPill(data.criticalCount, Color(.statusCritical), "critical")
100 countPill(data.healthyCount, .healthy, "healthy")
101 countPill(data.warningCount, .warning, "warning")
102 countPill(data.criticalCount, .critical, "critical")
103103 }
104104 }
105105 }
106106
107 private func countPill(_ value: Int, _ color: Color, _ label: String) -> some View {
107 private func countPill(_ value: Int, _ status: DomainDigWidgetStatus, _ label: String) -> some View {
108108 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))
110112 Text("\(value)").font(.caption).fontWeight(.medium)
111113 }
112114 // A coloured dot and a number say nothing on their own.
@@ -163,9 +165,9 @@ struct DomainDigWidgetView: View {
163165
164166 private func domainRow(_ domain: DomainDigWidgetDomain) -> some View {
165167 HStack(spacing: 6) {
166 Circle()
167 .fill(color(for: domain.status))
168 .frame(width: 8, height: 8)
168 Image(systemName: symbol(for: domain.status))
169 .font(.caption2)
170 .foregroundStyle(color(for: domain.status))
169171 if domain.isPinned {
170172 Image(systemName: "pin.fill")
171173 .font(.caption2)
@@ -219,4 +221,15 @@ struct DomainDigWidgetView: View {
219221 case .critical: return Color(.statusCritical)
220222 }
221223 }
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 }
222235}