feat: add WCAG AA accessibility app-wide, fix CloudKit container config, remove debug logs

- Add VoiceOver labels, hints, and element grouping across all 60+ views
- Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations
- Replace fixed font sizes with semantic Dynamic Type styles
- Hide decorative elements from VoiceOver with .accessibilityHidden(true)
- Add .minimumHitTarget() modifier ensuring 44pt touch targets
- Add AccessibilityAnnouncer utility for VoiceOver announcements
- Improve color contrast values in Theme.swift for WCAG AA compliance
- Extract CloudKitContainerConfig for explicit container identity
- Remove PostHog debug console log from AnalyticsManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-11 09:27:23 -06:00
parent e9c15d70b1
commit d63d311cab
77 changed files with 982 additions and 263 deletions

View File

@@ -28,7 +28,9 @@ struct AnimatedSportsBackground: View {
AnimatedSportsIcon(index: index, animate: animate)
}
}
.accessibilityHidden(true)
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: 5.0).repeatForever(autoreverses: true)) {
animate = true
}
@@ -173,6 +175,8 @@ struct AnimatedSportsIcon: View {
}
private func triggerGlow() {
guard !Theme.Animation.prefersReducedMotion else { return }
// Slow fade in
withAnimation(.easeIn(duration: 0.8)) {
glowOpacity = 1

View File

@@ -68,7 +68,9 @@ struct AnimatedRouteGraphic: View {
)
}
}
.accessibilityHidden(true)
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: Theme.Animation.routeDrawDuration).repeatForever(autoreverses: false)) {
animationProgress = 1
}
@@ -126,7 +128,9 @@ struct PulsingDot: View {
.frame(width: size, height: size)
.shadow(color: color.opacity(0.5), radius: 4)
}
.accessibilityHidden(true)
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) {
isPulsing = true
}
@@ -171,6 +175,7 @@ struct RoutePreviewStrip: View {
}
.padding(.horizontal, Theme.Spacing.md)
}
.accessibilityHidden(true)
}
private func abbreviateCity(_ city: String) -> String {
@@ -196,6 +201,7 @@ struct StatPill: View {
Text(value)
.font(.footnote)
}
.accessibilityElement(children: .combine)
.foregroundStyle(Theme.textSecondary(colorScheme))
.padding(.horizontal, 12)
.padding(.vertical, 6)
@@ -219,6 +225,7 @@ struct EmptyStateView: View {
Image(systemName: icon)
.font(.largeTitle)
.foregroundStyle(Theme.warmOrange.opacity(0.7))
.accessibilityHidden(true)
VStack(spacing: 8) {
Text(title)

View File

@@ -47,7 +47,9 @@ struct PlaceholderRectangle: View {
.fill(placeholderColor)
.frame(width: width, height: height)
.opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity)
.accessibilityHidden(true)
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true
}
@@ -72,7 +74,9 @@ struct PlaceholderCircle: View {
.fill(placeholderColor)
.frame(width: diameter, height: diameter)
.opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity)
.accessibilityHidden(true)
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true
}
@@ -98,7 +102,9 @@ struct PlaceholderCapsule: View {
.fill(placeholderColor)
.frame(width: width, height: height)
.opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity)
.accessibilityHidden(true)
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true
}
@@ -145,7 +151,10 @@ struct PlaceholderCard: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Loading content")
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true
}
@@ -185,6 +194,7 @@ struct PlaceholderListRow: View {
}
.padding(Theme.Spacing.md)
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true
}

View File

@@ -25,10 +25,11 @@ struct LoadingSheet: View {
// Dimmed background
Color.black.opacity(Self.backgroundOpacity)
.ignoresSafeArea()
.accessibilityHidden(true)
// Centered card
VStack(spacing: Theme.Spacing.lg) {
LoadingSpinner(size: .large)
LoadingSpinner(size: .large, label: label)
VStack(spacing: Theme.Spacing.xs) {
Text(label)

View File

@@ -56,6 +56,7 @@ struct LoadingSpinner: View {
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
.accessibilityLabel(label ?? "Loading")
}
private var spinnerView: some View {
@@ -63,15 +64,18 @@ struct LoadingSpinner: View {
// Background track - subtle gray like Apple's native spinner
Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: size.strokeWidth)
.accessibilityHidden(true)
// Rotating arc (270 degrees) - gray like Apple's ProgressView
Circle()
.trim(from: 0, to: 0.75)
.stroke(Color.secondary, style: StrokeStyle(lineWidth: size.strokeWidth, lineCap: .round))
.rotationEffect(.degrees(rotation))
.accessibilityHidden(true)
}
.frame(width: size.diameter, height: size.diameter)
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotation = 360
}

View File

@@ -90,6 +90,7 @@ struct SportActionButton: View {
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(sport.themeColor)
.accessibilityHidden(true)
}
Text(sport.rawValue)
@@ -100,13 +101,15 @@ struct SportActionButton: View {
.scaleEffect(isPressed ? 0.9 : 1.0)
}
.buttonStyle(.plain)
.accessibilityLabel(sport.rawValue)
.accessibilityAddTraits(.isButton)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true }
Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = true }
}
.onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false }
Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = false }
}
)
}
@@ -140,23 +143,27 @@ struct SportToggleButton: View {
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? .white : sport.themeColor)
.accessibilityHidden(true)
}
Text(sport.rawValue)
.font(.system(size: 10, weight: isSelected ? .semibold : .medium))
.font(.caption2.weight(isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity)
.scaleEffect(isPressed ? 0.9 : 1.0)
}
.buttonStyle(.plain)
.accessibilityLabel(sport.rawValue)
.accessibilityAddTraits(.isToggle)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true }
Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = true }
}
.onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false }
Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = false }
}
)
}
@@ -190,6 +197,7 @@ struct SportProgressButton: View {
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
Text(sport.rawValue)
@@ -200,13 +208,17 @@ struct SportProgressButton: View {
.scaleEffect(isPressed ? 0.9 : 1.0)
}
.buttonStyle(.plain)
.accessibilityLabel("\(sport.rawValue), \(Int(progress * 100)) percent visited")
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(.isButton)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true }
Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = true }
}
.onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false }
Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = false }
}
)
}

View File

@@ -259,7 +259,15 @@ enum Theme {
}
static var darkSurfaceGlow: Color {
warmOrange.opacity(0.15)
switch current {
case .teal: return Color(hex: "92AEAB")
case .orbit: return Color(hex: "708BAA")
case .retro: return Color(hex: "87A0BA")
case .clutch: return Color(hex: "6D8399")
case .monochrome: return Color(hex: "707070")
case .sunset: return Color(hex: "84719A")
case .midnight: return Color(hex: "7F95B0")
}
}
static var darkTextPrimary: Color {
@@ -288,13 +296,13 @@ enum Theme {
static var darkTextMuted: Color {
switch current {
case .teal: return Color(hex: "7FADA8")
case .orbit: return Color(hex: "8090A0")
case .retro: return Color(hex: "7898B8")
case .clutch: return Color(hex: "8898A8")
case .monochrome: return Color(hex: "707070")
case .sunset: return Color(hex: "9D8AA8")
case .midnight: return Color(hex: "64748B")
case .teal: return Color(hex: "B4CFCC")
case .orbit: return Color(hex: "A0B1C3")
case .retro: return Color(hex: "A4BBCF")
case .clutch: return Color(hex: "A8B7C6")
case .monochrome: return Color(hex: "B0B0B0")
case .sunset: return Color(hex: "C9B5D3")
case .midnight: return Color(hex: "A3B3C8")
}
}
@@ -329,7 +337,15 @@ enum Theme {
static var lightCardBackgroundElevated: Color { lightBackground1 }
static var lightSurfaceBorder: Color {
warmOrange.opacity(0.3)
switch current {
case .teal: return Color(hex: "568E88")
case .orbit: return Color(hex: "5F86AE")
case .retro: return Color(hex: "5D90B8")
case .clutch: return Color(hex: "6E8194")
case .monochrome: return Color(hex: "7A7A7A")
case .sunset: return Color(hex: "B98474")
case .midnight: return Color(hex: "7789A3")
}
}
static var lightTextPrimary: Color {
@@ -358,12 +374,12 @@ enum Theme {
static var lightTextMuted: Color {
switch current {
case .teal: return Color(hex: "5A9A94")
case .orbit: return Color(hex: "5A7A9A")
case .retro: return Color(hex: "5A8AAA")
case .clutch: return Color(hex: "6A7A8A")
case .teal: return Color(hex: "497F79")
case .orbit: return Color(hex: "537596")
case .retro: return Color(hex: "4A7A99")
case .clutch: return Color(hex: "5A6B7E")
case .monochrome: return Color(hex: "707070")
case .sunset: return Color(hex: "9A6A5A")
case .sunset: return Color(hex: "8A5A4A")
case .midnight: return Color(hex: "64748B")
}
}
@@ -442,6 +458,20 @@ enum Theme {
static var gentleSpring: SwiftUI.Animation {
.spring(response: 0.5, dampingFraction: 0.8)
}
/// Whether the system Reduce Motion preference is enabled.
static var prefersReducedMotion: Bool {
UIAccessibility.isReduceMotionEnabled
}
/// Performs a state change with animation, or instantly if Reduce Motion is enabled.
static func withMotion(_ animation: SwiftUI.Animation = spring, _ body: () -> Void) {
if prefersReducedMotion {
body()
} else {
withAnimation(animation) { body() }
}
}
}
}

View File

@@ -61,7 +61,10 @@ struct PressableButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? scale : 1.0)
.animation(Theme.Animation.spring, value: configuration.isPressed)
.animation(
Theme.Animation.prefersReducedMotion ? nil : Theme.Animation.spring,
value: configuration.isPressed
)
}
}
@@ -71,6 +74,36 @@ extension View {
}
}
// MARK: - Minimum Hit Target Modifier
private struct MinimumHitTargetModifier: ViewModifier {
let size: CGFloat
func body(content: Content) -> some View {
content
.frame(minWidth: size, minHeight: size, alignment: .center)
.contentShape(Rectangle())
}
}
extension View {
/// Ensures interactive elements meet the recommended 44x44pt touch area.
func minimumHitTarget(_ size: CGFloat = 44) -> some View {
modifier(MinimumHitTargetModifier(size: size))
}
}
// MARK: - Accessibility Announcements
enum AccessibilityAnnouncer {
static func announce(_ message: String) {
guard !message.isEmpty else { return }
DispatchQueue.main.async {
UIAccessibility.post(notification: .announcement, argument: message)
}
}
}
// MARK: - Shimmer Effect Modifier
struct ShimmerEffect: ViewModifier {
@@ -79,22 +112,25 @@ struct ShimmerEffect: ViewModifier {
func body(content: Content) -> some View {
content
.overlay {
GeometryReader { geo in
LinearGradient(
colors: [
.clear,
Color.white.opacity(0.3),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geo.size.width * 2)
.offset(x: -geo.size.width + (geo.size.width * 2 * phase))
if !Theme.Animation.prefersReducedMotion {
GeometryReader { geo in
LinearGradient(
colors: [
.clear,
Color.white.opacity(0.3),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geo.size.width * 2)
.offset(x: -geo.size.width + (geo.size.width * 2 * phase))
}
.mask(content)
}
.mask(content)
}
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
@@ -120,8 +156,12 @@ struct StaggeredAnimation: ViewModifier {
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.onAppear {
withAnimation(Theme.Animation.spring.delay(Double(index) * delay)) {
if Theme.Animation.prefersReducedMotion {
appeared = true
} else {
withAnimation(Theme.Animation.spring.delay(Double(index) * delay)) {
appeared = true
}
}
}
}
@@ -184,7 +224,7 @@ struct ThemedBackground: ViewModifier {
func body(content: Content) -> some View {
content
.background {
if DesignStyleManager.shared.animationsEnabled {
if DesignStyleManager.shared.animationsEnabled && !Theme.Animation.prefersReducedMotion {
AnimatedSportsBackground()
.ignoresSafeArea()
} else {