commit bb38ae05a94acd670e34d92ae430a6043cd40aab
parent 9cffe6dde15b02b312bf36858aa02ea0aeab299d
Author: Michael Camilleri <[email protected]>
Date: Sun, 19 Jul 2026 08:59:32 +0900
Route the VoiceOver reveal action through the confirmation alert
The grid's VoiceOver 'Reveal Square' action called
session.revealSquare() directly, while the Hints menu and the app-level
Cmd+R commands deliberately confirm first. Reveal is not undoable, so a
VoiceOver user could spend a hint and alter the shared grid with one
accidental double-tap, without the confirmation every other surface
receives.
This commit routes the action through the same alert. RevealConfirmation
now owns the confirmed execution as well as the request: performPending
applies the pending scope and, for a square, returns the 'Revealed R'
announcement, which the alert's confirm button posts so the spoken
feedback the direct path gave survives the rerouting. The toolbar and
presentation modifiers take the RevealConfirmation object in place of
the two reveal bindings, so the Hints menu, the app commands, and the
VoiceOver cell action all funnel through request and the alert, and no
path can reveal without confirmation.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
7 files changed, 192 insertions(+), 41 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -150,6 +150,7 @@
95170ECF07E94E7581C2B66F /* ContentKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */; };
9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */; };
95B4083F8BC2CA465077A662 /* PlayerColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23FCFFF1C2C7E909DFD8FC43 /* PlayerColorTests.swift */; };
+ 95FDB86366A30C95C69D5A02 /* RevealConfirmationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71312F9248AA21238F7D5D6D /* RevealConfirmationTests.swift */; };
9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; };
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; };
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
@@ -380,6 +381,7 @@
7023E506D777DB80B18A7DB5 /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; };
70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; };
710BCB6A647A820B106CE666 /* PuzzleSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSession.swift; sourceTree = "<group>"; };
+ 71312F9248AA21238F7D5D6D /* RevealConfirmationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevealConfirmationTests.swift; sourceTree = "<group>"; };
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
74C8886A66F0877858A67D62 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
@@ -650,6 +652,7 @@
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */,
847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */,
+ 71312F9248AA21238F7D5D6D /* RevealConfirmationTests.swift */,
8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */,
5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */,
057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */,
@@ -1120,6 +1123,7 @@
0BB721552A849A53AB9BF7CC /* RemoteCoordinateValidationTests.swift in Sources */,
61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */,
C58F15CBEADA72032B54009D /* ReplayControlsTests.swift in Sources */,
+ 95FDB86366A30C95C69D5A02 /* RevealConfirmationTests.swift in Sources */,
AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */,
07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */,
8AE376C0726116082B15241D /* ShareLinkRouteTests.swift in Sources */,
diff --git a/Crossmate/Views/Puzzle/GridAccessibility.swift b/Crossmate/Views/Puzzle/GridAccessibility.swift
@@ -163,6 +163,7 @@ extension View {
func puzzleGridAccessibility(
session: PlayerSession,
roster: PlayerRoster,
+ revealConfirmation: RevealConfirmation,
showsSharedAnnotations: Bool,
isReplaying: Bool,
gridSize: CGSize,
@@ -171,6 +172,7 @@ extension View {
modifier(PuzzleGridAccessibility(
session: session,
roster: roster,
+ revealConfirmation: revealConfirmation,
showsSharedAnnotations: showsSharedAnnotations,
isReplaying: isReplaying,
gridSize: gridSize,
@@ -182,6 +184,7 @@ extension View {
private struct PuzzleGridAccessibility: ViewModifier {
let session: PlayerSession
let roster: PlayerRoster
+ let revealConfirmation: RevealConfirmation
let showsSharedAnnotations: Bool
let isReplaying: Bool
let gridSize: CGSize
@@ -308,7 +311,7 @@ private struct PuzzleGridAccessibility: ViewModifier {
}
}
- // MARK: - Check / reveal actions
+ // MARK: - Check actions
private func checkAndAnnounce(wholeWord: Bool) {
let cells: [Puzzle.Cell]
@@ -326,15 +329,6 @@ private struct PuzzleGridAccessibility: ViewModifier {
announce(describer.checkAnnouncement(for: cells, squares: session.game.squares))
}
- private func revealAndAnnounce() {
- session.revealSquare()
- announce(describer.revealAnnouncement(
- atRow: session.selectedRow,
- atCol: session.selectedCol,
- squares: session.game.squares
- ))
- }
-
// MARK: - Peer fill announcements
/// Collects squares whose entry a remote author just changed. Fills only:
@@ -495,13 +489,15 @@ private struct PuzzleGridAccessibility: ViewModifier {
// reveal disappear on a solved grid or a read-only mutator
// — revoked access or an unsupported protocol (which would
// reject them anyway); Switch Direction stays for
- // navigation.
+ // navigation. Reveal is non-undoable, so it routes through
+ // the same confirmation alert as the Hints menu and ⌘R
+ // instead of revealing immediately.
.accessibilityActions {
Button("Switch Direction") { session.toggleDirection() }
if session.mutator.isEditable, session.game.completionState != .solved {
Button("Check Square") { checkAndAnnounce(wholeWord: false) }
Button("Check Word") { checkAndAnnounce(wholeWord: true) }
- Button("Reveal Square") { revealAndAnnounce() }
+ Button("Reveal Square") { revealConfirmation.request(.square) }
}
}
.frame(width: rect.width, height: rect.height)
diff --git a/Crossmate/Views/Puzzle/GridView.swift b/Crossmate/Views/Puzzle/GridView.swift
@@ -3,6 +3,10 @@ import SwiftUI
struct GridView: View {
@Bindable var session: PlayerSession
let roster: PlayerRoster
+ /// Owns the reveal confirmation alert (presented by `PuzzleView`); the
+ /// grid's VoiceOver Reveal Square action requests through it so a reveal
+ /// is never applied without the standard confirmation.
+ let revealConfirmation: RevealConfirmation
let showsSharedAnnotations: Bool
/// Whether to render peers' live cursor tracks. Off for a solved puzzle —
/// the game is no longer a live session, so the other player's cursor is
@@ -141,6 +145,7 @@ struct GridView: View {
.puzzleGridAccessibility(
session: session,
roster: roster,
+ revealConfirmation: revealConfirmation,
showsSharedAnnotations: showsSharedAnnotations,
isReplaying: isReplaying,
gridSize: gridSize,
diff --git a/Crossmate/Views/Puzzle/PuzzleCommands.swift b/Crossmate/Views/Puzzle/PuzzleCommands.swift
@@ -10,6 +10,31 @@ final class RevealConfirmation {
pendingScope = scope
isConfirming = true
}
+
+ /// Executes the pending reveal after the player confirms the alert. Every
+ /// reveal surface (toolbar Hints menu, app-level commands, the VoiceOver
+ /// cell action) funnels through `request` and this, so no path can reveal
+ /// without the confirmation. Returns the VoiceOver announcement for a
+ /// square reveal ("Revealed R"); the wider scopes return nil — their
+ /// outcome is read from the grid itself.
+ func performPending(on session: PlayerSession) -> String? {
+ switch pendingScope {
+ case .square:
+ session.revealSquare()
+ return CellAccessibilityDescriber(puzzle: session.puzzle, authorNames: [:])
+ .revealAnnouncement(
+ atRow: session.selectedRow,
+ atCol: session.selectedCol,
+ squares: session.game.squares
+ )
+ case .word:
+ session.revealCurrentWord()
+ return nil
+ case .puzzle:
+ session.revealPuzzle()
+ return nil
+ }
+ }
}
/// The active puzzle's command surface, published into the focused scene by
diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift
@@ -23,8 +23,7 @@ struct PuzzleToolbarModifier: ViewModifier {
@Binding var isConfirmingResign: Bool
@Binding var isConfirmingDelete: Bool
@Binding var isConfirmingLeave: Bool
- @Binding var isConfirmingReveal: Bool
- @Binding var pendingRevealScope: RevealScope
+ let revealConfirmation: RevealConfirmation
@Binding var isConfirmingClear: Bool
@Binding var isShowingShareSheet: Bool
@Environment(PlayerPreferences.self) private var preferences
@@ -139,9 +138,9 @@ struct PuzzleToolbarModifier: ViewModifier {
Button("Fill Half") { session.fillHalf() }
}
Section {
- Button("Reveal Square") { confirmReveal(.square) }
- Button("Reveal Word") { confirmReveal(.word) }
- Button("Reveal Puzzle", role: .destructive) { confirmReveal(.puzzle) }
+ Button("Reveal Square") { revealConfirmation.request(.square) }
+ Button("Reveal Word") { revealConfirmation.request(.word) }
+ Button("Reveal Puzzle", role: .destructive) { revealConfirmation.request(.puzzle) }
}
} label: {
Label("Hints", systemImage: "lightbulb")
@@ -149,11 +148,6 @@ struct PuzzleToolbarModifier: ViewModifier {
.disabled(isEditingBlocked)
}
- private func confirmReveal(_ scope: RevealScope) {
- pendingRevealScope = scope
- isConfirmingReveal = true
- }
-
private var playersMenu: some View {
Menu {
playerRosterSection
@@ -305,8 +299,7 @@ struct PuzzlePresentationModifier: ViewModifier {
@Binding var isConfirmingResign: Bool
@Binding var isConfirmingDelete: Bool
@Binding var isConfirmingLeave: Bool
- @Binding var isConfirmingReveal: Bool
- @Binding var pendingRevealScope: RevealScope
+ let revealConfirmation: RevealConfirmation
@Binding var isConfirmingClear: Bool
@Binding var leaveError: String?
@Binding var destructiveActionError: String?
@@ -317,6 +310,7 @@ struct PuzzlePresentationModifier: ViewModifier {
@Environment(PlayerPreferences.self) private var preferences
func body(content: Content) -> some View {
+ @Bindable var revealConfirmation = revealConfirmation
content
.alert("Not Quite Right", isPresented: $showErrorsAlert) {
Button("OK", role: .cancel) {}
@@ -347,13 +341,21 @@ struct PuzzlePresentationModifier: ViewModifier {
} message: {
Text("You will lose access to \"\(session.puzzle.title)\".")
}
- .alert(pendingRevealScope.title, isPresented: $isConfirmingReveal) {
+ .alert(
+ revealConfirmation.pendingScope.title,
+ isPresented: $revealConfirmation.isConfirming
+ ) {
Button("Reveal", role: .destructive) {
- performReveal(pendingRevealScope)
+ // The announcement replaces the direct spoken feedback the
+ // VoiceOver cell action gave before it routed through this
+ // alert; it is a no-op without assistive tech running.
+ if let announcement = revealConfirmation.performPending(on: session) {
+ AccessibilityNotification.Announcement(announcement).post()
+ }
}
Button("Cancel", role: .cancel) {}
} message: {
- Text(pendingRevealScope.message)
+ Text(revealConfirmation.pendingScope.message)
}
.alert("Clear Puzzle?", isPresented: $isConfirmingClear) {
Button("Clear", role: .destructive) {
@@ -413,14 +415,6 @@ struct PuzzlePresentationModifier: ViewModifier {
}
}
- private func performReveal(_ scope: RevealScope) {
- switch scope {
- case .square: session.revealSquare()
- case .word: session.revealCurrentWord()
- case .puzzle: session.revealPuzzle()
- }
- }
-
private var deleteConfirmationMessage: Text {
if session.mutator.isOwned && session.mutator.isShared {
Text("This will permanently delete \"\(session.puzzle.title)\" from iCloud for everyone.")
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -136,8 +136,6 @@ struct PuzzleView: View {
}
var body: some View {
- @Bindable var revealConfirmation = revealConfirmation
-
Group {
switch effectivePadLayout {
case .landscape:
@@ -185,8 +183,7 @@ struct PuzzleView: View {
isConfirmingResign: $isConfirmingResign,
isConfirmingDelete: $isConfirmingDelete,
isConfirmingLeave: $isConfirmingLeave,
- isConfirmingReveal: $revealConfirmation.isConfirming,
- pendingRevealScope: $revealConfirmation.pendingScope,
+ revealConfirmation: revealConfirmation,
isConfirmingClear: $isConfirmingClear,
isShowingShareSheet: $isShowingShareSheet
))
@@ -208,8 +205,7 @@ struct PuzzleView: View {
isConfirmingResign: $isConfirmingResign,
isConfirmingDelete: $isConfirmingDelete,
isConfirmingLeave: $isConfirmingLeave,
- isConfirmingReveal: $revealConfirmation.isConfirming,
- pendingRevealScope: $revealConfirmation.pendingScope,
+ revealConfirmation: revealConfirmation,
isConfirmingClear: $isConfirmingClear,
leaveError: $leaveError,
destructiveActionError: $destructiveActionError,
@@ -475,6 +471,7 @@ struct PuzzleView: View {
GridView(
session: session,
roster: roster,
+ revealConfirmation: revealConfirmation,
showsSharedAnnotations: session.mutator.isShared,
showsPeerCursors: !isSolved,
replayFrame: replay.frame
diff --git a/Tests/Unit/RevealConfirmationTests.swift b/Tests/Unit/RevealConfirmationTests.swift
@@ -0,0 +1,130 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Cancel/confirm behaviour of the reveal confirmation that every reveal
+/// surface routes through — the toolbar Hints menu, the app-level ⌘R
+/// commands, and the VoiceOver cell action: requesting a reveal must never
+/// touch the grid by itself, and only an explicit confirmation applies it.
+@MainActor
+@Suite("RevealConfirmation")
+struct RevealConfirmationTests {
+
+ /// 3x3 grid with a centre block:
+ /// A B C
+ /// D # E
+ /// F G H
+ private func makeSession() throws -> PlayerSession {
+ let source = """
+ Title: Reveal Confirmation Fixture
+
+
+ ABC
+ D#E
+ FGH
+
+
+ A1. Top row ~ ABC
+ A3. Bottom row ~ FGH
+ D1. Left column ~ ADF
+ D2. Right column ~ CEH
+ """
+ let puzzle = Puzzle(xd: try XD.parse(source))
+ let game = Game(puzzle: puzzle)
+ let mutator = GameMutator(game: game, gameID: UUID(), movesUpdater: nil)
+ return PlayerSession(game: game, mutator: mutator)
+ }
+
+ private func isUntouched(_ session: PlayerSession) -> Bool {
+ session.game.squares.allSatisfy { row in row.allSatisfy { $0.entry.isEmpty } }
+ }
+
+ @Test("Requesting a reveal arms the confirmation without touching the grid")
+ func requestArmsWithoutRevealing() throws {
+ let session = try makeSession()
+ let confirmation = RevealConfirmation()
+
+ confirmation.request(.square)
+
+ #expect(confirmation.isConfirming)
+ #expect(confirmation.pendingScope == .square)
+ #expect(isUntouched(session))
+ }
+
+ @Test("Cancelling a requested reveal leaves the grid untouched")
+ func cancelLeavesGridUntouched() throws {
+ let session = try makeSession()
+ let confirmation = RevealConfirmation()
+
+ confirmation.request(.puzzle)
+ // The alert's Cancel button only dismisses; nothing performs.
+ confirmation.isConfirming = false
+
+ #expect(isUntouched(session))
+ #expect(session.game.completionState == .incomplete)
+ }
+
+ @Test("Confirming a square reveal fills the selected square and announces it")
+ func confirmRevealsSquare() throws {
+ let session = try makeSession()
+ let confirmation = RevealConfirmation()
+ session.select(row: 0, col: 0)
+
+ confirmation.request(.square)
+ let announcement = confirmation.performPending(on: session)
+
+ #expect(session.game.squares[0][0].entry == "A")
+ #expect(session.game.squares[0][0].mark.isRevealed)
+ #expect(announcement == "Revealed A")
+ // Only the confirmed square was spent, not its word.
+ #expect(session.game.squares[0][1].entry.isEmpty)
+ }
+
+ @Test("Confirming after a cancelled request applies the new scope, not the stale one")
+ func reRequestAfterCancelUsesNewScope() throws {
+ let session = try makeSession()
+ let confirmation = RevealConfirmation()
+ session.select(row: 0, col: 0)
+
+ confirmation.request(.puzzle)
+ confirmation.isConfirming = false
+ confirmation.request(.square)
+ confirmation.performPending(on: session)
+
+ #expect(session.game.squares[0][0].entry == "A")
+ #expect(session.game.squares[2][2].entry.isEmpty)
+ #expect(session.game.completionState == .incomplete)
+ }
+
+ @Test("Confirming a word reveal fills the current word without a square announcement")
+ func confirmRevealsWord() throws {
+ let session = try makeSession()
+ let confirmation = RevealConfirmation()
+ session.select(row: 0, col: 1)
+
+ confirmation.request(.word)
+ let announcement = confirmation.performPending(on: session)
+
+ #expect(session.game.squares[0][0].entry == "A")
+ #expect(session.game.squares[0][1].entry == "B")
+ #expect(session.game.squares[0][2].entry == "C")
+ #expect(session.game.squares[2][0].entry.isEmpty)
+ #expect(announcement == nil)
+ }
+
+ @Test("Confirming a puzzle reveal fills the grid and latches completion")
+ func confirmRevealsPuzzle() throws {
+ let session = try makeSession()
+ let confirmation = RevealConfirmation()
+
+ confirmation.request(.puzzle)
+ let announcement = confirmation.performPending(on: session)
+
+ #expect(session.game.squares[0][0].entry == "A")
+ #expect(session.game.squares[2][2].entry == "H")
+ #expect(session.game.squares[2][2].mark.isRevealed)
+ #expect(session.game.completionState == .solved)
+ #expect(announcement == nil)
+ }
+}