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

@@ -115,14 +115,16 @@ struct AchievementsListView: View {
.frame(width: 64, height: 64)
Image(systemName: selectedSport?.iconName ?? "trophy.fill")
.font(.system(size: 28))
.font(.title2)
.foregroundStyle(earned > 0 ? completedGold : accentColor)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(earned)")
.font(.system(size: 36, weight: .bold, design: .rounded))
.font(.system(.largeTitle, design: .rounded).weight(.bold))
.monospacedDigit()
.foregroundStyle(earned > 0 ? completedGold : Theme.textPrimary(colorScheme))
Text("/ \(total)")
.font(.title2)
@@ -174,7 +176,7 @@ struct AchievementsListView: View {
color: Theme.warmOrange,
isSelected: selectedSport == nil
) {
withAnimation(Theme.Animation.spring) {
Theme.Animation.withMotion(Theme.Animation.spring) {
selectedSport = nil
}
}
@@ -187,7 +189,7 @@ struct AchievementsListView: View {
color: sport.themeColor,
isSelected: selectedSport == sport
) {
withAnimation(Theme.Animation.spring) {
Theme.Animation.withMotion(Theme.Animation.spring) {
selectedSport = sport
}
}
@@ -287,6 +289,8 @@ struct SportFilterButton: View {
}
}
.buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
@@ -318,8 +322,9 @@ struct AchievementCard: View {
}
Image(systemName: achievement.definition.iconName)
.font(.system(size: 28))
.font(.title2)
.foregroundStyle(badgeIconColor)
.accessibilityHidden(true)
if !achievement.isEarned {
Circle()
@@ -329,6 +334,7 @@ struct AchievementCard: View {
Image(systemName: "lock.fill")
.font(.subheadline)
.foregroundStyle(.white)
.accessibilityHidden(true)
}
}
@@ -346,6 +352,7 @@ struct AchievementCard: View {
HStack(spacing: 4) {
Image(systemName: "checkmark.seal.fill")
.font(.caption)
.accessibilityHidden(true)
if let earnedAt = achievement.earnedAt {
Text(earnedAt.formatted(date: .abbreviated, time: .omitted))
} else {
@@ -376,6 +383,7 @@ struct AchievementCard: View {
}
.shadow(color: achievement.isEarned ? completedGold.opacity(0.3) : Theme.cardShadow(colorScheme), radius: achievement.isEarned ? 8 : 5, y: 2)
.opacity(achievement.isEarned ? 1.0 : 0.7)
.accessibilityElement(children: .combine)
}
private var badgeBackgroundColor: Color {
@@ -492,8 +500,9 @@ struct AchievementDetailSheet: View {
}
Image(systemName: achievement.definition.iconName)
.font(.system(size: 56))
.font(.largeTitle)
.foregroundStyle(badgeIconColor)
.accessibilityHidden(true)
if !achievement.isEarned {
Circle()
@@ -501,8 +510,9 @@ struct AchievementDetailSheet: View {
.frame(width: 120, height: 120)
Image(systemName: "lock.fill")
.font(.system(size: 24))
.font(.title3)
.foregroundStyle(.white)
.accessibilityHidden(true)
}
}
@@ -538,8 +548,9 @@ struct AchievementDetailSheet: View {
if achievement.isEarned {
VStack(spacing: 8) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 32))
.font(.title)
.foregroundStyle(completedGold)
.accessibilityHidden(true)
if let earnedAt = achievement.earnedAt {
Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))")
@@ -575,6 +586,7 @@ struct AchievementDetailSheet: View {
if let sport = achievement.definition.sport {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: sport.iconName)
.accessibilityLabel(sport.displayName)
Text(sport.displayName)
}
.font(.subheadline)

View File

@@ -12,6 +12,7 @@ struct GamesHistoryRow: View {
.font(.title3)
.foregroundStyle(stadium.sport.themeColor)
.frame(width: 32)
.accessibilityHidden(true)
}
// Visit info
@@ -38,10 +39,12 @@ struct GamesHistoryRow: View {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
.accessibilityElement(children: .combine)
}
private func sportIcon(for sport: Sport) -> String {

View File

@@ -8,7 +8,7 @@ struct VisitListCard: View {
VStack(alignment: .leading, spacing: 0) {
// Header row (always visible)
Button {
withAnimation(.easeInOut(duration: 0.2)) {
Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
@@ -37,11 +37,13 @@ struct VisitListCard: View {
.font(.caption)
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.accessibilityLabel(isExpanded ? "Collapse details" : "Expand details")
}
.padding()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint("Double-tap to expand visit details")
// Expanded content
if isExpanded {
@@ -115,6 +117,7 @@ private struct InfoRow: View {
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 16)
.accessibilityHidden(true)
Text(label)
.font(.caption)

View File

@@ -107,6 +107,7 @@ struct GameMatchConfirmationView: View {
HStack {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Nearest Stadium")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
@@ -131,6 +132,7 @@ struct GameMatchConfirmationView: View {
Text(match.formattedDistance)
.font(.subheadline)
.foregroundStyle(confidenceColor(match.confidence))
.accessibilityLabel("\(match.formattedDistance), \(match.confidence.description) confidence")
Text(match.confidence.description)
.font(.caption2)
@@ -154,6 +156,7 @@ struct GameMatchConfirmationView: View {
HStack {
Image(systemName: "sportscourt.fill")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text(matchOptionsTitle)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
@@ -196,6 +199,9 @@ struct GameMatchConfirmationView: View {
} label: {
gameMatchRow(match, isSelected: selectedMatch?.id == match.id)
}
.buttonStyle(.plain)
.accessibilityValue(selectedMatch?.id == match.id ? "Selected" : "Not selected")
.accessibilityAddTraits(selectedMatch?.id == match.id ? .isSelected : [])
}
}
@@ -222,6 +228,7 @@ struct GameMatchConfirmationView: View {
Image(systemName: match.game.sport.iconName)
.font(.caption)
.foregroundStyle(match.game.sport.themeColor)
.accessibilityHidden(true)
}
Text(match.gameDateTime)
@@ -233,6 +240,7 @@ struct GameMatchConfirmationView: View {
Circle()
.fill(combinedConfidenceColor(match.confidence.combined))
.frame(width: 8, height: 8)
.accessibilityLabel(confidenceAccessibilityLabel(match.confidence.combined))
Text(match.confidence.combined.description)
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
@@ -245,6 +253,7 @@ struct GameMatchConfirmationView: View {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isSelected ? .green : Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
.padding(Theme.Spacing.md)
.background(isSelected ? Theme.cardBackgroundElevated(colorScheme) : Color.clear)
@@ -318,6 +327,14 @@ struct GameMatchConfirmationView: View {
case .manualOnly: return .red
}
}
private func confidenceAccessibilityLabel(_ confidence: CombinedConfidence) -> String {
switch confidence {
case .autoSelect: return "High confidence"
case .userConfirm: return "Medium confidence"
case .manualOnly: return "Low confidence"
}
}
}
// MARK: - Preview

View File

@@ -57,6 +57,7 @@ private struct GamesHistoryContent: View {
viewModel.clearFilters()
}
.font(.caption)
.accessibilityHint("Clear all sport filters")
}
}
@@ -119,6 +120,7 @@ private struct SportChip: View {
HStack(spacing: 4) {
Image(systemName: sportIcon)
.font(.caption)
.accessibilityHidden(true)
Text(sport.rawValue)
.font(.caption.bold())
}
@@ -131,6 +133,8 @@ private struct SportChip: View {
)
}
.buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private var sportIcon: String {
@@ -199,7 +203,7 @@ private struct EmptyGamesView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "ticket")
.font(.system(size: 48))
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No games recorded yet")

View File

@@ -91,7 +91,7 @@ struct PhotoImportView: View {
.frame(width: 120, height: 120)
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 50))
.font(.largeTitle)
.foregroundStyle(Theme.warmOrange)
}
@@ -137,6 +137,7 @@ struct PhotoImportView: View {
HStack {
Image(systemName: "info.circle.fill")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("How it works")
.font(.body)
}
@@ -374,6 +375,7 @@ struct PhotoImportCandidateCard: View {
.font(.title2)
.foregroundStyle(isConfirmed ? .green : Theme.textMuted(colorScheme))
}
.accessibilityLabel(isConfirmed ? "Deselect for import" : "Confirm import")
}
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
@@ -419,6 +421,7 @@ struct PhotoImportCandidateCard: View {
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
}
@@ -426,6 +429,7 @@ struct PhotoImportCandidateCard: View {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.red)
.accessibilityLabel("Error")
Text(reason.description)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
@@ -538,6 +542,7 @@ private struct InfoRow: View {
HStack(spacing: 8) {
Image(systemName: icon)
.frame(width: 16)
.accessibilityHidden(true)
Text(text)
}
}

View File

@@ -33,7 +33,7 @@ struct ProgressMapView: View {
isVisited: isVisited(stadium),
isSelected: selectedStadium?.id == stadium.id,
onTap: {
withAnimation(.spring(response: 0.3)) {
Theme.Animation.withMotion(.spring(response: 0.3)) {
if selectedStadium?.id == stadium.id {
selectedStadium = nil
} else {
@@ -51,7 +51,7 @@ struct ProgressMapView: View {
.overlay(alignment: .bottomTrailing) {
if mapViewModel.shouldShowResetButton {
Button {
withAnimation(.easeInOut(duration: 0.5)) {
Theme.Animation.withMotion(.easeInOut(duration: 0.5)) {
cameraPosition = .region(MapInteractionViewModel.defaultRegion)
mapViewModel.resetToDefault()
selectedStadium = nil
@@ -108,6 +108,7 @@ struct StadiumMapPin: View {
.fill(pinColor)
.frame(width: 10, height: 6)
.offset(y: -2)
.accessibilityHidden(true)
// Stadium name (when selected)
if isSelected {
@@ -128,7 +129,10 @@ struct StadiumMapPin: View {
}
}
.buttonStyle(.plain)
.animation(.spring(response: 0.3), value: isSelected)
.accessibilityLabel("\(stadium.name), \(isVisited ? "visited" : "not visited")")
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
.animation(Theme.Animation.prefersReducedMotion ? nil : .spring(response: 0.3), value: isSelected)
}
private var pinColor: Color {

View File

@@ -63,6 +63,7 @@ struct ProgressTabView: View {
.font(.title2)
.foregroundStyle(Theme.warmOrange)
}
.accessibilityLabel("Add stadium visit")
}
}
.task {
@@ -153,7 +154,7 @@ struct ProgressTabView: View {
isSelected: viewModel.selectedSport == sport,
progress: progressForSport(sport)
) {
withAnimation(Theme.Animation.spring) {
Theme.Animation.withMotion(Theme.Animation.spring) {
viewModel.selectSport(sport)
}
}
@@ -180,13 +181,18 @@ struct ProgressTabView: View {
Circle()
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 8)
.frame(width: 80, height: 80)
.accessibilityHidden(true)
Circle()
.trim(from: 0, to: progress.progressFraction)
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 80, height: 80)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.5), value: progress.progressFraction)
.animation(
Theme.Animation.prefersReducedMotion ? nil : .easeInOut(duration: 0.5),
value: progress.progressFraction
)
.accessibilityHidden(true)
VStack(spacing: 0) {
Text("\(progress.visitedStadiums)")
@@ -265,6 +271,7 @@ struct ProgressTabView: View {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.accessibilityHidden(true)
Text("Visited (\(viewModel.visitedStadiums.count))")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
@@ -292,6 +299,7 @@ struct ProgressTabView: View {
HStack {
Image(systemName: "circle.dotted")
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text("Not Yet Visited (\(viewModel.unvisitedStadiums.count))")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
@@ -331,6 +339,7 @@ struct ProgressTabView: View {
HStack(spacing: 4) {
Text("View All")
Image(systemName: "chevron.right")
.accessibilityHidden(true)
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
@@ -348,8 +357,9 @@ struct ProgressTabView: View {
.frame(width: 50, height: 50)
Image(systemName: "trophy.fill")
.font(.system(size: 24))
.font(.title3)
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 4) {
@@ -366,6 +376,7 @@ struct ProgressTabView: View {
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
@@ -396,6 +407,7 @@ struct ProgressTabView: View {
HStack(spacing: 4) {
Text("See All")
Image(systemName: "chevron.right")
.accessibilityHidden(true)
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
@@ -432,6 +444,7 @@ struct ProgressStatPill: View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.accessibilityHidden(true)
Text(value)
.font(.body)
}
@@ -460,6 +473,7 @@ struct StadiumChip: View {
Image(systemName: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
@@ -495,6 +509,7 @@ struct StadiumChip: View {
}
}
.buttonStyle(.plain)
.accessibilityElement(children: .combine)
}
}
@@ -513,6 +528,7 @@ struct RecentVisitRow: View {
Image(systemName: visit.sport.iconName)
.foregroundStyle(visit.sport.themeColor)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 4) {
@@ -538,6 +554,7 @@ struct RecentVisitRow: View {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
@@ -546,6 +563,7 @@ struct RecentVisitRow: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.accessibilityElement(children: .combine)
}
}

View File

@@ -34,6 +34,7 @@ struct StadiumVisitHistoryView: View {
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Add visit to this stadium")
}
}
.sheet(isPresented: $showingAddVisit) {
@@ -93,7 +94,7 @@ private struct EmptyVisitHistoryView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "calendar.badge.plus")
.font(.system(size: 48))
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No visits recorded")

View File

@@ -165,6 +165,7 @@ struct StadiumVisitSheet: View {
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule())
}
.accessibilityLabel("Select team \(team.name)")
}
}
.padding(.top, 8)
@@ -201,6 +202,7 @@ struct StadiumVisitSheet: View {
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule())
}
.accessibilityLabel("Select team \(team.name)")
}
}
.padding(.top, 8)
@@ -283,6 +285,7 @@ struct StadiumVisitSheet: View {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.accessibilityHidden(true)
Text(error)
.foregroundStyle(.red)
}

View File

@@ -149,6 +149,7 @@ struct VisitDetailView: View {
Image(systemName: visit.sportEnum?.iconName ?? "sportscourt")
.font(.largeTitle)
.foregroundStyle(sportColor)
.accessibilityLabel(visit.sportEnum?.displayName ?? "Sport")
}
VStack(spacing: Theme.Spacing.xs) {
@@ -188,6 +189,7 @@ struct VisitDetailView: View {
HStack {
Image(systemName: "sportscourt.fill")
.foregroundStyle(sportColor)
.accessibilityHidden(true)
Text("Game Info")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
@@ -232,6 +234,7 @@ struct VisitDetailView: View {
HStack {
Image(systemName: "info.circle.fill")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Details")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
@@ -292,6 +295,7 @@ struct VisitDetailView: View {
HStack {
Image(systemName: "note.text")
.foregroundStyle(Theme.routeGold)
.accessibilityHidden(true)
Text("Notes")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))