commit c4e6d5121eec126b9e109ddde7e630a0a6c9e98d
parent 778814baa99dd52eb942f2a63b1d2cd36667f49f
Author: Michael Camilleri <[email protected]>
Date: Sat, 4 Jul 2026 09:50:02 +0900
Make the puzzle grid playable with VoiceOver
The Canvas-drawn grid previously exposed no useful accessibility tree,
so blind solvers could not explore cells, hear clue context, or keep the
session cursor aligned with VoiceOver focus. This commit adds synthetic
per-cell elements over the existing grid geometry, with labels for word
position, values for entries, marks, and author attribution, and
double-tap behaviour that uses the same selection path as a sighted tap.
VoiceOver focus now follows the session cursor as entry advances, and
flicking onto a cell updates the cursor that drives the Clue Bar and
shared presence. The grid also gains clue and incomplete-clue rotors,
word-change announcements, check and reveal custom actions, completion
feedback, and coalesced low-priority announcements for friends' fills.
The accessibility wording is covered by unit tests, and Square becomes
Equatable so the grid modifier can diff square changes for peer-fill
announcements.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
6 files changed, 879 insertions(+), 7 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -107,6 +107,7 @@
786813F3418C32EFBF296220 /* XDMarkupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9FE0A9624DB87758F3D1768 /* XDMarkupTests.swift */; };
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; };
7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; };
+ 7BE53C851A311A7B6255B45E /* CellAccessibilityDescriberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3059A4C23D040E0D1F258B92 /* CellAccessibilityDescriberTests.swift */; };
7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507B4DC893CE8AC4778CBACE /* NotificationService.swift */; };
7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */; };
7E6ED856CE78A774B061CA60 /* FriendEncryptionKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833AAFBA0094BAB27E13FE29 /* FriendEncryptionKeyDirectory.swift */; };
@@ -114,6 +115,7 @@
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; };
8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D491B7232333AA8957732387 /* PendingEditFlagTests.swift */; };
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; };
+ 83CF8376EBFD7271931AC1B7 /* GridAccessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4CA70D10760D6770907606 /* GridAccessibility.swift */; };
85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */; };
85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */; };
884BC090D4E2D416AA52D6FD /* FriendPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E78C275F8A90E3E3EEF190CC /* FriendPickerView.swift */; };
@@ -296,6 +298,7 @@
2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeCoordinator.swift; sourceTree = "<group>"; };
2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalUploadTests.swift; sourceTree = "<group>"; };
2E2702C74378FD2F14D1CE33 /* FriendControllerNicknameReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendControllerNicknameReplayTests.swift; sourceTree = "<group>"; };
+ 3059A4C23D040E0D1F258B92 /* CellAccessibilityDescriberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellAccessibilityDescriberTests.swift; sourceTree = "<group>"; };
3111803C8FFFB0C839217482 /* NicknameDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectory.swift; sourceTree = "<group>"; };
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; };
3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; };
@@ -473,6 +476,7 @@
FED7C2528355BC007E48B7EF /* ParticipantSummaries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantSummaries.swift; sourceTree = "<group>"; };
FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationNavigationBrokerTests.swift; sourceTree = "<group>"; };
FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisherTests.swift; sourceTree = "<group>"; };
+ FF4CA70D10760D6770907606 /* GridAccessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridAccessibility.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
@@ -556,6 +560,7 @@
4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */,
978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */,
B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */,
+ 3059A4C23D040E0D1F258B92 /* CellAccessibilityDescriberTests.swift */,
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */,
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */,
@@ -712,6 +717,7 @@
9F13534332699C80382FE682 /* CellPatterns.swift */,
E935CE4384F3B67CC22EEBAC /* ClueBar.swift */,
7B8B65482CA1739A3863A99E /* ClueList.swift */,
+ FF4CA70D10760D6770907606 /* GridAccessibility.swift */,
ED48AD9C3A7A113D101BBD21 /* GridView.swift */,
6940546CFA1E87EF814AA6BB /* HardwareKeyboardInputView.swift */,
E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */,
@@ -991,6 +997,7 @@
D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */,
DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */,
A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */,
+ 7BE53C851A311A7B6255B45E /* CellAccessibilityDescriberTests.swift in Sources */,
328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */,
3B3F30AA1B7AA6D4465CB537 /* EngagementMessageAuthenticatorTests.swift in Sources */,
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */,
@@ -1118,6 +1125,7 @@
44FF4A5334A4086DEA7D8A7B /* GameShareItem.swift in Sources */,
D58980B92C99122C368D4216 /* GameStore.swift in Sources */,
1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */,
+ 83CF8376EBFD7271931AC1B7 /* GridAccessibility.swift in Sources */,
4B8CA45845618D75A3313816 /* GridSilhouette.swift in Sources */,
ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */,
1AAFF86B40CBBFF1EC9ADF9F /* GridThumbnailView.swift in Sources */,
diff --git a/Crossmate/Models/Square.swift b/Crossmate/Models/Square.swift
@@ -2,7 +2,7 @@ import Foundation
/// Per-cell state for one square in the grid. Combines the letter entry and
/// mark (previously parallel arrays on `Game`) with sync bookkeeping fields.
-struct Square: Sendable {
+struct Square: Sendable, Equatable {
var entry: String = ""
var mark: CellMark = .none
var updatedAt: Date?
diff --git a/Crossmate/Views/Puzzle/GridAccessibility.swift b/Crossmate/Views/Puzzle/GridAccessibility.swift
@@ -0,0 +1,532 @@
+import SwiftUI
+
+/// Builds the VoiceOver label/value strings for one grid cell. Pure and
+/// UI-free so the wording is unit-testable without any view scaffolding.
+struct CellAccessibilityDescriber {
+ let puzzle: Puzzle
+ /// Display names keyed by authorID, spoken as "filled by <name>" on a
+ /// filled square ("you" for the local player). Empty when author
+ /// attribution should not be spoken (solo games, hidden annotations).
+ let authorNames: [String: String]
+
+ /// The cell's position within its word(s): "5 Across, letter 2 of 7;
+ /// 3 Down, letter 4 of 5", dropping either half when the cell isn't part
+ /// of a crossing word. A cell in no word at all (a length-1 run) falls
+ /// back to its coordinates so it still reads as something.
+ func label(atRow row: Int, atCol col: Int) -> String {
+ var parts: [String] = []
+ for direction in [Puzzle.Direction.across, .down] {
+ let cells = puzzle.wordCells(atRow: row, col: col, direction: direction)
+ guard let number = cells.first?.number,
+ let index = cells.firstIndex(where: { $0.row == row && $0.col == col })
+ else { continue }
+ let name = direction == .across ? "Across" : "Down"
+ parts.append("\(number) \(name), letter \(index + 1) of \(cells.count)")
+ }
+ if parts.isEmpty {
+ parts.append("Row \(row + 1), Column \(col + 1)")
+ }
+ let special = puzzle.cells[row][col].special
+ if special == .circled { parts.append("circled") }
+ if special == .shaded { parts.append("shaded") }
+ return parts.joined(separator: "; ")
+ }
+
+ /// The spoken heading for a clue: "5 Across: Big cheese in Holland".
+ /// Used both as the rotor entry label and as the announcement prefix.
+ /// Nil when no clue carries that number in that direction.
+ func clueLabel(number: Int, direction: Puzzle.Direction) -> String? {
+ let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
+ guard let clue = clues.first(where: { $0.number == number }) else { return nil }
+ let name = direction == .across ? "Across" : "Down"
+ return "\(number) \(name): \(clue.text)"
+ }
+
+ /// The announcement posted when the cursor enters a new word or the
+ /// direction toggles: the clue heading plus progress — "5 Across: Big
+ /// cheese in Holland. 7 letters, 3 filled." A zero filled-count is
+ /// omitted; a complete word is spoken as "all filled". Nil when the cell
+ /// has no word in `direction`.
+ func clueAnnouncement(
+ atRow row: Int,
+ atCol col: Int,
+ direction: Puzzle.Direction,
+ squares: [[Square]]
+ ) -> String? {
+ let cells = puzzle.wordCells(atRow: row, col: col, direction: direction)
+ guard let number = cells.first?.number,
+ let heading = clueLabel(number: number, direction: direction)
+ else { return nil }
+ let filled = cells.count { !squares[$0.row][$0.col].entry.isEmpty }
+ var progress = "\(cells.count) letters"
+ if filled == cells.count {
+ progress += ", all filled"
+ } else if filled > 0 {
+ progress += ", \(filled) filled"
+ }
+ return "\(heading). \(progress)"
+ }
+
+ /// The coalesced announcement for a burst of peer fills: "Alice filled
+ /// 5 Across" when the burst completes a single word, "Alice filled
+ /// 3 squares in 5 Across" while it's still open, and a plain square
+ /// count when the burst spans words. Gap cells count as filled for the
+ /// completeness read, matching the completion logic.
+ func peerFillAnnouncement(
+ playerName: String,
+ positions: Set<GridPosition>,
+ squares: [[Square]]
+ ) -> String? {
+ guard let first = positions.first else { return nil }
+ for direction in [Puzzle.Direction.across, .down] {
+ let cells = puzzle.wordCells(atRow: first.row, col: first.col, direction: direction)
+ guard let number = cells.first?.number else { continue }
+ let wordPositions = Set(cells.map { GridPosition(row: $0.row, col: $0.col) })
+ guard positions.isSubset(of: wordPositions) else { continue }
+ let word = "\(number) \(direction == .across ? "Across" : "Down")"
+ let complete = cells.allSatisfy {
+ !squares[$0.row][$0.col].entry.isEmpty || $0.expectsBlank
+ }
+ if complete { return "\(playerName) filled \(word)" }
+ if positions.count == 1 { return "\(playerName) filled a square in \(word)" }
+ return "\(playerName) filled \(positions.count) squares in \(word)"
+ }
+ return positions.count == 1
+ ? "\(playerName) filled a square"
+ : "\(playerName) filled \(positions.count) squares"
+ }
+
+ /// The outcome of a check gesture over `cells`, read against the marks
+ /// the check just applied. Single-cell checks read as a bare verdict;
+ /// word checks count mistakes and unfilled squares.
+ func checkAnnouncement(for cells: [Puzzle.Cell], squares: [[Square]]) -> String {
+ let open = cells.filter { !$0.isBlock }
+ let filled = open.filter { !squares[$0.row][$0.col].entry.isEmpty }
+ guard !filled.isEmpty else { return "Nothing to check" }
+ let wrong = filled.count { squares[$0.row][$0.col].mark.isCheckedWrong }
+ if open.count == 1 {
+ return wrong > 0 ? "Incorrect" : "Correct"
+ }
+ if wrong > 0 {
+ return wrong == 1 ? "1 square incorrect" : "\(wrong) squares incorrect"
+ }
+ let empty = open.count - filled.count
+ if empty == 0 { return "All correct" }
+ return empty == 1
+ ? "No mistakes, 1 square empty"
+ : "No mistakes, \(empty) squares empty"
+ }
+
+ /// Speaks the letter a reveal just wrote: "Revealed R". Nil when the
+ /// reveal was a no-op (e.g. a gap cell, whose correct state is empty).
+ func revealAnnouncement(atRow row: Int, atCol col: Int, squares: [[Square]]) -> String? {
+ let entry = squares[row][col].entry
+ guard !entry.isEmpty else { return nil }
+ return "Revealed \(entry)"
+ }
+
+ /// The grid-completion announcement. `.incomplete` is a return to normal
+ /// play and stays silent. `.filledWithErrors` is the state a sighted
+ /// player notices by the absence of the success panel — for a blind
+ /// player it must be said out loud.
+ func completionAnnouncement(for state: Game.CompletionState) -> String? {
+ switch state {
+ case .incomplete: nil
+ case .filledWithErrors: "The grid is full, but something isn't right"
+ case .solved: "Puzzle solved. Congratulations!"
+ }
+ }
+
+ /// The cell's fill and its provenance: "Empty", "R", "R, draft",
+ /// "R, revealed", "R, incorrect", plus "filled by Alice" when author
+ /// names are being spoken. "Draft" matches the keyboard's user-facing
+ /// term for pencil mode.
+ func value(for square: Square) -> String {
+ guard !square.entry.isEmpty else { return "Empty" }
+ var parts = [square.entry]
+ if square.mark.isPencil { parts.append("draft") }
+ if square.mark.isRevealed { parts.append("revealed") }
+ if square.mark.isCheckedWrong { parts.append("incorrect") }
+ if let authorID = square.letterAuthorID, let name = authorNames[authorID] {
+ parts.append("filled by \(name)")
+ }
+ return parts.joined(separator: ", ")
+ }
+}
+
+extension View {
+ /// Synthesises a VoiceOver representation for the Canvas-drawn puzzle
+ /// grid: one accessibility element per open cell, framed to match the
+ /// drawn cell exactly (so touch exploration works and blocks read as
+ /// gaps), plus two-way sync between VoiceOver focus and the session
+ /// cursor. Apply to the grid container alongside the tap recogniser.
+ func puzzleGridAccessibility(
+ session: PlayerSession,
+ roster: PlayerRoster,
+ showsSharedAnnotations: Bool,
+ isReplaying: Bool,
+ gridSize: CGSize,
+ spacing: CGFloat
+ ) -> some View {
+ modifier(PuzzleGridAccessibility(
+ session: session,
+ roster: roster,
+ showsSharedAnnotations: showsSharedAnnotations,
+ isReplaying: isReplaying,
+ gridSize: gridSize,
+ spacing: spacing
+ ))
+ }
+}
+
+private struct PuzzleGridAccessibility: ViewModifier {
+ let session: PlayerSession
+ let roster: PlayerRoster
+ let showsSharedAnnotations: Bool
+ let isReplaying: Bool
+ let gridSize: CGSize
+ let spacing: CGFloat
+
+ @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled
+ @AccessibilityFocusState private var focusedCell: GridPosition?
+ /// Pairs the rotor entries with the cell elements they jump to.
+ @Namespace private var rotorNamespace
+ /// Peer fills buffered per author while a burst is in flight, flushed as
+ /// one coalesced announcement after a quiet interval — a peer typing a
+ /// word arrives as one edit per letter and must not announce per letter.
+ @State private var pendingPeerFills: [String: Set<GridPosition>] = [:]
+ @State private var peerFillFlushTask: Task<Void, Never>?
+
+ /// Whether to build the synthetic hierarchy. Normally only while
+ /// VoiceOver is running, but the Simulator can't run VoiceOver at all —
+ /// Accessibility Inspector reads the tree without setting the flag — so
+ /// debug builds can force it on with the `-ForceGridAccessibility`
+ /// launch argument.
+ private var isActive: Bool {
+ if voiceOverEnabled { return true }
+ #if DEBUG
+ return ProcessInfo.processInfo.arguments.contains("-ForceGridAccessibility")
+ #else
+ return false
+ #endif
+ }
+
+ func body(content: Content) -> some View {
+ // The synthetic hierarchy is ~441 accessibility-only nodes, so it is
+ // built only while VoiceOver is running — sighted users keep the
+ // single-Canvas render path untouched. Replay is display-only: the
+ // cells are suppressed and the scrubber narrates the playhead.
+ if isActive, !isReplaying, gridSize != .zero {
+ content
+ .accessibilityChildren { cellElements }
+ // Selection → focus: `enter(_:)` auto-advances the cursor, so
+ // following it makes VoiceOver read the newly focused cell —
+ // that is the typing echo; no explicit announcement needed.
+ .onChange(of: selection) { _, newValue in
+ focusedCell = newValue
+ }
+ // Focus → selection: a flick onto a cell is navigation, so the
+ // session cursor (and the clue bar, and peers' view of our
+ // cursor) follows. Guarded on inequality because `select` on
+ // the already-selected cell toggles direction — that toggle is
+ // reserved for an explicit double-tap (the activate action),
+ // matching what a sighted tap does.
+ .onChange(of: focusedCell) { _, newValue in
+ guard let newValue, newValue != selection else { return }
+ session.select(row: newValue.row, col: newValue.col)
+ }
+ // Word-change context: moving within a word stays quiet (the
+ // focused cell's own label carries the position); entering a
+ // new word or toggling direction announces the clue and its
+ // progress once.
+ .onChange(of: currentClueRef) { _, newValue in
+ guard newValue != nil else { return }
+ announceCurrentClue()
+ }
+ .accessibilityRotor("Across clues") { clueRotorEntries(for: .across) }
+ .accessibilityRotor("Down clues") { clueRotorEntries(for: .down) }
+ .accessibilityRotor("Incomplete clues") { incompleteClueRotorEntries }
+ // Peer fills: both the realtime overlay and CloudKit merges
+ // land on the open game through `GameStore.refreshCurrentGame`,
+ // so diffing the squares here catches every remote path with
+ // no sync plumbing. Local edits are filtered out by authorID.
+ .onChange(of: session.game.squares) { oldValue, newValue in
+ collectPeerFills(from: oldValue, to: newValue)
+ }
+ .onChange(of: session.completionEvent) { _, event in
+ guard let event else { return }
+ announce(describer.completionAnnouncement(for: event.state))
+ }
+ .onAppear { focusedCell = selection }
+ .onDisappear { peerFillFlushTask?.cancel() }
+ } else {
+ content
+ }
+ }
+
+ private var selection: GridPosition {
+ GridPosition(row: session.selectedRow, col: session.selectedCol)
+ }
+
+ /// Identity of the word the cursor is in: changes when the cursor crosses
+ /// into a different word or the direction toggles, and drives the clue
+ /// announcement. Nil when the focused cell has no word in the current
+ /// direction.
+ private var currentClueRef: Puzzle.ClueRef? {
+ session.puzzle.clue(
+ atRow: session.selectedRow,
+ col: session.selectedCol,
+ direction: session.direction
+ ).map { Puzzle.ClueRef(number: $0.number, direction: session.direction) }
+ }
+
+ private var describer: CellAccessibilityDescriber {
+ CellAccessibilityDescriber(puzzle: session.puzzle, authorNames: [:])
+ }
+
+ private func announceCurrentClue() {
+ announce(describer.clueAnnouncement(
+ atRow: session.selectedRow,
+ atCol: session.selectedCol,
+ direction: session.direction,
+ squares: session.game.squares
+ ))
+ }
+
+ /// Posts a spoken announcement. Low priority queues behind whatever
+ /// VoiceOver is already saying (and may be dropped if the user keeps
+ /// talking over it) — right for ambient peer activity, wrong for direct
+ /// responses to the user's own action.
+ private func announce(_ text: String?, lowPriority: Bool = false) {
+ guard let text else { return }
+ if lowPriority {
+ var attributed = AttributedString(text)
+ attributed.accessibilitySpeechAnnouncementPriority = .low
+ AccessibilityNotification.Announcement(attributed).post()
+ } else {
+ AccessibilityNotification.Announcement(text).post()
+ }
+ }
+
+ // MARK: - Check / reveal actions
+
+ private func checkAndAnnounce(wholeWord: Bool) {
+ let cells: [Puzzle.Cell]
+ if wholeWord {
+ session.checkCurrentWord()
+ cells = session.puzzle.wordCells(
+ atRow: session.selectedRow,
+ col: session.selectedCol,
+ direction: session.direction
+ )
+ } else {
+ session.checkSquare()
+ cells = [session.puzzle.cells[session.selectedRow][session.selectedCol]]
+ }
+ 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:
+ /// a cleared square carries no author, so a peer's clear is
+ /// indistinguishable here from the local player's own delete and must
+ /// stay silent rather than misfire on every local backspace.
+ private func collectPeerFills(from old: [[Square]], to new: [[Square]]) {
+ guard old.count == new.count else { return }
+ let localAuthorID = roster.entries.first(where: { $0.isLocal })?.authorID
+ var found = false
+ for row in 0..<new.count {
+ guard old[row].count == new[row].count else { continue }
+ for col in 0..<new[row].count {
+ let after = new[row][col]
+ guard after.entry != old[row][col].entry,
+ !after.entry.isEmpty,
+ let author = after.letterAuthorID,
+ author != localAuthorID
+ else { continue }
+ pendingPeerFills[author, default: []].insert(GridPosition(row: row, col: col))
+ found = true
+ }
+ }
+ guard found else { return }
+ peerFillFlushTask?.cancel()
+ peerFillFlushTask = Task {
+ try? await Task.sleep(for: .seconds(2))
+ guard !Task.isCancelled else { return }
+ flushPeerFills()
+ }
+ }
+
+ private func flushPeerFills() {
+ let fills = pendingPeerFills
+ pendingPeerFills = [:]
+ guard !fills.isEmpty else { return }
+ let names = Dictionary(
+ roster.entries.map { ($0.authorID, $0.name) },
+ uniquingKeysWith: { first, _ in first }
+ )
+ let squares = session.game.squares
+ let texts = fills.keys.sorted().compactMap { author in
+ describer.peerFillAnnouncement(
+ playerName: names[author] ?? "A collaborator",
+ positions: fills[author] ?? [],
+ squares: squares
+ )
+ }
+ guard !texts.isEmpty else { return }
+ announce(texts.joined(separator: ". "), lowPriority: true)
+ }
+
+ // MARK: - Rotors
+
+ /// Rotor entries for one direction's clue list, each jumping VoiceOver to
+ /// the word's first cell. The prepare closure aligns the session cursor
+ /// first so the jump lands with direction and selection already correct
+ /// (and the clue announcement fires from the resulting word change).
+ @AccessibilityRotorContentBuilder
+ private func clueRotorEntries(
+ for direction: Puzzle.Direction
+ ) -> some AccessibilityRotorContent {
+ let puzzle = session.puzzle
+ let describer = CellAccessibilityDescriber(puzzle: puzzle, authorNames: [:])
+ let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
+ ForEach(clues) { clue in
+ if let cell = puzzle.cell(numbered: clue.number),
+ let label = describer.clueLabel(number: clue.number, direction: direction) {
+ AccessibilityRotorEntry(
+ Text(label),
+ id: GridPosition(row: cell.row, col: cell.col),
+ in: rotorNamespace
+ ) {
+ session.selectClue(direction: direction, number: clue.number)
+ }
+ }
+ }
+ }
+
+ /// "What's left": every clue whose word still has an unfilled square,
+ /// acrosses first then downs. Gap cells (`expectsBlank`) count as filled —
+ /// their correct state is empty, so they must not pin a word in this
+ /// rotor forever.
+ @AccessibilityRotorContentBuilder
+ private var incompleteClueRotorEntries: some AccessibilityRotorContent {
+ let puzzle = session.puzzle
+ let describer = CellAccessibilityDescriber(puzzle: puzzle, authorNames: [:])
+ ForEach(incompleteClueRefs, id: \.self) { ref in
+ if let cell = puzzle.cell(numbered: ref.number),
+ let label = describer.clueLabel(number: ref.number, direction: ref.direction) {
+ AccessibilityRotorEntry(
+ Text(label),
+ id: GridPosition(row: cell.row, col: cell.col),
+ in: rotorNamespace
+ ) {
+ session.selectClue(direction: ref.direction, number: ref.number)
+ }
+ }
+ }
+ }
+
+ private var incompleteClueRefs: [Puzzle.ClueRef] {
+ let puzzle = session.puzzle
+ let squares = session.game.squares
+ var refs: [Puzzle.ClueRef] = []
+ for direction in [Puzzle.Direction.across, .down] {
+ let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
+ for clue in clues {
+ guard let start = puzzle.cell(numbered: clue.number) else { continue }
+ let cells = puzzle.wordCells(
+ atRow: start.row, col: start.col, direction: direction
+ )
+ let isIncomplete = cells.contains {
+ squares[$0.row][$0.col].entry.isEmpty && !$0.expectsBlank
+ }
+ if isIncomplete {
+ refs.append(Puzzle.ClueRef(number: clue.number, direction: direction))
+ }
+ }
+ }
+ return refs
+ }
+
+ /// The hidden per-cell hierarchy handed to `accessibilityChildren`. Each
+ /// open cell becomes a clear rect at the exact `PuzzleGridGeometry` frame
+ /// of the drawn cell; blocks get no element and read as gaps. Emitted in
+ /// row-major order, which is also VoiceOver's flick order.
+ private var cellElements: some View {
+ let puzzle = session.puzzle
+ let geometry = PuzzleGridGeometry(
+ size: gridSize,
+ columns: puzzle.width,
+ rows: puzzle.height,
+ spacing: spacing
+ )
+ let describer = CellAccessibilityDescriber(
+ puzzle: puzzle,
+ authorNames: authorNames
+ )
+ let selection = self.selection
+ return ZStack(alignment: .topLeading) {
+ ForEach(openPositions, id: \.self) { pos in
+ let rect = geometry.cellRect(row: pos.row, col: pos.col)
+ Color.clear
+ .accessibilityElement()
+ .accessibilityLabel(describer.label(atRow: pos.row, atCol: pos.col))
+ .accessibilityValue(describer.value(for: session.game.squares[pos.row][pos.col]))
+ .accessibilityAddTraits(pos == selection ? .isSelected : [])
+ // Double-tap: same entry point as a sighted tap, including
+ // select's toggle-direction behaviour on the focused cell.
+ .accessibilityAction {
+ session.select(row: pos.row, col: pos.col)
+ }
+ .accessibilityFocused($focusedCell, equals: pos)
+ .accessibilityRotorEntry(id: pos, in: rotorNamespace)
+ // These act on the session's *selected* cell, which the
+ // focus sync keeps equal to the focused element. Check and
+ // reveal disappear on a solved grid (the mutator rejects
+ // them anyway); Switch Direction stays for navigation.
+ .accessibilityActions {
+ Button("Switch Direction") { session.toggleDirection() }
+ if session.game.completionState != .solved {
+ Button("Check Square") { checkAndAnnounce(wholeWord: false) }
+ Button("Check Word") { checkAndAnnounce(wholeWord: true) }
+ Button("Reveal Square") { revealAndAnnounce() }
+ }
+ }
+ .frame(width: rect.width, height: rect.height)
+ .position(x: rect.midX, y: rect.midY)
+ }
+ }
+ .frame(width: gridSize.width, height: gridSize.height, alignment: .topLeading)
+ }
+
+ private var openPositions: [GridPosition] {
+ let puzzle = session.puzzle
+ var positions: [GridPosition] = []
+ for row in 0..<puzzle.height {
+ for col in 0..<puzzle.width where !puzzle.cells[row][col].isBlock {
+ positions.append(GridPosition(row: row, col: col))
+ }
+ }
+ return positions
+ }
+
+ /// Spoken author attribution, mirroring the visual author tints: only in
+ /// a shared game, with the local player spoken as "you".
+ private var authorNames: [String: String] {
+ guard showsSharedAnnotations else { return [:] }
+ return Dictionary(
+ roster.entries.map { ($0.authorID, $0.isLocal ? "you" : $0.name) },
+ uniquingKeysWith: { first, _ in first }
+ )
+ }
+}
diff --git a/Crossmate/Views/Puzzle/GridView.swift b/Crossmate/Views/Puzzle/GridView.swift
@@ -136,6 +136,16 @@ struct GridView: View {
}
}
.onGeometryChange(for: CGSize.self) { $0.size } action: { gridSize = $0 }
+ // Synthetic VoiceOver elements over the Canvas layers; built only
+ // while VoiceOver is running (see GridAccessibility.swift).
+ .puzzleGridAccessibility(
+ session: session,
+ roster: roster,
+ showsSharedAnnotations: showsSharedAnnotations,
+ isReplaying: isReplaying,
+ gridSize: gridSize,
+ spacing: spacing
+ )
}
/// The canonical fill to display for a rebus square on a solved puzzle, or
@@ -416,7 +426,9 @@ private struct PuzzleGridLayerLayout: Layout {
/// `Canvas` layers (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`) all
/// derive their cell rects from this, so every layer stays pixel-aligned; it
/// also inverts the mapping (`cell(at:)`) for the grid's single tap recogniser.
-private struct PuzzleGridGeometry {
+/// Internal (not private) so `GridAccessibility` can frame its synthetic
+/// VoiceOver elements on the same lattice.
+struct PuzzleGridGeometry {
let cellSize: CGFloat
let spacing: CGFloat
let gridSize: CGSize
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -65,6 +65,11 @@ struct PuzzleView: View {
@State private var hasSolved = false
@State private var replay = ReplayControls()
@State private var padLayout: PadLayout?
+ /// The most recent size reported while the app was frontmost. Snapshot
+ /// passes the system runs while backgrounded report other orientations;
+ /// those must not disturb the layout, so only active-time sizes are kept
+ /// and re-applied when the scene returns to the foreground.
+ @State private var lastActiveSize: CGSize = .zero
/// The shared open "arm" beat: flips a moment after open so the banner and
/// the "changed while you were away" borders reveal together.
@State private var isArmed = false
@@ -73,8 +78,9 @@ struct PuzzleView: View {
/// player swipes the keyboard away) commits the buffer.
@FocusState private var isRebusFieldFocused: Bool
@Environment(\.engagementStatus) private var engagementStatus
+ @Environment(\.scenePhase) private var scenePhase
- private enum PadLayout {
+ private enum PadLayout: Equatable {
case landscape
case portrait
}
@@ -214,8 +220,18 @@ struct PuzzleView: View {
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newSize in
+ if UIApplication.shared.applicationState == .active {
+ lastActiveSize = newSize
+ }
updateLayoutTrait(for: newSize)
}
+ .onChange(of: scenePhase) { _, newPhase in
+ // On return to the foreground, re-derive the layout from the last
+ // size seen while active — never from a backgrounded snapshot pass.
+ if newPhase == .active {
+ updateLayoutTrait(for: lastActiveSize, force: true)
+ }
+ }
.onAppear {
session.onRecentChangesAcknowledged = markPuzzleViewed
}
@@ -307,12 +323,27 @@ struct PuzzleView: View {
}
}
- private func updateLayoutTrait(for size: CGSize) {
- guard UIDevice.current.userInterfaceIdiom == .pad, size != .zero else {
- padLayout = nil
+ private func updateLayoutTrait(for size: CGSize, force: Bool = false) {
+ // Ignore geometry reported while the app is backgrounded: the system
+ // lays the scene out at both orientations to render app-switcher
+ // snapshots, and reacting to those would flip `padLayout` and rebuild
+ // the puzzle subtree — losing the Clue List's scroll position. A forced
+ // re-apply on return to active handles a genuine rotation made while
+ // away.
+ guard force || UIApplication.shared.applicationState == .active else {
return
}
- padLayout = size.width > size.height ? .landscape : .portrait
+ let newLayout: PadLayout?
+ if UIDevice.current.userInterfaceIdiom == .pad, size != .zero {
+ newLayout = size.width > size.height ? .landscape : .portrait
+ } else {
+ newLayout = nil
+ }
+ // Dedupe so re-applying the same value on foreground doesn't needlessly
+ // invalidate the layout.
+ if newLayout != padLayout {
+ padLayout = newLayout
+ }
}
private func performResign() {
diff --git a/Tests/Unit/CellAccessibilityDescriberTests.swift b/Tests/Unit/CellAccessibilityDescriberTests.swift
@@ -0,0 +1,289 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("CellAccessibilityDescriber")
+struct CellAccessibilityDescriberTests {
+
+ /// 3x3 grid with a centre block:
+ /// A B C
+ /// D # E
+ /// F G H
+ /// Words: 1 Across ABC, 3 Across FGH, 1 Down ADF, 2 Down CEH.
+ /// D (1,0) and E (1,2) each belong to exactly one word.
+ private func makePuzzle() throws -> Puzzle {
+ Puzzle(xd: try XD.parse("""
+ Title: Describer Fixture
+
+
+ ABC
+ D#E
+ FGH
+
+
+ A1. Top row ~ ABC
+ A3. Bottom row ~ FGH
+ D1. Left column ~ ADF
+ D2. Right column ~ CEH
+ """))
+ }
+
+ private func describer(
+ authorNames: [String: String] = [:]
+ ) throws -> CellAccessibilityDescriber {
+ CellAccessibilityDescriber(puzzle: try makePuzzle(), authorNames: authorNames)
+ }
+
+ // MARK: - Labels
+
+ @Test("A cell in crossing words reads both memberships, across first")
+ func labelForCrossingCell() throws {
+ let label = try describer().label(atRow: 0, atCol: 0)
+ #expect(label == "1 Across, letter 1 of 3; 1 Down, letter 1 of 3")
+ }
+
+ @Test("A cell in a single word reads only that membership")
+ func labelForDownOnlyCell() throws {
+ // D at (1,0): its across run is length 1 (blocked to the right), so
+ // only the down membership is spoken.
+ let label = try describer().label(atRow: 1, atCol: 0)
+ #expect(label == "1 Down, letter 2 of 3")
+ }
+
+ @Test("Letter position counts from the word start, not the grid edge")
+ func labelCountsWithinWord() throws {
+ let label = try describer().label(atRow: 2, atCol: 1)
+ #expect(label == "3 Across, letter 2 of 3")
+ }
+
+ // MARK: - Values
+
+ @Test("An unfilled square reads as Empty")
+ func valueForEmptySquare() throws {
+ #expect(try describer().value(for: Square()) == "Empty")
+ }
+
+ @Test("A plain pen entry reads just the letter")
+ func valueForPenEntry() throws {
+ var square = Square()
+ square.entry = "R"
+ square.mark = .pen(checked: nil)
+ #expect(try describer().value(for: square) == "R")
+ }
+
+ @Test("A pencil entry reads as draft, matching the keyboard's term")
+ func valueForPencilEntry() throws {
+ var square = Square()
+ square.entry = "R"
+ square.mark = .pencil(checked: nil)
+ #expect(try describer().value(for: square) == "R, draft")
+ }
+
+ @Test("Revealed and checked-wrong marks are spoken")
+ func valueForMarkedSquares() throws {
+ var revealed = Square()
+ revealed.entry = "R"
+ revealed.mark = .revealed
+ #expect(try describer().value(for: revealed) == "R, revealed")
+
+ var wrong = Square()
+ wrong.entry = "R"
+ wrong.mark = .pen(checked: .wrong)
+ #expect(try describer().value(for: wrong) == "R, incorrect")
+ }
+
+ @Test("Author attribution is spoken when names are provided")
+ func valueWithAuthorAttribution() throws {
+ var square = Square()
+ square.entry = "R"
+ square.letterAuthorID = "alice-id"
+
+ let named = try describer(authorNames: ["alice-id": "Alice"])
+ #expect(named.value(for: square) == "R, filled by Alice")
+
+ // Solo games / hidden annotations pass no names: no attribution.
+ #expect(try describer().value(for: square) == "R")
+ }
+
+ // MARK: - Clue announcements
+
+ private func emptySquares() -> [[Square]] {
+ Array(repeating: Array(repeating: Square(), count: 3), count: 3)
+ }
+
+ @Test("An untouched word announces clue and length, omitting a zero fill count")
+ func clueAnnouncementForEmptyWord() throws {
+ let text = try describer().clueAnnouncement(
+ atRow: 0, atCol: 0, direction: .across, squares: emptySquares()
+ )
+ #expect(text == "1 Across: Top row. 3 letters")
+ }
+
+ @Test("A partially filled word announces its fill count")
+ func clueAnnouncementForPartialWord() throws {
+ var squares = emptySquares()
+ squares[0][1].entry = "B"
+ let text = try describer().clueAnnouncement(
+ atRow: 0, atCol: 0, direction: .across, squares: squares
+ )
+ #expect(text == "1 Across: Top row. 3 letters, 1 filled")
+ }
+
+ @Test("A complete word announces all filled")
+ func clueAnnouncementForCompleteWord() throws {
+ var squares = emptySquares()
+ squares[0][0].entry = "A"
+ squares[0][1].entry = "B"
+ squares[0][2].entry = "C"
+ let text = try describer().clueAnnouncement(
+ atRow: 0, atCol: 0, direction: .across, squares: squares
+ )
+ #expect(text == "1 Across: Top row. 3 letters, all filled")
+ }
+
+ @Test("Announcements resolve from any cell in the word, in either direction")
+ func clueAnnouncementFromMidWord() throws {
+ let text = try describer().clueAnnouncement(
+ atRow: 1, atCol: 0, direction: .down, squares: emptySquares()
+ )
+ #expect(text == "1 Down: Left column. 3 letters")
+ }
+
+ @Test("A cell with no word in the direction announces nothing")
+ func clueAnnouncementForWordlessDirection() throws {
+ // D at (1,0) has no across word (blocked to the right).
+ let text = try describer().clueAnnouncement(
+ atRow: 1, atCol: 0, direction: .across, squares: emptySquares()
+ )
+ #expect(text == nil)
+ }
+
+ @Test("Clue labels pair number, direction, and text")
+ func clueLabelWording() throws {
+ #expect(try describer().clueLabel(number: 2, direction: .down) == "2 Down: Right column")
+ #expect(try describer().clueLabel(number: 2, direction: .across) == nil)
+ }
+
+ // MARK: - Peer fill announcements
+
+ @Test("A single peer fill names its word")
+ func peerFillSingleSquare() throws {
+ var squares = emptySquares()
+ squares[0][1].entry = "B"
+ let text = try describer().peerFillAnnouncement(
+ playerName: "Alice",
+ positions: [GridPosition(row: 0, col: 1)],
+ squares: squares
+ )
+ #expect(text == "Alice filled a square in 1 Across")
+ }
+
+ @Test("A burst inside one open word counts its squares")
+ func peerFillBurstInWord() throws {
+ var squares = emptySquares()
+ squares[0][0].entry = "A"
+ squares[0][1].entry = "B"
+ let text = try describer().peerFillAnnouncement(
+ playerName: "Alice",
+ positions: [GridPosition(row: 0, col: 0), GridPosition(row: 0, col: 1)],
+ squares: squares
+ )
+ #expect(text == "Alice filled 2 squares in 1 Across")
+ }
+
+ @Test("A burst that completes a word announces the word itself")
+ func peerFillCompletesWord() throws {
+ var squares = emptySquares()
+ squares[0][0].entry = "A"
+ squares[0][1].entry = "B"
+ squares[0][2].entry = "C"
+ let text = try describer().peerFillAnnouncement(
+ playerName: "Bob",
+ positions: [GridPosition(row: 0, col: 0), GridPosition(row: 0, col: 1),
+ GridPosition(row: 0, col: 2)],
+ squares: squares
+ )
+ #expect(text == "Bob filled 1 Across")
+ }
+
+ @Test("A burst spanning words falls back to a square count")
+ func peerFillAcrossWords() throws {
+ var squares = emptySquares()
+ squares[0][0].entry = "A"
+ squares[2][2].entry = "H"
+ let text = try describer().peerFillAnnouncement(
+ playerName: "Carol",
+ positions: [GridPosition(row: 0, col: 0), GridPosition(row: 2, col: 2)],
+ squares: squares
+ )
+ #expect(text == "Carol filled 2 squares")
+ }
+
+ // MARK: - Check / reveal / completion announcements
+
+ @Test("Single-square checks read a bare verdict")
+ func checkSingleSquare() throws {
+ let d = try describer()
+ let cell = try makePuzzle().cells[0][0]
+
+ var squares = emptySquares()
+ #expect(d.checkAnnouncement(for: [cell], squares: squares) == "Nothing to check")
+
+ squares[0][0].entry = "A"
+ squares[0][0].mark = .pen(checked: .right)
+ #expect(d.checkAnnouncement(for: [cell], squares: squares) == "Correct")
+
+ squares[0][0].mark = .pen(checked: .wrong)
+ #expect(d.checkAnnouncement(for: [cell], squares: squares) == "Incorrect")
+ }
+
+ @Test("Word checks count mistakes and unfilled squares")
+ func checkWholeWord() throws {
+ let puzzle = try makePuzzle()
+ let d = try describer()
+ let word = puzzle.wordCells(atRow: 0, col: 0, direction: .across)
+
+ var squares = emptySquares()
+ squares[0][0].entry = "A"
+ squares[0][0].mark = .pen(checked: .right)
+ squares[0][1].entry = "X"
+ squares[0][1].mark = .pen(checked: .wrong)
+ #expect(d.checkAnnouncement(for: word, squares: squares) == "1 square incorrect")
+
+ squares[0][1].mark = .pen(checked: .right)
+ #expect(d.checkAnnouncement(for: word, squares: squares) == "No mistakes, 1 square empty")
+
+ squares[0][2].entry = "C"
+ squares[0][2].mark = .pen(checked: .right)
+ #expect(d.checkAnnouncement(for: word, squares: squares) == "All correct")
+ }
+
+ @Test("Reveals speak the revealed letter")
+ func revealSpeaksLetter() throws {
+ var squares = emptySquares()
+ squares[1][2].entry = "E"
+ squares[1][2].mark = .revealed
+ #expect(try describer().revealAnnouncement(atRow: 1, atCol: 2, squares: squares) == "Revealed E")
+ #expect(try describer().revealAnnouncement(atRow: 0, atCol: 0, squares: squares) == nil)
+ }
+
+ @Test("Completion states announce solved and filled-with-errors, never incomplete")
+ func completionAnnouncements() throws {
+ let d = try describer()
+ #expect(d.completionAnnouncement(for: .incomplete) == nil)
+ #expect(d.completionAnnouncement(for: .filledWithErrors) == "The grid is full, but something isn't right")
+ #expect(d.completionAnnouncement(for: .solved) == "Puzzle solved. Congratulations!")
+ }
+
+ @Test("Qualifiers compose in a stable order: letter, mark, author")
+ func valueComposesQualifiers() throws {
+ var square = Square()
+ square.entry = "R"
+ square.mark = .pencil(checked: .wrong)
+ square.letterAuthorID = "bob-id"
+
+ let value = try describer(authorNames: ["bob-id": "Bob"]).value(for: square)
+ #expect(value == "R, draft, incorrect, filled by Bob")
+ }
+}