diff --git a/Baccarat/Agents.md b/Baccarat/Agents.md index 746311c..adfa776 100644 --- a/Baccarat/Agents.md +++ b/Baccarat/Agents.md @@ -158,6 +158,58 @@ If SwiftData is configured to use CloudKit: - Test with accessibility settings: Settings > Accessibility > Display & Text Size > Larger Text. +## VoiceOver accessibility instructions + +- All interactive elements (buttons, betting zones, selectable items) must have meaningful `.accessibilityLabel()`. +- Use `.accessibilityValue()` to communicate dynamic state (e.g., current bet amount, selection state, hand value). +- Use `.accessibilityHint()` to describe what will happen when interacting with an element: + ```swift + Button("Deal", action: deal) + .accessibilityHint("Deals cards and starts the round") + ``` +- Use `.accessibilityAddTraits()` to communicate element type: + - `.isButton` for tappable elements that aren't SwiftUI Buttons + - `.isHeader` for section headers + - `.isModal` for modal overlays + - `.updatesFrequently` for live-updating content +- Hide purely decorative elements from VoiceOver: + ```swift + TableBackgroundView() + .accessibilityHidden(true) // Decorative element + ``` +- Group related elements to reduce VoiceOver navigation complexity: + ```swift + VStack { + handLabel + cardStack + valueDisplay + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Player hand") + .accessibilityValue("Ace of Hearts, King of Spades. Value: 1") + ``` +- For complex elements, use `.accessibilityElement(children: .contain)` to allow navigation to children while adding context. +- Post accessibility announcements for important events: + ```swift + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(500)) + UIAccessibility.post(notification: .announcement, argument: "Player wins!") + } + ``` +- Provide accessibility names for model types that appear in UI: + ```swift + enum Suit { + var accessibilityName: String { + switch self { + case .hearts: return String(localized: "Hearts") + // ... + } + } + } + ``` +- Test with VoiceOver enabled: Settings > Accessibility > VoiceOver. + + ## Project structure - Use a consistent project structure, with folder layout determined by app features. diff --git a/Baccarat/Models/Card.swift b/Baccarat/Models/Card.swift index 1708dc4..4f22b90 100644 --- a/Baccarat/Models/Card.swift +++ b/Baccarat/Models/Card.swift @@ -20,6 +20,16 @@ enum Suit: String, CaseIterable, Identifiable { var isRed: Bool { self == .hearts || self == .diamonds } + + /// Accessibility name for VoiceOver. + var accessibilityName: String { + switch self { + case .hearts: return String(localized: "Hearts") + case .diamonds: return String(localized: "Diamonds") + case .clubs: return String(localized: "Clubs") + case .spades: return String(localized: "Spades") + } + } } /// Represents the rank of a card from Ace through King. @@ -62,6 +72,25 @@ enum Rank: Int, CaseIterable, Identifiable { return 0 } } + + /// Accessibility name for VoiceOver. + var accessibilityName: String { + switch self { + case .ace: return String(localized: "Ace") + case .two: return String(localized: "Two") + case .three: return String(localized: "Three") + case .four: return String(localized: "Four") + case .five: return String(localized: "Five") + case .six: return String(localized: "Six") + case .seven: return String(localized: "Seven") + case .eight: return String(localized: "Eight") + case .nine: return String(localized: "Nine") + case .ten: return String(localized: "Ten") + case .jack: return String(localized: "Jack") + case .queen: return String(localized: "Queen") + case .king: return String(localized: "King") + } + } } /// Represents a single playing card with a suit and rank. diff --git a/Baccarat/Models/ChipDenomination.swift b/Baccarat/Models/ChipDenomination.swift index 22c092e..15c52df 100644 --- a/Baccarat/Models/ChipDenomination.swift +++ b/Baccarat/Models/ChipDenomination.swift @@ -113,5 +113,11 @@ enum ChipDenomination: Int, CaseIterable, Identifiable { case .hundredThousand: return Color.Chip.goldRubyStripe } } + + /// Accessibility label for VoiceOver. + var accessibilityLabel: String { + let format = String(localized: "chipDenominationFormat") + return String(format: format, rawValue.formatted()) + } } diff --git a/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Resources/Localizable.xcstrings index 2a00a22..a326fc7 100644 --- a/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Resources/Localizable.xcstrings @@ -70,6 +70,9 @@ } } } + }, + "$%@" : { + }, "$%lldK" : { "comment" : "A button that allows the user to select a starting balance for the game.", @@ -105,6 +108,9 @@ } } } + }, + "Ace" : { + }, "B" : { "comment" : "The letter \"B\" displayed in the center of the playing card's back.", @@ -140,6 +146,9 @@ } } } + }, + "Balance" : { + }, "BALANCE" : { "comment" : "The label for the user's balance in the top bar.", @@ -211,6 +220,12 @@ } } } + }, + "Banker bet, pays 0.95 to 1" : { + + }, + "Banker hand" : { + }, "BANKER WINS" : { "comment" : "Result banner text when banker wins.", @@ -253,6 +268,12 @@ } } } + }, + "betAmountFormat" : { + + }, + "Betting disabled" : { + }, "Cancel" : { "comment" : "The label of a button to cancel making changes in the settings.", @@ -288,6 +309,21 @@ } } } + }, + "Card face down" : { + + }, + "Cards face down" : { + + }, + "Cards remaining in shoe" : { + + }, + "Chip selector" : { + + }, + "chipDenominationFormat" : { + }, "Clear" : { "comment" : "The label of a button that clears all current bets in the game.", @@ -323,6 +359,12 @@ } } } + }, + "Clubs" : { + + }, + "currentBetFormat" : { + }, "Deal" : { "comment" : "The label of a button that deals cards in a game.", @@ -428,6 +470,9 @@ } } } + }, + "Diamonds" : { + }, "Done" : { "comment" : "The text for a button that confirms and saves settings.", @@ -463,6 +508,32 @@ } } } + }, + "Double tap a chip to select bet amount" : { + "comment" : "A hint that appears when hovering over the chip selector, explaining how to select a bet amount.", + "isCommentAutoGenerated" : true + }, + "Double tap to place bet" : { + + }, + "Eight" : { + + }, + "Empty card slot" : { + "comment" : "An accessibility label for an empty card slot.", + "isCommentAutoGenerated" : true + }, + "Five" : { + + }, + "Four" : { + + }, + "Game history" : { + + }, + "Game Over" : { + }, "GAME OVER" : { "comment" : "The title of the game over screen.", @@ -498,6 +569,12 @@ } } } + }, + "handValueFormat" : { + + }, + "Hearts" : { + }, "HISTORY" : { "comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.", @@ -533,6 +610,18 @@ } } } + }, + "historySummaryFormat" : { + + }, + "Jack" : { + + }, + "King" : { + + }, + "lostAmountFormat" : { + }, "MAX" : { "comment" : "A label displayed as a badge on top-right of a chip to indicate it's the maximum bet.", @@ -568,6 +657,12 @@ } } } + }, + "maximum bet" : { + + }, + "Maximum bet reached" : { + }, "New Round" : { "comment" : "The label of a button that starts a new round of the game.", @@ -603,6 +698,15 @@ } } } + }, + "Nine" : { + + }, + "No cards" : { + + }, + "No history yet" : { + }, "PAYS 0.95 TO 1" : { "comment" : "A description of the payout for betting on the banker in a mini baccarat table.", @@ -778,6 +882,12 @@ } } } + }, + "Player bet, pays 1 to 1" : { + + }, + "Player hand" : { + }, "PLAYER WINS" : { "comment" : "Result banner text when player wins.", @@ -820,6 +930,9 @@ } } } + }, + "Queen" : { + }, "Reset" : { "comment" : "A button that resets the game to its initial state.", @@ -925,6 +1038,12 @@ } } } + }, + "selected" : { + + }, + "Selected" : { + }, "Settings" : { "comment" : "The label of a button that navigates to the settings screen.", @@ -960,6 +1079,15 @@ } } } + }, + "Seven" : { + + }, + "Six" : { + + }, + "Spades" : { + }, "tableLimitsFormat" : { "comment" : "Format string for table limits display. First argument is min bet, second is max bet.", @@ -1002,6 +1130,13 @@ } } } + }, + "Ten" : { + "comment" : "Accessibility name for a card with the rank of Ten.", + "isCommentAutoGenerated" : true + }, + "Three" : { + }, "TIE" : { "comment" : "The text displayed in the TIE betting zone.", @@ -1037,6 +1172,9 @@ } } } + }, + "Tie bet, pays 8 to 1" : { + }, "TIE GAME" : { "comment" : "Result banner text when the game is a tie.", @@ -1079,6 +1217,9 @@ } } } + }, + "Two" : { + }, "WIN" : { "comment" : "The text that appears as a badge when a player wins a hand in baccarat.", @@ -1114,6 +1255,12 @@ } } } + }, + "Winner" : { + + }, + "wonAmountFormat" : { + }, "You've run out of chips!" : { "comment" : "A message displayed when a player runs out of money in the game over screen.", @@ -1151,5 +1298,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Baccarat/Views/CardView.swift b/Baccarat/Views/CardView.swift index cd3ab7f..6d30f08 100644 --- a/Baccarat/Views/CardView.swift +++ b/Baccarat/Views/CardView.swift @@ -23,6 +23,15 @@ struct CardView: View { cardWidth * 1.4 } + /// Accessibility label describing the card for VoiceOver. + private var accessibilityDescription: String { + if isFaceUp { + return "\(card.rank.accessibilityName) of \(card.suit.accessibilityName)" + } else { + return String(localized: "Card face down") + } + } + var body: some View { ZStack { if isFaceUp { @@ -36,6 +45,8 @@ struct CardView: View { axis: (x: 0, y: 1, z: 0) ) .animation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.cardFlipBounce), value: isFaceUp) + .accessibilityLabel(accessibilityDescription) + .accessibilityAddTraits(.isImage) } } @@ -254,6 +265,7 @@ struct CardPlaceholderView: View { style: StrokeStyle(lineWidth: Design.LineWidth.medium, dash: [8, 4]) ) .frame(width: width, height: height) + .accessibilityLabel(String(localized: "Empty card slot")) } } diff --git a/Baccarat/Views/Chips/ChipOnTable.swift b/Baccarat/Views/Chips/ChipOnTable.swift index e9b4423..60c711d 100644 --- a/Baccarat/Views/Chips/ChipOnTable.swift +++ b/Baccarat/Views/Chips/ChipOnTable.swift @@ -82,8 +82,21 @@ struct ChipOnTable: View { .fill(Color.red) ) .offset(x: maxBadgeOffsetX, y: maxBadgeOffsetY) + .accessibilityHidden(true) // Included in parent label } } + .accessibilityLabel(accessibilityDescription) + } + + // MARK: - Accessibility + + private var accessibilityDescription: String { + let format = String(localized: "betAmountFormat") + let base = String(format: format, amount.formatted()) + if showMax { + return base + ", " + String(localized: "maximum bet") + } + return base } } diff --git a/Baccarat/Views/Chips/ChipSelectorView.swift b/Baccarat/Views/Chips/ChipSelectorView.swift index 9d8a648..a945609 100644 --- a/Baccarat/Views/Chips/ChipSelectorView.swift +++ b/Baccarat/Views/Chips/ChipSelectorView.swift @@ -48,6 +48,9 @@ struct ChipSelectorView: View { .padding(.vertical, Design.Spacing.small) // Extra padding for selection scale effect (1.1x) } .scrollIndicators(.hidden) + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Chip selector")) + .accessibilityHint(String(localized: "Double tap a chip to select bet amount")) .onChange(of: balance) { _, newBalance in // Auto-select highest affordable chip if current selection is now too expensive if newBalance < selectedChip.rawValue { diff --git a/Baccarat/Views/Chips/ChipView.swift b/Baccarat/Views/Chips/ChipView.swift index f8a1555..89e36cc 100644 --- a/Baccarat/Views/Chips/ChipView.swift +++ b/Baccarat/Views/Chips/ChipView.swift @@ -103,6 +103,9 @@ struct ChipView: View { .shadow(color: .black.opacity(Design.Opacity.overlay), radius: isSelected ? Design.Shadow.radiusSmall * 2 : Design.Shadow.radiusSmall, x: shadowOffset, y: shadowOffsetY) .scaleEffect(isSelected ? Design.Scale.selected : Design.Scale.normal) .animation(.spring(duration: Design.Animation.selectionDuration), value: isSelected) + .accessibilityLabel(denomination.accessibilityLabel) + .accessibilityValue(isSelected ? String(localized: "Selected") : "") + .accessibilityAddTraits(.isButton) } } diff --git a/Baccarat/Views/GameTableView.swift b/Baccarat/Views/GameTableView.swift index 1f0bb0a..b5368e8 100644 --- a/Baccarat/Views/GameTableView.swift +++ b/Baccarat/Views/GameTableView.swift @@ -275,6 +275,9 @@ struct GameOverView: View { showContent = true } } + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Game Over")) + .accessibilityAddTraits(.isModal) } } @@ -295,6 +298,40 @@ struct CardsDisplayArea: View { private let labelFontSize: CGFloat = 14 + // MARK: - Accessibility + + private var playerHandDescription: String { + if playerCards.isEmpty { + return String(localized: "No cards") + } + let visibleCards = zip(playerCards, playerCardsFaceUp) + .filter { $1 } + .map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" } + + if visibleCards.isEmpty { + return String(localized: "Cards face down") + } + + let format = String(localized: "handValueFormat") + return visibleCards.joined(separator: ", ") + ". " + String(format: format, playerValue) + } + + private var bankerHandDescription: String { + if bankerCards.isEmpty { + return String(localized: "No cards") + } + let visibleCards = zip(bankerCards, bankerCardsFaceUp) + .filter { $1 } + .map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" } + + if visibleCards.isEmpty { + return String(localized: "Cards face down") + } + + let format = String(localized: "handValueFormat") + return visibleCards.joined(separator: ", ") + ". " + String(format: format, bankerValue) + } + var body: some View { HStack(spacing: Design.Spacing.xxxLarge) { // Player side @@ -318,6 +355,9 @@ struct CardsDisplayArea: View { isWinner: playerIsWinner ) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Player hand")) + .accessibilityValue(playerHandDescription + (playerIsWinner ? ", " + String(localized: "Winner") : "")) // Banker side VStack(spacing: Design.Spacing.small) { @@ -340,6 +380,9 @@ struct CardsDisplayArea: View { isWinner: bankerIsWinner ) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Banker hand")) + .accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : "")) } .padding(.top, Design.Spacing.large) .padding(.bottom, Design.Spacing.xLarge) @@ -347,6 +390,7 @@ struct CardsDisplayArea: View { .background( RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) .fill(Color.black.opacity(0.25)) + .accessibilityHidden(true) ) .padding(.horizontal) } @@ -458,6 +502,7 @@ struct TableBackgroundView: View { .opacity(Design.Opacity.subtle / 3) } .ignoresSafeArea() + .accessibilityHidden(true) // Decorative element } } @@ -526,6 +571,9 @@ struct TopBarView: View { Capsule() .fill(Color.black.opacity(Design.Opacity.overlay)) ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Balance")) + .accessibilityValue("$\(balance.formatted())") Spacer() @@ -538,6 +586,9 @@ struct TopBarView: View { .font(.system(size: smallFontSize, weight: .medium)) } .foregroundStyle(.white.opacity(Design.Opacity.secondary)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Cards remaining in shoe")) + .accessibilityValue("\(cardsRemaining)") Spacer() } diff --git a/Baccarat/Views/MiniBaccaratTableView.swift b/Baccarat/Views/MiniBaccaratTableView.swift index b61152e..0129c99 100644 --- a/Baccarat/Views/MiniBaccaratTableView.swift +++ b/Baccarat/Views/MiniBaccaratTableView.swift @@ -210,6 +210,30 @@ struct TieBettingZone: View { private let titleFontSize: CGFloat = Design.BaseFontSize.medium private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall + // MARK: - Accessibility + + private var accessibilityDescription: String { + var description = String(localized: "Tie bet, pays 8 to 1") + if betAmount > 0 { + let format = String(localized: "currentBetFormat") + description += ". " + String(format: format, betAmount.formatted()) + if isAtMax { + description += ", " + String(localized: "maximum bet") + } + } + return description + } + + private var accessibilityHintText: String { + if isEnabled { + return String(localized: "Double tap to place bet") + } else if isAtMax { + return String(localized: "Maximum bet reached") + } else { + return String(localized: "Betting disabled") + } + } + // MARK: - Layout Constants private let cornerRadius = Design.CornerRadius.small @@ -263,10 +287,15 @@ struct TieBettingZone: View { if betAmount > 0 { ChipOnTable(amount: betAmount, showMax: isAtMax) .padding(.trailing, chipTrailingPadding) + .accessibilityHidden(true) // Included in zone description } } } .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + .accessibilityHint(accessibilityHintText) + .accessibilityAddTraits(.isButton) } } @@ -284,6 +313,33 @@ struct BankerBettingZone: View { private let titleFontSize: CGFloat = Design.BaseFontSize.large private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall + // MARK: - Accessibility + + private var accessibilityDescription: String { + var description = String(localized: "Banker bet, pays 0.95 to 1") + if isSelected { + description += ", " + String(localized: "selected") + } + if betAmount > 0 { + let format = String(localized: "currentBetFormat") + description += ". " + String(format: format, betAmount.formatted()) + if isAtMax { + description += ", " + String(localized: "maximum bet") + } + } + return description + } + + private var accessibilityHintText: String { + if isEnabled { + return String(localized: "Double tap to place bet") + } else if isAtMax { + return String(localized: "Maximum bet reached") + } else { + return String(localized: "Betting disabled") + } + } + // MARK: - Layout Constants private let cornerRadius = Design.CornerRadius.medium @@ -353,10 +409,15 @@ struct BankerBettingZone: View { if betAmount > 0 { ChipOnTable(amount: betAmount, showMax: isAtMax) .padding(.trailing, chipTrailingPadding) + .accessibilityHidden(true) // Included in zone description } } } .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + .accessibilityHint(accessibilityHintText) + .accessibilityAddTraits(.isButton) } } @@ -374,6 +435,33 @@ struct PlayerBettingZone: View { private let titleFontSize: CGFloat = Design.BaseFontSize.large private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall + // MARK: - Accessibility + + private var accessibilityDescription: String { + var description = String(localized: "Player bet, pays 1 to 1") + if isSelected { + description += ", " + String(localized: "selected") + } + if betAmount > 0 { + let format = String(localized: "currentBetFormat") + description += ". " + String(format: format, betAmount.formatted()) + if isAtMax { + description += ", " + String(localized: "maximum bet") + } + } + return description + } + + private var accessibilityHintText: String { + if isEnabled { + return String(localized: "Double tap to place bet") + } else if isAtMax { + return String(localized: "Maximum bet reached") + } else { + return String(localized: "Betting disabled") + } + } + // MARK: - Layout Constants private let cornerRadius = Design.CornerRadius.medium @@ -443,10 +531,15 @@ struct PlayerBettingZone: View { if betAmount > 0 { ChipOnTable(amount: betAmount, showMax: isAtMax) .padding(.trailing, chipTrailingPadding) + .accessibilityHidden(true) // Included in zone description } } } .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + .accessibilityHint(accessibilityHintText) + .accessibilityAddTraits(.isButton) } } diff --git a/Baccarat/Views/ResultBannerView.swift b/Baccarat/Views/ResultBannerView.swift index 43b80bb..a855894 100644 --- a/Baccarat/Views/ResultBannerView.swift +++ b/Baccarat/Views/ResultBannerView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit /// An animated banner showing the round result. struct ResultBannerView: View { @@ -108,6 +109,34 @@ struct ResultBannerView: View { withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2)) { showWinnings = true } + + // Announce result to VoiceOver users + announceResult() + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + .accessibilityAddTraits(.updatesFrequently) + } + + // MARK: - Accessibility + + private var accessibilityDescription: String { + var description = result.displayText + if winnings > 0 { + let format = String(localized: "wonAmountFormat") + description += ". " + String(format: format, winnings.formatted()) + } else if winnings < 0 { + let format = String(localized: "lostAmountFormat") + description += ". " + String(format: format, abs(winnings).formatted()) + } + return description + } + + private func announceResult() { + // Post accessibility announcement for screen reader users + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(500)) + UIAccessibility.post(notification: .announcement, argument: accessibilityDescription) } } } @@ -157,6 +186,7 @@ struct ConfettiView: View { } } .allowsHitTesting(false) + .accessibilityHidden(true) // Decorative element } } diff --git a/Baccarat/Views/RoadMapView.swift b/Baccarat/Views/RoadMapView.swift index 003efb1..a5a140a 100644 --- a/Baccarat/Views/RoadMapView.swift +++ b/Baccarat/Views/RoadMapView.swift @@ -16,6 +16,21 @@ struct RoadMapView: View { @ScaledMetric(relativeTo: .caption2) private var historyFontSize: CGFloat = Design.BaseFontSize.small @ScaledMetric(relativeTo: .caption2) private var dotSize: CGFloat = 22 + // MARK: - Accessibility + + private var historySummary: String { + if results.isEmpty { + return String(localized: "No history yet") + } + + let playerWins = results.filter { $0.result == .playerWins }.count + let bankerWins = results.filter { $0.result == .bankerWins }.count + let ties = results.filter { $0.result == .tie }.count + + let format = String(localized: "historySummaryFormat") + return String(format: format, results.count, playerWins, bankerWins, ties) + } + var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { Text("HISTORY") @@ -39,6 +54,9 @@ struct RoadMapView: View { RoundedRectangle(cornerRadius: Design.CornerRadius.small) .fill(Color.black.opacity(Design.Opacity.light)) ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Game history")) + .accessibilityValue(historySummary) } }