krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

c220e26bb496e455a5bdb6a8dc22abf09ecfed5f

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-22T01:47:34Z

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.
 Docs/ACCESSIBILITY.md                          | 25 +++++++++++
 DomainDig/ContentView.swift                    | 30 ++++++++++++-
 DomainDig/DashboardView.swift                  | 61 +++++++++++++++++++++-----
 DomainDig/DomainDigUI.swift                    |  8 ++--
 DomainDig/TimelineView.swift                   |  3 +-
 DomainDig/WatchlistView.swift                  |  3 +-
 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"
+        }
+    }
 }