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 | 238 | and cipher suites; extend it wherever the view model emits a fingerprint, |
| 239 | 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 | |
| 260 | These cannot be verified by `simctl`, which toggles only Increase Contrast — the |
| 261 | other three settings live in the simulator's Settings app. They are correct by |
| 262 | construction and build-clean; their runtime behaviour is part of the Phase 6 |
| 263 | manual pass. `SweepActivityController` was dropped from the motion list: it is |
| 264 | pure ActivityKit lifecycle with no animation to guard. |
| 265 | |
| 241 | 266 | ### What the automated audit cannot check |
| 242 | 267 | |
| 243 | 268 | `performAccessibilityAudit()` validates descriptions, traits, contrast, hit |
DomainDig/ContentView.swift
+28 −2
| @@ -2580,6 +2580,7 @@ struct SectionTrustMetadataView: View { |
| 2580 | 2580 | |
| 2581 | 2581 | struct LabeledValueRow: View { |
| 2582 | 2582 | @Environment(\.appDensity) private var appDensity |
| 2583 | @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor |
| 2583 | 2584 | let row: InfoRowViewData |
| 2584 | 2585 | |
| 2585 | 2586 | var body: some View { |
| @@ -2589,7 +2590,15 @@ struct LabeledValueRow: View { |
| 2589 | 2590 | Text(row.label) |
| 2590 | 2591 | .font(appDensity.font(.caption2)) |
| 2591 | 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 | 2603 | .frame(maxWidth: .infinity, alignment: .leading) |
| 2595 | 2604 | .layoutPriority(1) |
| @@ -2602,6 +2611,17 @@ struct LabeledValueRow: View { |
| 2602 | 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 | 2625 | @ViewBuilder |
| 2606 | 2626 | private var valueText: some View { |
| 2607 | 2627 | let base = Text(row.value) |
| @@ -3588,6 +3608,7 @@ private struct DataPortabilitySettingsView: View { |
| 3588 | 3608 | |
| 3589 | 3609 | private struct DataManagementSettingsView: View { |
| 3590 | 3610 | @Bindable var viewModel: DomainViewModel |
| 3611 | @Environment(\.accessibilityReduceTransparency) private var reduceTransparency |
| 3591 | 3612 | |
| 3592 | 3613 | @State private var showClearHistoryConfirmation = false |
| 3593 | 3614 | @State private var showClearCacheConfirmation = false |
| @@ -3695,7 +3716,12 @@ private struct DataManagementSettingsView: View { |
| 3695 | 3716 | .foregroundStyle(Color(.appTextSecondary)) |
| 3696 | 3717 | .padding(.horizontal, 14) |
| 3697 | 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 | 3725 | .padding(.bottom, 8) |
| 3700 | 3726 | .transition(.move(edge: .bottom).combined(with: .opacity)) |
| 3701 | 3727 | } |
DomainDig/DashboardView.swift
+50 −11
| @@ -2,6 +2,7 @@ import SwiftUI |
| 2 | 2 | |
| 3 | 3 | struct DashboardView: View { |
| 4 | 4 | @Environment(\.appDensity) private var appDensity |
| 5 | @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor |
| 5 | 6 | @Bindable var viewModel: DomainViewModel |
| 6 | 7 | @State private var collapsedGroups = Set<String>() |
| 7 | 8 | |
| @@ -44,15 +45,33 @@ struct DashboardView: View { |
| 44 | 45 | Button { |
| 45 | 46 | viewModel.dashboardFilter = filter |
| 46 | 47 | } 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()) |
| 54 | 72 | } |
| 55 | 73 | .buttonStyle(.plain) |
| 74 | .accessibilityAddTraits(viewModel.dashboardFilter == filter ? .isSelected : []) |
| 56 | 75 | } |
| 57 | 76 | } |
| 58 | 77 | .padding(.vertical, 4) |
| @@ -198,10 +217,18 @@ struct DashboardView: View { |
| 198 | 217 | // Was a fixed 28pt, which ignored Dynamic Type entirely. |
| 199 | 218 | .font(.system(.title, design: .rounded, weight: .bold)) |
| 200 | 219 | .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 | } |
| 205 | 232 | Text(filter.title) |
| 206 | 233 | .font(appDensity.font(.caption2, design: .default, weight: .semibold)) |
| 207 | 234 | .foregroundStyle(tint) |
| @@ -215,6 +242,18 @@ struct DashboardView: View { |
| 215 | 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 | 257 | private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle { |
| 219 | 258 | if viewModel.dashboardFilter == filter { |
| 220 | 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 | |
| 261 | 261 | struct AppCopyButton: View { |
| 262 | 262 | @Environment(\.appDensity) private var appDensity |
| 263 | @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| 263 | 264 | @State private var didCopy = false |
| 264 | 265 | |
| 265 | 266 | /// Grows with Dynamic Type. The `max(_, minimumTapTarget)` floor matters |
| @@ -274,13 +275,13 @@ struct AppCopyButton: View { |
| 274 | 275 | Button { |
| 275 | 276 | AppClipboard.copy(value) |
| 276 | 277 | AppHaptics.copy() |
| 277 | | withAnimation(.easeInOut(duration: 0.18)) { |
| 278 | withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) { |
| 278 | 279 | didCopy = true |
| 279 | 280 | } |
| 280 | 281 | Task { |
| 281 | 282 | try? await Task.sleep(nanoseconds: 900_000_000) |
| 282 | 283 | await MainActor.run { |
| 283 | | withAnimation(.easeInOut(duration: 0.18)) { |
| 284 | withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) { |
| 284 | 285 | didCopy = false |
| 285 | 286 | } |
| 286 | 287 | } |
| @@ -411,6 +412,7 @@ struct EmptyStateCardView: View { |
| 411 | 412 | |
| 412 | 413 | struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { |
| 413 | 414 | @Environment(\.appDensity) private var appDensity |
| 415 | @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| 414 | 416 | |
| 415 | 417 | let title: String |
| 416 | 418 | @Binding var isCollapsed: Bool |
| @@ -435,7 +437,7 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { |
| 435 | 437 | var body: some View { |
| 436 | 438 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 437 | 439 | Button { |
| 438 | | withAnimation(.easeInOut(duration: 0.2)) { |
| 440 | withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) { |
| 439 | 441 | isCollapsed.toggle() |
| 440 | 442 | } |
| 441 | 443 | } label: { |
DomainDig/TimelineView.swift
+2 −1
| @@ -168,6 +168,7 @@ private struct TimelineRow: View { |
| 168 | 168 | } |
| 169 | 169 | |
| 170 | 170 | struct TimelineDiffView: View { |
| 171 | @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| 171 | 172 | @Bindable var viewModel: DomainViewModel |
| 172 | 173 | let diff: DomainDiff |
| 173 | 174 | @Binding var focusedSectionID: String? |
| @@ -218,7 +219,7 @@ struct TimelineDiffView: View { |
| 218 | 219 | |
| 219 | 220 | private func scroll(proxy: ScrollViewProxy) { |
| 220 | 221 | guard let focusedSectionID else { return } |
| 221 | | withAnimation { |
| 222 | withAnimation(reduceMotion ? nil : .default) { |
| 222 | 223 | proxy.scrollTo(focusedSectionID, anchor: .top) |
| 223 | 224 | } |
| 224 | 225 | } |
DomainDig/WatchlistView.swift
+2 −1
| @@ -2,6 +2,7 @@ import SwiftUI |
| 2 | 2 | |
| 3 | 3 | struct WatchlistView: View { |
| 4 | 4 | @Environment(\.appDensity) private var appDensity |
| 5 | @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| 5 | 6 | @Bindable var viewModel: DomainViewModel |
| 6 | 7 | @Environment(\.dismiss) private var dismiss |
| 7 | 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 | 97 | .scrollContentBackground(.hidden) |
| 97 | 98 | .background(Color(.appBackground)) |
| 98 | 99 | .navigationTitle("Watchlist") |
DomainDigWidget/DomainDigPortfolioWidget.swift
+21 −8
| @@ -97,16 +97,18 @@ struct DomainDigWidgetView: View { |
| 97 | 97 | Spacer(minLength: 0) |
| 98 | 98 | |
| 99 | 99 | 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") |
| 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 | 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 | 112 | Text("\(value)").font(.caption).fontWeight(.medium) |
| 111 | 113 | } |
| 112 | 114 | // A coloured dot and a number say nothing on their own. |
| @@ -163,9 +165,9 @@ struct DomainDigWidgetView: View { |
| 163 | 165 | |
| 164 | 166 | private func domainRow(_ domain: DomainDigWidgetDomain) -> some View { |
| 165 | 167 | 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)) |
| 169 | 171 | if domain.isPinned { |
| 170 | 172 | Image(systemName: "pin.fill") |
| 171 | 173 | .font(.caption2) |
| @@ -219,4 +221,15 @@ struct DomainDigWidgetView: View { |
| 219 | 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 | } |