diff options
| author | Christian Cleberg <[email protected]> | 2026-07-21 20:47:34 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-21 21:40:44 -0500 |
| commit | c220e26bb496e455a5bdb6a8dc22abf09ecfed5f (patch) | |
| tree | 1709346960c291f9f7c304724c89432e7194affa | |
| parent | dc479a3ba0d27fe86509d0eb2d27b01132c2b37f (diff) | |
| download | domain-dig-c220e26bb496e455a5bdb6a8dc22abf09ecfed5f.tar.gz domain-dig-c220e26bb496e455a5bdb6a8dc22abf09ecfed5f.tar.bz2 domain-dig-c220e26bb496e455a5bdb6a8dc22abf09ecfed5f.zip | |
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.
| -rw-r--r-- | Docs/ACCESSIBILITY.md | 25 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 30 | ||||
| -rw-r--r-- | DomainDig/DashboardView.swift | 61 | ||||
| -rw-r--r-- | DomainDig/DomainDigUI.swift | 8 | ||||
| -rw-r--r-- | DomainDig/TimelineView.swift | 3 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 3 | ||||
| -rw-r--r-- | DomainDigWidget/DomainDigPortfolioWidget.swift | 29 |
7 files changed, 133 insertions, 26 deletions
diff --git a/Docs/ACCESSIBILITY.md b/Docs/ACCESSIBILITY.md index bc6f061..b35f02c 100644 --- a/Docs/ACCESSIBILITY.md +++ b/Docs/ACCESSIBILITY.md @@ -238,6 +238,31 @@ pre-commit that blocks every commit. A hook routinely bypassed with and cipher suites; extend it wherever the view model emits a fingerprint, serial, or record string. +## Color independence, motion, transparency + +- **Status is never colour-only.** In-app badges already pair a symbol with the + colour. The widget status dot is now an SF Symbol + (`checkmark.circle.fill` / `exclamationmark.triangle.fill` / + `exclamationmark.octagon.fill`) — the same vocabulary as the badges, so a + status reads consistently across surfaces and survives greyscale. +- **`accessibilityDifferentiateWithoutColor`** adds redundant shape only when the + user asks for it, avoiding clutter otherwise: the Dashboard summary-card dot + becomes a per-filter symbol, the selected quick-filter chip gains a checkmark + and border (selection was fill-colour only), and `LabeledValueRow` prefixes a + warning/failure symbol. +- **`accessibilityReduceMotion`** guards all five animation sites via + `withAnimation(reduceMotion ? nil : …)` / `.animation(reduceMotion ? nil : …)`: + `AppCopyButton`'s check cross-fade, `CollapsibleSectionView`'s expand/collapse, + `TimelineDiffView`'s scroll, and `WatchlistView`'s list reorder. +- **`accessibilityReduceTransparency`** swaps the single `.thinMaterial` for an + opaque `AppSurfaceElevated` capsule. + +These cannot be verified by `simctl`, which toggles only Increase Contrast — the +other three settings live in the simulator's Settings app. They are correct by +construction and build-clean; their runtime behaviour is part of the Phase 6 +manual pass. `SweepActivityController` was dropped from the motion list: it is +pure ActivityKit lifecycle with no animation to guard. + ### What the automated audit cannot check `performAccessibilityAudit()` validates descriptions, traits, contrast, hit diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index bc159d9..ea6ead8 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -2580,6 +2580,7 @@ struct SectionTrustMetadataView: View { struct LabeledValueRow: View { @Environment(\.appDensity) private var appDensity + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor let row: InfoRowViewData var body: some View { @@ -2589,7 +2590,15 @@ struct LabeledValueRow: View { Text(row.label) .font(appDensity.font(.caption2)) .foregroundStyle(Color(.appTextSecondary)) - valueText + HStack(alignment: .firstTextBaseline, spacing: 4) { + if differentiateWithoutColor, let symbol = toneSymbol { + Image(systemName: symbol) + .font(appDensity.font(.caption2)) + .foregroundStyle(ResultColors.color(for: row.tone)) + .accessibilityHidden(true) + } + valueText + } } .frame(maxWidth: .infinity, alignment: .leading) .layoutPriority(1) @@ -2602,6 +2611,17 @@ struct LabeledValueRow: View { .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) } + /// A leading symbol for warning/failure tones, shown only under Differentiate + /// Without Color so tone is not conveyed by text colour alone. Hidden from + /// VoiceOver — the value text already carries the meaning. + private var toneSymbol: String? { + switch row.tone { + case .warning: return "exclamationmark.triangle.fill" + case .failure: return "xmark.octagon.fill" + default: return nil + } + } + @ViewBuilder private var valueText: some View { let base = Text(row.value) @@ -3588,6 +3608,7 @@ private struct DataPortabilitySettingsView: View { private struct DataManagementSettingsView: View { @Bindable var viewModel: DomainViewModel + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @State private var showClearHistoryConfirmation = false @State private var showClearCacheConfirmation = false @@ -3695,7 +3716,12 @@ private struct DataManagementSettingsView: View { .foregroundStyle(Color(.appTextSecondary)) .padding(.horizontal, 14) .padding(.vertical, 10) - .background(.thinMaterial, in: Capsule()) + // Reduce Transparency swaps the blur for an opaque surface. + // On iOS 26+ the system also composites its own translucency + // that the app cannot declare — verify there too (Phase 6). + .background( + Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial)) + ) .padding(.bottom, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) } diff --git a/DomainDig/DashboardView.swift b/DomainDig/DashboardView.swift index 24955bb..d86e591 100644 --- a/DomainDig/DashboardView.swift +++ b/DomainDig/DashboardView.swift @@ -2,6 +2,7 @@ import SwiftUI struct DashboardView: View { @Environment(\.appDensity) private var appDensity + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor @Bindable var viewModel: DomainViewModel @State private var collapsedGroups = Set<String>() @@ -44,15 +45,33 @@ struct DashboardView: View { Button { viewModel.dashboardFilter = filter } label: { - Text(filter.title) - .font(appDensity.font(.caption, weight: .semibold)) - .foregroundStyle(viewModel.dashboardFilter == filter ? Color(.appOnAccent) : Color.primary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(viewModel.dashboardFilter == filter ? Color(.statusInfo) : Color(.appSurfaceElevated)) - .clipShape(Capsule()) + let isSelected = viewModel.dashboardFilter == filter + HStack(spacing: 4) { + // Selection is a fill-colour change; under + // Differentiate Without Color add a + // checkmark + border so it does not depend + // on hue alone. + if isSelected, differentiateWithoutColor { + Image(systemName: "checkmark") + .font(.caption2.weight(.bold)) + } + Text(filter.title) + .font(appDensity.font(.caption, weight: .semibold)) + } + .foregroundStyle(isSelected ? Color(.appOnAccent) : Color.primary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? Color(.statusInfo) : Color(.appSurfaceElevated)) + .overlay( + Capsule().strokeBorder( + Color.primary, + lineWidth: isSelected && differentiateWithoutColor ? 1.5 : 0 + ) + ) + .clipShape(Capsule()) } .buttonStyle(.plain) + .accessibilityAddTraits(viewModel.dashboardFilter == filter ? .isSelected : []) } } .padding(.vertical, 4) @@ -198,10 +217,18 @@ struct DashboardView: View { // Was a fixed 28pt, which ignored Dynamic Type entirely. .font(.system(.title, design: .rounded, weight: .bold)) .foregroundStyle(.primary) - HStack { - Circle() - .fill(tint) - .frame(width: 8, height: 8) + HStack(spacing: 5) { + // A symbol under Differentiate Without Color (where a bare + // colour dot conveys nothing), a plain dot otherwise. + if differentiateWithoutColor { + Image(systemName: symbol(for: filter)) + .font(.caption2) + .foregroundStyle(tint) + } else { + Circle() + .fill(tint) + .frame(width: 8, height: 8) + } Text(filter.title) .font(appDensity.font(.caption2, design: .default, weight: .semibold)) .foregroundStyle(tint) @@ -215,6 +242,18 @@ struct DashboardView: View { .buttonStyle(.plain) } + private func symbol(for filter: PortfolioFilterOption) -> String { + switch filter { + case .all: return "square.grid.2x2.fill" + case .healthy: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .critical: return "exclamationmark.octagon.fill" + case .changed: return "arrow.triangle.2.circlepath" + case .expiring: return "clock.badge.exclamationmark.fill" + case .unreachable: return "wifi.slash" + } + } + private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle { if viewModel.dashboardFilter == filter { // Uses the authored info surface rather than a translucent wash of diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift index de93a56..a50f766 100644 --- a/DomainDig/DomainDigUI.swift +++ b/DomainDig/DomainDigUI.swift @@ -260,6 +260,7 @@ struct AppStatusBadgeView: View { struct AppCopyButton: View { @Environment(\.appDensity) private var appDensity + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var didCopy = false /// Grows with Dynamic Type. The `max(_, minimumTapTarget)` floor matters @@ -274,13 +275,13 @@ struct AppCopyButton: View { Button { AppClipboard.copy(value) AppHaptics.copy() - withAnimation(.easeInOut(duration: 0.18)) { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) { didCopy = true } Task { try? await Task.sleep(nanoseconds: 900_000_000) await MainActor.run { - withAnimation(.easeInOut(duration: 0.18)) { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) { didCopy = false } } @@ -411,6 +412,7 @@ struct EmptyStateCardView: View { struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { @Environment(\.appDensity) private var appDensity + @Environment(\.accessibilityReduceMotion) private var reduceMotion let title: String @Binding var isCollapsed: Bool @@ -435,7 +437,7 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View { var body: some View { VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { Button { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) { isCollapsed.toggle() } } label: { diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift index 9b79adf..6979ca0 100644 --- a/DomainDig/TimelineView.swift +++ b/DomainDig/TimelineView.swift @@ -168,6 +168,7 @@ private struct TimelineRow: View { } struct TimelineDiffView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion @Bindable var viewModel: DomainViewModel let diff: DomainDiff @Binding var focusedSectionID: String? @@ -218,7 +219,7 @@ struct TimelineDiffView: View { private func scroll(proxy: ScrollViewProxy) { guard let focusedSectionID else { return } - withAnimation { + withAnimation(reduceMotion ? nil : .default) { proxy.scrollTo(focusedSectionID, anchor: .top) } } diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index 72eb04e..db79b0b 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -2,6 +2,7 @@ import SwiftUI struct WatchlistView: View { @Environment(\.appDensity) private var appDensity + @Environment(\.accessibilityReduceMotion) private var reduceMotion @Bindable var viewModel: DomainViewModel @Environment(\.dismiss) private var dismiss @State private var purchaseService = PurchaseService.shared @@ -92,7 +93,7 @@ struct WatchlistView: View { } } } - .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id)) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id)) .scrollContentBackground(.hidden) .background(Color(.appBackground)) .navigationTitle("Watchlist") diff --git a/DomainDigWidget/DomainDigPortfolioWidget.swift b/DomainDigWidget/DomainDigPortfolioWidget.swift index 66002c5..45772e3 100644 --- a/DomainDigWidget/DomainDigPortfolioWidget.swift +++ b/DomainDigWidget/DomainDigPortfolioWidget.swift @@ -97,16 +97,18 @@ struct DomainDigWidgetView: View { Spacer(minLength: 0) HStack(spacing: 10) { - countPill(data.healthyCount, Color(.statusPositive), "healthy") - countPill(data.warningCount, Color(.statusWarning), "warning") - countPill(data.criticalCount, Color(.statusCritical), "critical") + countPill(data.healthyCount, .healthy, "healthy") + countPill(data.warningCount, .warning, "warning") + countPill(data.criticalCount, .critical, "critical") } } } - private func countPill(_ value: Int, _ color: Color, _ label: String) -> some View { + private func countPill(_ value: Int, _ status: DomainDigWidgetStatus, _ label: String) -> some View { HStack(spacing: 3) { - Circle().fill(color).frame(width: 7, height: 7) + Image(systemName: symbol(for: status)) + .font(.caption2) + .foregroundStyle(color(for: status)) Text("\(value)").font(.caption).fontWeight(.medium) } // A coloured dot and a number say nothing on their own. @@ -163,9 +165,9 @@ struct DomainDigWidgetView: View { private func domainRow(_ domain: DomainDigWidgetDomain) -> some View { HStack(spacing: 6) { - Circle() - .fill(color(for: domain.status)) - .frame(width: 8, height: 8) + Image(systemName: symbol(for: domain.status)) + .font(.caption2) + .foregroundStyle(color(for: domain.status)) if domain.isPinned { Image(systemName: "pin.fill") .font(.caption2) @@ -219,4 +221,15 @@ struct DomainDigWidgetView: View { case .critical: return Color(.statusCritical) } } + + /// Same symbol vocabulary as the in-app badges, so status survives without + /// colour (Differentiate Without Color, greyscale, colour-blind viewers) and + /// reads consistently across surfaces. + private func symbol(for status: DomainDigWidgetStatus) -> String { + switch status { + case .healthy: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .critical: return "exclamationmark.octagon.fill" + } + } } |
