Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-16 20:36:40 -06:00
parent b08e92a402
commit a36525771d
12 changed files with 458 additions and 1 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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())
}
}

View File

@ -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"
}

View File

@ -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"))
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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)
}
}