From 495cccb3db64065f888f1d8d4085cb6c144ffe95 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Wed, 15 Jul 2026 22:23:13 -0700 Subject: [PATCH 1/5] fix game picker outside-tap dismissal on ios --- .../Views/Shared/ContentView.swift | 104 ++++++++++-------- .../Views/Shared/GameModePickerView.swift | 7 +- 2 files changed, 61 insertions(+), 50 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index b4698fe..050b933 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -262,67 +262,75 @@ struct ContentView: View { view .toolbar { #if os(iOS) - ToolbarItem(placement: .bottomBar) { - Menu { - Section { - Button("New Game", systemImage: "plus") { - startNewGameFromUI() - } - Button("Redeal", systemImage: "arrow.clockwise") { - redealFromUI() + // The bottom bar is UIKit chrome layered above the SwiftUI + // overlay: left in place while the picker is up it stays + // undimmed and fully tappable, and it swallows scrim taps in + // the bottom strip. Removing its items is what keeps the + // picker modal — `.toolbar(.hidden, for: .bottomBar)` is inert + // here because no NavigationStack hosts the bar. + if !isShowingGamePicker { + ToolbarItem(placement: .bottomBar) { + Menu { + Section { + Button("New Game", systemImage: "plus") { + startNewGameFromUI() + } + Button("Redeal", systemImage: "arrow.clockwise") { + redealFromUI() + } + .disabled(!viewModel.canRedeal) } - .disabled(!viewModel.canRedeal) - } - Section { - Button("Statistics", systemImage: "chart.bar") { - isShowingStats = true + Section { + Button("Statistics", systemImage: "chart.bar") { + isShowingStats = true + } + Button("Rules & Scoring", systemImage: "book") { + presentRulesAndScoring(initialSection: .rules) + } } - Button("Rules & Scoring", systemImage: "book") { - presentRulesAndScoring(initialSection: .rules) + Section { + Button("Settings", systemImage: "gear") { + isShowingSettings = true + } } + } label: { + Label("More", systemImage: "ellipsis") } - Section { - Button("Settings", systemImage: "gear") { - isShowingSettings = true + } + ToolbarSpacer(.flexible, placement: .bottomBar) + if viewModel.isAutoFinishAvailable { + ToolbarItem(placement: .bottomBar) { + Button { + startAutoFinish() + } label: { + // The bottom bar renders Labels icon-only. + HStack(spacing: 5) { + Image(systemName: "bolt") + Text("Auto") + } } + .accessibilityLabel("Auto Finish") + .disabled(isAutoFinishDisabled) } - } label: { - Label("More", systemImage: "ellipsis") + ToolbarSpacer(.fixed, placement: .bottomBar) } - } - ToolbarSpacer(.flexible, placement: .bottomBar) - if viewModel.isAutoFinishAvailable { - ToolbarItem(placement: .bottomBar) { - Button { - startAutoFinish() - } label: { - // The bottom bar renders Labels icon-only. - HStack(spacing: 5) { - Image(systemName: "bolt") - Text("Auto") + ToolbarItemGroup(placement: .bottomBar) { + if isHintButtonVisible { + Button { + triggerHint() + } label: { + Label("Hint", systemImage: "lightbulb") } + .disabled(isHintDisabled) } - .accessibilityLabel("Auto Finish") - .disabled(isAutoFinishDisabled) - } - ToolbarSpacer(.fixed, placement: .bottomBar) - } - ToolbarItemGroup(placement: .bottomBar) { - if isHintButtonVisible { Button { - triggerHint() + stopAutoFinish() + beginUndoAnimationIfNeeded() } label: { - Label("Hint", systemImage: "lightbulb") + Label("Undo", systemImage: "arrow.uturn.backward") } - .disabled(isHintDisabled) + .disabled(isUndoDisabled) } - Button { - stopAutoFinish() - beginUndoAnimationIfNeeded() - } label: { - Label("Undo", systemImage: "arrow.uturn.backward") - } - .disabled(isUndoDisabled) } #endif #if os(macOS) diff --git a/ComputerSolitaire/Views/Shared/GameModePickerView.swift b/ComputerSolitaire/Views/Shared/GameModePickerView.swift index 3f20529..859037b 100644 --- a/ComputerSolitaire/Views/Shared/GameModePickerView.swift +++ b/ComputerSolitaire/Views/Shared/GameModePickerView.swift @@ -271,13 +271,16 @@ struct GameModePickerOverlay: View { var body: some View { ZStack { // The scrim is the picker's cancel button: clicking outside the - // panel dismisses, matching a system presentation. + // panel dismisses, matching a system presentation. Safe areas must + // be ignored on the button itself — expanding only the label paints + // edge to edge but leaves the hit area inset, deadening taps in the + // top and bottom strips on iOS. Button(action: onDismiss) { Color.black.opacity(0.35) - .ignoresSafeArea() .contentShape(Rectangle()) } .buttonStyle(.plain) + .ignoresSafeArea() .accessibilityLabel("Dismiss game picker") // Short windows (a ten-family gallery on a phone, macOS near its From 9f5c925417a59e6af760e18fa75ee03f3b729ba8 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Wed, 15 Jul 2026 22:23:13 -0700 Subject: [PATCH 2/5] add ios picker dismissal ui test --- .../GameModePickerUITests.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/ComputerSolitaireUITests/GameModePickerUITests.swift b/ComputerSolitaireUITests/GameModePickerUITests.swift index 60e45f3..ed90138 100644 --- a/ComputerSolitaireUITests/GameModePickerUITests.swift +++ b/ComputerSolitaireUITests/GameModePickerUITests.swift @@ -73,4 +73,36 @@ final class GameModePickerUITests: XCTestCase { XCTAssertTrue(yukonTitle.waitForExistence(timeout: 3), "Selecting the scrolled-to game should switch to it") } #endif +#if os(iOS) + /// The bottom strip is where a thumb naturally taps to dismiss, and it is + /// also where the bottom toolbar's chrome used to swallow scrim taps. + /// Regression coverage: the toolbar must leave while the picker is open, + /// and a tap in that strip must dismiss the overlay. + @MainActor + func testTapOutsideDismissesGamePicker() throws { + let app = XCUIApplication() + app.launch() + + let titleButton = app.buttons.matching( + NSPredicate(format: "label CONTAINS 'Switch game mode'") + ).firstMatch + XCTAssertTrue(titleButton.waitForExistence(timeout: 5), "Game title button should be on the board") + titleButton.tap() + + let scrim = app.buttons["Dismiss game picker"] + XCTAssertTrue(scrim.waitForExistence(timeout: 3), "Picker overlay should open from the title button") + XCTAssertTrue( + app.buttons["Undo"].waitForNonExistence(timeout: 3), + "The bottom toolbar must leave while the picker is open — its chrome sits above the overlay and would swallow scrim taps" + ) + + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.97)).tap() + + XCTAssertTrue(scrim.waitForNonExistence(timeout: 3), "Tapping the bottom strip outside the panel should dismiss the picker overlay") + XCTAssertTrue( + app.buttons["Undo"].waitForExistence(timeout: 3), + "The bottom toolbar should return once the picker closes" + ) + } +#endif } From fac11195d78ab64242ee834421b7267dd0f2223b Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Wed, 15 Jul 2026 22:36:01 -0700 Subject: [PATCH 3/5] bump marketing version to 0.8.4 --- ComputerSolitaire.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index 9cbbfce..93b3e31 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -324,7 +324,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.8.2; + MARKETING_VERSION = 0.8.4; PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; @@ -376,7 +376,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.8.2; + MARKETING_VERSION = 0.8.4; PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; From e85199d277c877d9189f102606bf1ceedba76080 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Thu, 16 Jul 2026 00:26:18 -0700 Subject: [PATCH 4/5] revert 0.8.4 version bump --- ComputerSolitaire.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index e6b74a6..ccff99f 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -516,7 +516,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.8.4; + MARKETING_VERSION = 0.8.3; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -574,7 +574,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.8.4; + MARKETING_VERSION = 0.8.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule; From d25b9687add14887738a0642cec3c764a028106a Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Thu, 16 Jul 2026 00:33:37 -0700 Subject: [PATCH 5/5] bump version number --- ComputerSolitaire.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index ccff99f..e6b74a6 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -516,7 +516,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.8.3; + MARKETING_VERSION = 0.8.4; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -574,7 +574,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.8.3; + MARKETING_VERSION = 0.8.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule;