Commit c220e26bb4
Unsigned
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 | |||
| 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 | ### 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 | ||
| 2581 | struct LabeledValueRow: View { | 2581 | struct 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 | ||
| 3589 | private struct DataManagementSettingsView: View { | 3609 | private 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 | ||
| 3 | struct DashboardView: View { | 3 | struct 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 | ||
| 261 | struct AppCopyButton: View { | 261 | struct 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 | ||
| 412 | struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { | 413 | struct 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 | ||
| 170 | struct TimelineDiffView: View { | 170 | struct 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 | ||
| 3 | struct WatchlistView: View { | 3 | struct 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 | } |