From b5c351f313f1e557e388341ef45c9e345641e730 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 9 Feb 2026 08:42:26 -0600 Subject: [PATCH] accessibility motion support Signed-off-by: Matt Bruce --- Andromida/AndromidaApp.swift | 6 +++- .../Onboarding/FirstCheckInStepView.swift | 19 ++++++---- .../Onboarding/GoalSelectionStepView.swift | 14 ++++---- .../Onboarding/NotificationStepView.swift | 29 ++++++++++----- .../Onboarding/RitualPreviewStepView.swift | 3 +- .../Views/Onboarding/SetupWizardView.swift | 34 +++++++++++------- .../Onboarding/TimeSelectionStepView.swift | 14 ++++---- .../Views/Onboarding/WelcomeStepView.swift | 36 ++++++++++++------- .../Views/Onboarding/WhatsNextStepView.swift | 13 +++---- Andromida/App/Views/RootView.swift | 10 ++++-- Andromida/Shared/MotionSupport.swift | 25 +++++++++++++ PRD.md | 2 ++ README.md | 1 + 13 files changed, 144 insertions(+), 62 deletions(-) create mode 100644 Andromida/Shared/MotionSupport.swift diff --git a/Andromida/AndromidaApp.swift b/Andromida/AndromidaApp.swift index cfb9e89..ea6c1c7 100644 --- a/Andromida/AndromidaApp.swift +++ b/Andromida/AndromidaApp.swift @@ -110,8 +110,12 @@ struct AndromidaApp: App { reminderScheduler: store.reminderScheduler, onComplete: { justCompletedWizard = true - withAnimation(.easeInOut(duration: 0.5)) { + if UIAccessibility.isReduceMotionEnabled { hasCompletedSetupWizard = true + } else { + withAnimation(.easeInOut(duration: 0.5)) { + hasCompletedSetupWizard = true + } } } ) diff --git a/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift index 06bc4e3..77d74af 100644 --- a/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift +++ b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift @@ -4,6 +4,7 @@ import Bedrock /// Interactive tutorial step where users complete their first habit check-in. struct FirstCheckInStepView: View { @Bindable var store: RitualStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion let ritual: Ritual @Binding var hasCompletedCheckIn: Bool let onComplete: () -> Void @@ -121,7 +122,7 @@ struct FirstCheckInStepView: View { .frame(height: Design.Spacing.xxLarge) } .onAppear { - withAnimation(.easeOut(duration: 0.5)) { + withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) { animateContent = true } } @@ -159,7 +160,7 @@ struct FirstCheckInStepView: View { } .accessibilityIdentifier("onboarding.firstCheckInContinueToRituals") .padding(.horizontal, Design.Spacing.xxLarge) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity)) } Spacer() @@ -172,10 +173,15 @@ struct FirstCheckInStepView: View { private func triggerCelebration() { hasCompletedCheckIn = true - withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { + withOptionalAnimation(.spring(response: 0.5, dampingFraction: 0.7), reduceMotion: reduceMotion) { showCelebration = true } + if reduceMotion { + showContinueButton = true + return + } + // Show continue button after celebration settles DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { withAnimation(.easeOut(duration: 0.3)) { @@ -187,6 +193,7 @@ struct FirstCheckInStepView: View { /// A habit row styled for the onboarding flow with optional highlight. private struct OnboardingHabitRowView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion let title: String let symbolName: String let isCompleted: Bool @@ -219,13 +226,13 @@ private struct OnboardingHabitRowView: View { isHighlighted ? AppAccent.primary : Color.clear, lineWidth: 2 ) - .scaleEffect(pulseAnimation && isHighlighted ? 1.02 : 1.0) - .opacity(pulseAnimation && isHighlighted ? 0.5 : 1.0) + .scaleEffect(pulseAnimation && isHighlighted && !reduceMotion ? 1.02 : 1.0) + .opacity(pulseAnimation && isHighlighted && !reduceMotion ? 0.5 : 1.0) ) } .buttonStyle(.plain) .onAppear { - if isHighlighted { + if isHighlighted && !reduceMotion { withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { pulseAnimation = true } diff --git a/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift b/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift index 4cce106..0de6db0 100644 --- a/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift +++ b/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift @@ -4,6 +4,7 @@ import Bedrock /// The goal selection screen where users choose what they want to focus on. struct GoalSelectionStepView: View { @Binding var selectedGoals: [OnboardingGoal] + @Environment(\.accessibilityReduceMotion) private var reduceMotion let onContinue: () -> Void @State private var animateCards = false @@ -34,16 +35,17 @@ struct GoalSelectionStepView: View { goal: goal, isSelected: selectedGoals.contains(goal), onTap: { - withAnimation(.easeInOut(duration: Design.Animation.quick)) { + withOptionalAnimation(.easeInOut(duration: Design.Animation.quick), reduceMotion: reduceMotion) { toggleGoalSelection(goal) } } ) .opacity(animateCards ? 1 : 0) .offset(y: animateCards ? 0 : 20) - .animation( + .optionalAnimation( .easeOut(duration: 0.4).delay(Double(index) * 0.1), - value: animateCards + value: animateCards, + reduceMotion: reduceMotion ) } } @@ -65,13 +67,13 @@ struct GoalSelectionStepView: View { .accessibilityIdentifier("onboarding.goalContinue") .padding(.horizontal, Design.Spacing.xxLarge) .padding(.bottom, Design.Spacing.xxLarge) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity)) } } - .animation(.easeInOut(duration: Design.Animation.quick), value: !selectedGoals.isEmpty) + .optionalAnimation(.easeInOut(duration: Design.Animation.quick), value: !selectedGoals.isEmpty, reduceMotion: reduceMotion) .accessibilityIdentifier("onboarding.goalSelection") .onAppear { - withAnimation { + withOptionalAnimation(reduceMotion: reduceMotion) { animateCards = true } } diff --git a/Andromida/App/Views/Onboarding/NotificationStepView.swift b/Andromida/App/Views/Onboarding/NotificationStepView.swift index 07863cf..210e148 100644 --- a/Andromida/App/Views/Onboarding/NotificationStepView.swift +++ b/Andromida/App/Views/Onboarding/NotificationStepView.swift @@ -4,6 +4,7 @@ import Bedrock /// The notification permission screen where users can enable reminders. /// Shown after the first check-in to maximize conversion after experiencing value. struct NotificationStepView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion let selectedTimes: Set let reminderScheduler: ReminderScheduler let onComplete: () -> Void @@ -117,20 +118,22 @@ struct NotificationStepView: View { Circle() .fill(AppAccent.primary.opacity(0.1)) .frame(width: 160, height: 160) - .scaleEffect(animateIcon ? 1.1 : 1.0) - .animation( + .scaleEffect(animateIcon && !reduceMotion ? 1.1 : 1.0) + .optionalAnimation( .easeInOut(duration: 2).repeatForever(autoreverses: true), - value: animateIcon + value: animateIcon, + reduceMotion: reduceMotion ) // Middle circle Circle() .fill(AppAccent.primary.opacity(0.15)) .frame(width: 120, height: 120) - .scaleEffect(animateIcon ? 1.05 : 1.0) - .animation( + .scaleEffect(animateIcon && !reduceMotion ? 1.05 : 1.0) + .optionalAnimation( .easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.2), - value: animateIcon + value: animateIcon, + reduceMotion: reduceMotion ) // Inner circle with bell @@ -142,10 +145,11 @@ struct NotificationStepView: View { Image(systemName: "bell.fill") .font(.system(size: 36, weight: .medium)) .foregroundStyle(AppAccent.primary) - .rotationEffect(.degrees(animateIcon ? 10 : -10)) - .animation( + .rotationEffect(.degrees(animateIcon && !reduceMotion ? 10 : 0)) + .optionalAnimation( .easeInOut(duration: 0.5).repeatForever(autoreverses: true), - value: animateIcon + value: animateIcon, + reduceMotion: reduceMotion ) } } @@ -173,6 +177,13 @@ struct NotificationStepView: View { // MARK: - Animations private func startAnimations() { + if reduceMotion { + animateIcon = true + animateContent = true + animateButtons = true + return + } + // Stagger the animations withAnimation(.easeOut(duration: 0.6)) { animateIcon = true diff --git a/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift b/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift index d3fff56..ea18b7e 100644 --- a/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift +++ b/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift @@ -3,6 +3,7 @@ import Bedrock /// Shows a preview of a ritual preset before creation. struct RitualPreviewStepView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion let preset: RitualPreset let ritualIndex: Int? // e.g., 1 for "Ritual 1 of 2" let totalRituals: Int? // e.g., 2 @@ -105,7 +106,7 @@ struct RitualPreviewStepView: View { .frame(height: Design.Spacing.xxLarge) } .onAppear { - withAnimation(.easeOut(duration: 0.5)) { + withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) { animateContent = true } } diff --git a/Andromida/App/Views/Onboarding/SetupWizardView.swift b/Andromida/App/Views/Onboarding/SetupWizardView.swift index 82bcd5e..00af0e0 100644 --- a/Andromida/App/Views/Onboarding/SetupWizardView.swift +++ b/Andromida/App/Views/Onboarding/SetupWizardView.swift @@ -6,6 +6,7 @@ import Bedrock struct SetupWizardView: View { @Bindable var store: RitualStore @Bindable var categoryStore: CategoryStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion let reminderScheduler: ReminderScheduler let onComplete: () -> Void @@ -136,13 +137,10 @@ struct SetupWizardView: View { } } .adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait) - .transition(.asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - )) + .transition(stepTransition) } } - .animation(.easeInOut(duration: Design.Animation.standard), value: currentStep) + .optionalAnimation(.easeInOut(duration: Design.Animation.standard), value: currentStep, reduceMotion: reduceMotion) .alert(String(localized: "Skip setup?"), isPresented: $isShowingSkipConfirmation) { Button(String(localized: "Keep going"), role: .cancel) {} Button(String(localized: "Skip"), role: .destructive) { @@ -198,11 +196,21 @@ struct SetupWizardView: View { RoundedRectangle(cornerRadius: Design.CornerRadius.small) .fill(AppAccent.primary) .frame(width: geometry.size.width * progressValue, height: 4) - .animation(.easeInOut(duration: Design.Animation.standard), value: currentStep) + .optionalAnimation(.easeInOut(duration: Design.Animation.standard), value: currentStep, reduceMotion: reduceMotion) } } .frame(height: 4) } + + private var stepTransition: AnyTransition { + if reduceMotion { + return .opacity + } + return .asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + ) + } /// Adjusted progress value that accounts for skipped steps private var progressValue: Double { @@ -228,14 +236,14 @@ struct SetupWizardView: View { private func advanceToNextStep() { guard let nextStep = WizardStep(rawValue: currentStep.rawValue + 1) else { return } - withAnimation { + withOptionalAnimation(reduceMotion: reduceMotion) { currentStep = nextStep } } private func goBack() { if currentStep == .ritualPreview, currentPresetIndex > 0 { - withAnimation { + withOptionalAnimation(reduceMotion: reduceMotion) { currentPresetIndex -= 1 } return @@ -244,19 +252,19 @@ struct SetupWizardView: View { let targetStep = currentStep.rawValue - 1 guard targetStep >= 0, let previousStep = WizardStep(rawValue: targetStep) else { return } - withAnimation { + withOptionalAnimation(reduceMotion: reduceMotion) { currentStep = previousStep } } private func advanceToNotifications() { - withAnimation { + withOptionalAnimation(reduceMotion: reduceMotion) { currentStep = .notifications } } private func advanceToWhatsNext() { - withAnimation { + withOptionalAnimation(reduceMotion: reduceMotion) { currentStep = .whatsNext } } @@ -287,7 +295,7 @@ struct SetupWizardView: View { } } - withAnimation { + withOptionalAnimation(reduceMotion: reduceMotion) { pendingPresets = presets currentPresetIndex = 0 createdRituals = [] @@ -315,7 +323,7 @@ struct SetupWizardView: View { } private func advanceFromPreview() { - withAnimation { + withOptionalAnimation(reduceMotion: reduceMotion) { if currentPresetIndex + 1 < pendingPresets.count { currentPresetIndex += 1 } else if hasCreatedRitual { diff --git a/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift b/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift index bb1f2ce..90b0034 100644 --- a/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift +++ b/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift @@ -4,6 +4,7 @@ import Bedrock /// The time selection screen where users choose when they want to build habits. struct TimeSelectionStepView: View { @Binding var selectedTimes: Set + @Environment(\.accessibilityReduceMotion) private var reduceMotion let onContinue: () -> Void @State private var animateCards = false @@ -39,9 +40,10 @@ struct TimeSelectionStepView: View { ) .opacity(animateCards ? 1 : 0) .offset(y: animateCards ? 0 : 20) - .animation( + .optionalAnimation( .easeOut(duration: 0.4).delay(Double(index) * 0.1), - value: animateCards + value: animateCards, + reduceMotion: reduceMotion ) } } @@ -65,20 +67,20 @@ struct TimeSelectionStepView: View { .accessibilityIdentifier("onboarding.timeContinue") .padding(.horizontal, Design.Spacing.xxLarge) .padding(.bottom, Design.Spacing.xxLarge) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity)) } } - .animation(.easeInOut(duration: Design.Animation.quick), value: !selectedTimes.isEmpty) + .optionalAnimation(.easeInOut(duration: Design.Animation.quick), value: !selectedTimes.isEmpty, reduceMotion: reduceMotion) .accessibilityIdentifier("onboarding.timeSelection") .onAppear { - withAnimation { + withOptionalAnimation(reduceMotion: reduceMotion) { animateCards = true } } } private func toggleSelection(_ time: OnboardingTimePreference) { - withAnimation(.easeInOut(duration: Design.Animation.quick)) { + withOptionalAnimation(.easeInOut(duration: Design.Animation.quick), reduceMotion: reduceMotion) { if selectedTimes.contains(time) { selectedTimes.remove(time) } else { diff --git a/Andromida/App/Views/Onboarding/WelcomeStepView.swift b/Andromida/App/Views/Onboarding/WelcomeStepView.swift index ae9d230..fdf03fc 100644 --- a/Andromida/App/Views/Onboarding/WelcomeStepView.swift +++ b/Andromida/App/Views/Onboarding/WelcomeStepView.swift @@ -3,6 +3,7 @@ import Bedrock /// The welcome screen shown as the first step of the setup wizard. struct WelcomeStepView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion let onContinue: () -> Void @State private var animateRings = false @@ -70,10 +71,11 @@ struct WelcomeStepView: View { style: StrokeStyle(lineWidth: 8, lineCap: .round) ) .frame(width: 180, height: 180) - .rotationEffect(.degrees(animateRings ? 360 : 0)) - .animation( + .rotationEffect(.degrees(animateRings && !reduceMotion ? 360 : 0)) + .optionalAnimation( .linear(duration: 20).repeatForever(autoreverses: false), - value: animateRings + value: animateRings, + reduceMotion: reduceMotion ) // Middle ring with arc @@ -84,10 +86,11 @@ struct WelcomeStepView: View { style: StrokeStyle(lineWidth: 10, lineCap: .round) ) .frame(width: 140, height: 140) - .rotationEffect(.degrees(animateRings ? -360 : 0)) - .animation( + .rotationEffect(.degrees(animateRings && !reduceMotion ? -360 : 0)) + .optionalAnimation( .linear(duration: 15).repeatForever(autoreverses: false), - value: animateRings + value: animateRings, + reduceMotion: reduceMotion ) // Inner ring with arc @@ -98,18 +101,20 @@ struct WelcomeStepView: View { style: StrokeStyle(lineWidth: 12, lineCap: .round) ) .frame(width: 100, height: 100) - .rotationEffect(.degrees(animateRings ? 360 : 0)) - .animation( + .rotationEffect(.degrees(animateRings && !reduceMotion ? 360 : 0)) + .optionalAnimation( .linear(duration: 10).repeatForever(autoreverses: false), - value: animateRings + value: animateRings, + reduceMotion: reduceMotion ) // Center icon SymbolIcon("sparkles", size: .card, color: AppAccent.primary) - .scaleEffect(animateRings ? 1.1 : 1.0) - .animation( + .scaleEffect(animateRings && !reduceMotion ? 1.1 : 1.0) + .optionalAnimation( .easeInOut(duration: 1.5).repeatForever(autoreverses: true), - value: animateRings + value: animateRings, + reduceMotion: reduceMotion ) } } @@ -117,6 +122,13 @@ struct WelcomeStepView: View { // MARK: - Animations private func startAnimations() { + if reduceMotion { + animateRings = true + animateText = true + animateButton = true + return + } + // Stagger the animations withAnimation(.easeOut(duration: 0.6)) { animateRings = true diff --git a/Andromida/App/Views/Onboarding/WhatsNextStepView.swift b/Andromida/App/Views/Onboarding/WhatsNextStepView.swift index af0b5d0..afbaf98 100644 --- a/Andromida/App/Views/Onboarding/WhatsNextStepView.swift +++ b/Andromida/App/Views/Onboarding/WhatsNextStepView.swift @@ -3,6 +3,7 @@ import Bedrock /// Educational screen shown at the end of the wizard explaining the app's main features. struct WhatsNextStepView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion let onComplete: () -> Void @State private var animateContent = false @@ -37,7 +38,7 @@ struct WhatsNextStepView: View { ) .opacity(animateContent ? 1 : 0) .offset(y: animateContent ? 0 : 20) - .animation(.easeOut(duration: 0.4).delay(0.1), value: animateContent) + .optionalAnimation(.easeOut(duration: 0.4).delay(0.1), value: animateContent, reduceMotion: reduceMotion) FeatureHelpCard( icon: "sparkles", @@ -46,7 +47,7 @@ struct WhatsNextStepView: View { ) .opacity(animateContent ? 1 : 0) .offset(y: animateContent ? 0 : 20) - .animation(.easeOut(duration: 0.4).delay(0.2), value: animateContent) + .optionalAnimation(.easeOut(duration: 0.4).delay(0.2), value: animateContent, reduceMotion: reduceMotion) FeatureHelpCard( icon: "chart.bar.fill", @@ -55,12 +56,12 @@ struct WhatsNextStepView: View { ) .opacity(animateContent ? 1 : 0) .offset(y: animateContent ? 0 : 20) - .animation(.easeOut(duration: 0.4).delay(0.3), value: animateContent) + .optionalAnimation(.easeOut(duration: 0.4).delay(0.3), value: animateContent, reduceMotion: reduceMotion) WidgetDiscoveryCard(onLearnMore: { isShowingWidgetHelp = true }) .opacity(animateContent ? 1 : 0) .offset(y: animateContent ? 0 : 20) - .animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent) + .optionalAnimation(.easeOut(duration: 0.4).delay(0.4), value: animateContent, reduceMotion: reduceMotion) } .padding(.horizontal, Design.Spacing.large) @@ -77,13 +78,13 @@ struct WhatsNextStepView: View { .accessibilityIdentifier("onboarding.letsGo") .padding(.horizontal, Design.Spacing.xxLarge) .opacity(animateContent ? 1 : 0) - .animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent) + .optionalAnimation(.easeOut(duration: 0.4).delay(0.4), value: animateContent, reduceMotion: reduceMotion) Spacer() .frame(height: Design.Spacing.xxLarge) } .onAppear { - withAnimation(.easeOut(duration: 0.5)) { + withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) { animateContent = true } } diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index 3c225c4..578c914 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -6,6 +6,7 @@ struct RootView: View { @Bindable var settingsStore: SettingsStore @Bindable var categoryStore: CategoryStore @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var selectedTab: RootTab @State private var analyticsPrewarmTask: Task? @State private var isForegroundRefreshing = false @@ -98,8 +99,13 @@ struct RootView: View { } .tint(AppAccent.primary) .background(AppSurface.primary.ignoresSafeArea()) - .animation(.easeInOut(duration: 0.12), value: isForegroundRefreshing) - .animation(.easeIn(duration: 0.05), value: isResumingFromBackground) + .optionalAnimation(.easeInOut(duration: 0.12), value: isForegroundRefreshing, reduceMotion: reduceMotion) + .optionalAnimation(.easeIn(duration: 0.05), value: isResumingFromBackground, reduceMotion: reduceMotion) + .transaction { transaction in + if reduceMotion { + transaction.animation = nil + } + } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { store.reminderScheduler.clearBadge() diff --git a/Andromida/Shared/MotionSupport.swift b/Andromida/Shared/MotionSupport.swift new file mode 100644 index 0000000..de6519d --- /dev/null +++ b/Andromida/Shared/MotionSupport.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Executes state updates with animation only when Reduce Motion is disabled. +func withOptionalAnimation( + _ animation: Animation? = .default, + reduceMotion: Bool, + _ updates: () -> Void +) { + if reduceMotion { + updates() + } else { + withAnimation(animation, updates) + } +} + +extension View { + /// Applies animation only when Reduce Motion is disabled. + func optionalAnimation( + _ animation: Animation?, + value: Value, + reduceMotion: Bool + ) -> some View { + self.animation(reduceMotion ? nil : animation, value: value) + } +} diff --git a/PRD.md b/PRD.md index 12a754e..e437283 100644 --- a/PRD.md +++ b/PRD.md @@ -214,6 +214,8 @@ URL scheme support for navigation. | NFR-A11Y-03 | Ensure sufficient color contrast ratios | | NFR-A11Y-04 | Support reduced motion preferences | +Implementation note: Onboarding flows and root-level shell transitions should avoid motion-heavy animations when iOS Reduce Motion is enabled. + ### 4.3 Localization | Requirement | Description | diff --git a/README.md b/README.md index a962662..4f5517d 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA): ## Notes - App is configured with a dark theme; the root view enforces `.preferredColorScheme(.dark)` to ensure semantic text legibility. +- Setup wizard and root shell animations respect the iOS **Reduce Motion** accessibility setting. - The launch storyboard matches the branding primary color to avoid a white flash. - App icon generation is available in DEBUG builds from Settings. - Fresh installs start with no rituals; users create their own from scratch or presets.