summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-21 20:47:34 -0500
committerChristian Cleberg <[email protected]>2026-07-21 21:40:44 -0500
commitc220e26bb496e455a5bdb6a8dc22abf09ecfed5f (patch)
tree1709346960c291f9f7c304724c89432e7194affa /DomainDig
parentdc479a3ba0d27fe86509d0eb2d27b01132c2b37f (diff)
downloaddomain-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.
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/ContentView.swift30
-rw-r--r--DomainDig/DashboardView.swift61
-rw-r--r--DomainDig/DomainDigUI.swift8
-rw-r--r--DomainDig/TimelineView.swift3
-rw-r--r--DomainDig/WatchlistView.swift3
5 files changed, 87 insertions, 18 deletions
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")