crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit d135a009cb51a784b6fb9ffbbd94b968da463184
parent 4af80a380caa6af6783ef301d2529898ed340cdf
Author: Michael Camilleri <[email protected]>
Date:   Tue,  7 Jul 2026 21:45:50 +0900

Improve VoiceOver semantics for puzzle selection and solving

This commit makes the main puzzle flows less dependent on visual
grouping. The Game List now gives VoiceOver an explicit 'List of
Puzzles' starting point, treats puzzle rows and invitation chits as
single actionable elements, and exposes their secondary actions through
custom accessibility actions instead of requiring the user to discover
the visual overflow controls.

The New Puzzle and friends surfaces now describe their custom rows in
the same style: bundled, imported, and NYT puzzle choices announce the
useful puzzle metadata and state, NYT random selection is split into
coherent day, year, and fetch controls, and friend rows expose invite,
rename, and block actions from one element.

The puzzle screen adds a short VoiceOver-only opening announcement with
the title, grid dimensions, and compact shared-player context, then
leaves the focused square and clue machinery to provide the actionable
solving context. Rebus entry now has an explicit text-field label and
value, the Draft toggle uses the same term as draft cell values, and the
custom crossword keyboard is marked as a Direct Touch region with a hint
that language switching and dictation controls are not shown.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Views/Browse/BundledBrowseView.swift | 13+++++++++++++
MCrossmate/Views/Browse/CalendarDayCell.swift | 17+++++++++++++++++
MCrossmate/Views/Browse/ImportedBrowseView.swift | 5+++++
MCrossmate/Views/Browse/NYTBrowseView.swift | 23+++++++++++++++++++----
MCrossmate/Views/Friends/FriendsView.swift | 23+++++++++++++++++++++++
MCrossmate/Views/GameList/GameCardView.swift | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameList/GameListView.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MCrossmate/Views/GameList/GameRowView.swift | 8++++++++
MCrossmate/Views/Puzzle/KeyboardView.swift | 3+++
MCrossmate/Views/Puzzle/PuzzleModifiers.swift | 2+-
MCrossmate/Views/Puzzle/PuzzleView.swift | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 358 insertions(+), 9 deletions(-)

diff --git a/Crossmate/Views/Browse/BundledBrowseView.swift b/Crossmate/Views/Browse/BundledBrowseView.swift @@ -21,6 +21,7 @@ struct BundledBrowseView: View { .foregroundStyle(.secondary) } } + .accessibilityLabel("\(bundle.name), \(bundle.puzzles.count) \(bundle.puzzles.count == 1 ? "puzzle" : "puzzles")") } } } @@ -67,6 +68,18 @@ private struct PuzzleListView: View { } } .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel(for: entry)) + .accessibilityHint("Creates this puzzle") } } + + private func accessibilityLabel(for entry: PuzzleCatalog.Entry) -> String { + var parts = [entry.title] + if let publisher = entry.publisher, !publisher.isEmpty { + parts.append(publisher) + } + parts.append("\(entry.gridWidth) by \(entry.gridHeight)") + return parts.joined(separator: ", ") + } } diff --git a/Crossmate/Views/Browse/CalendarDayCell.swift b/Crossmate/Views/Browse/CalendarDayCell.swift @@ -1,6 +1,7 @@ import SwiftUI struct CalendarDayCell: View { + let date: Date let dayNumber: Int let isEnabled: Bool let isToday: Bool @@ -28,10 +29,26 @@ struct CalendarDayCell: View { } .buttonStyle(.plain) .disabled(!isEnabled) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue) + .accessibilityHint(isEnabled ? "Selects this puzzle date" : "") + .accessibilityAddTraits(isSelected ? .isSelected : []) } private var foregroundColor: Color { if isSelected { return .white } return isEnabled ? .primary : .secondary.opacity(0.4) } + + private var accessibilityLabel: String { + date.formatted(.dateTime.weekday(.wide).month(.wide).day().year()) + } + + private var accessibilityValue: String { + var parts: [String] = [] + if isToday { parts.append("Today") } + if isSelected { parts.append("Selected") } + if !isEnabled { parts.append("Unavailable") } + return parts.joined(separator: ", ") + } } diff --git a/Crossmate/Views/Browse/ImportedBrowseView.swift b/Crossmate/Views/Browse/ImportedBrowseView.swift @@ -99,6 +99,7 @@ private struct DriveItemRow: View { } label: { Label(item.name, systemImage: "folder") } + .accessibilityLabel("\(item.name), folder") } else { Button { onOpen(item) @@ -113,6 +114,10 @@ private struct DriveItemRow: View { } } } + .accessibilityElement(children: .ignore) + .accessibilityLabel(item.url.lastPathComponent) + .accessibilityValue(item.isDownloaded ? "Downloaded" : "Downloading from iCloud") + .accessibilityHint(item.isDownloaded ? "Creates this puzzle" : "Try again after download finishes") } } } diff --git a/Crossmate/Views/Browse/NYTBrowseView.swift b/Crossmate/Views/Browse/NYTBrowseView.swift @@ -189,6 +189,7 @@ struct NYTBrowseView: View { .font(.title3) } .disabled(!canGoBack) + .accessibilityLabel("Previous Month") Spacer() @@ -204,6 +205,8 @@ struct NYTBrowseView: View { .font(.headline) } .buttonStyle(.plain) + .accessibilityLabel("Choose Month") + .accessibilityValue(monthTitle) .popover(isPresented: $showingMonthPicker) { dateWheelPopover .presentationCompactAdaptation(.popover) @@ -218,6 +221,7 @@ struct NYTBrowseView: View { .font(.title3) } .disabled(!canGoForward) + .accessibilityLabel("Next Month") } } @@ -239,6 +243,7 @@ struct NYTBrowseView: View { let cal = Self.nytCalendar let dayNumber = cal.component(.day, from: date) CalendarDayCell( + date: date, dayNumber: dayNumber, isEnabled: isEnabled(date), isToday: cal.isDateInToday(date), @@ -283,8 +288,10 @@ struct NYTBrowseView: View { HStack(spacing: 12) { HStack(spacing: 4) { Text("Random") + .accessibilityHidden(true) weekdayMenu Text("in") + .accessibilityHidden(true) yearMenu } .font(.headline) @@ -309,13 +316,15 @@ struct NYTBrowseView: View { .contentShape(.circle) } .buttonStyle(.plain) - .accessibilityLabel(randomButtonAccessibilityLabel) + .accessibilityLabel("Fetch Random Puzzle") + .accessibilityValue(randomSelectionAccessibilityValue) + .accessibilityHint("Creates a random puzzle using the selected day and year") } } - private var randomButtonAccessibilityLabel: String { - let scope = randomWeekday.map { "\(Self.weekdayName($0)) " } ?? "" - return "Fetch a random \(scope)puzzle from \(randomYear)" + private var randomSelectionAccessibilityValue: String { + let day = randomWeekday.map(Self.weekdayName) ?? "Any Day" + return "\(day), \(randomYear)" } private static let randomButtonTint = Color.accentColor.opacity(0.15) @@ -334,6 +343,9 @@ struct NYTBrowseView: View { reservingWidthFor: Self.weekdayMenuLabelOptions ) } + .accessibilityLabel("Random Day") + .accessibilityValue(randomWeekday.map(Self.weekdayName) ?? "Any Day") + .accessibilityHint("Chooses the weekday for random puzzles") } private var yearMenu: some View { @@ -344,6 +356,9 @@ struct NYTBrowseView: View { } label: { menuLabel(String(randomYear)) } + .accessibilityLabel("Random Year") + .accessibilityValue(String(randomYear)) + .accessibilityHint("Chooses the year for random puzzles") } private static var weekdayMenuLabelOptions: [String] { diff --git a/Crossmate/Views/Friends/FriendsView.swift b/Crossmate/Views/Friends/FriendsView.swift @@ -143,5 +143,28 @@ struct FriendsView: View { .swipeActions(edge: .trailing) { Button("Block", role: .destructive) { blockTarget = friend } } + .accessibilityElement(children: .ignore) + .accessibilityLabel(displayName) + .accessibilityHint("Starts a new puzzle with this crossmate") + .accessibilityAddTraits(.isButton) + .accessibilityAction { + guard !authorID.isEmpty else { return } + onStartGame(FriendNewGameTarget(authorID: authorID, displayName: displayName)) + dismiss() + } + .accessibilityActions { + Button("Invite") { + guard !authorID.isEmpty else { return } + onStartGame(FriendNewGameTarget(authorID: authorID, displayName: displayName)) + dismiss() + } + Button("Rename") { + renameText = friend.nickname ?? "" + renameTarget = friend + } + Button("Block", role: .destructive) { + blockTarget = friend + } + } } } diff --git a/Crossmate/Views/GameList/GameCardView.swift b/Crossmate/Views/GameList/GameCardView.swift @@ -72,6 +72,14 @@ struct GameCardView: View { ) .padding(.trailing, 12) } + .gameSummaryAccessibility( + game: game, + onInvite: { isShowingShareSheet = true }, + onOpen: onResume, + onLeave: onLeave, + onResign: onResign, + onDelete: onDelete + ) .sheet(isPresented: $isShowingShareSheet) { GameShareSheet( gameID: game.id, @@ -82,6 +90,124 @@ struct GameCardView: View { } } +private struct GameSummaryAccessibility: ViewModifier { + let game: GameSummary + let onInvite: () -> Void + let onOpen: () -> Void + let onLeave: () -> Void + let onResign: () -> Void + let onDelete: () -> Void + + private var isCompleted: Bool { + game.completedAt != nil + } + + private var shouldLeaveShare: Bool { + !isCompleted && !game.isOwned && game.isShared + } + + func body(content: Content) -> some View { + content + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(isCompleted ? "Opens completed puzzle" : "Opens puzzle") + .accessibilityAddTraits(.isButton) + .accessibilityAction { + onOpen() + } + .accessibilityActions { + if !isCompleted, game.isOwned { + Button("Invite", action: onInvite) + } + Button(isCompleted ? "View" : "Resume", action: onOpen) + if !isCompleted { + Button("Resign", role: .destructive, action: onResign) + } + if shouldLeaveShare { + Button("Leave", role: .destructive, action: onLeave) + } else { + Button("Delete", role: .destructive, action: onDelete) + } + } + } + + private var accessibilityLabel: String { + var parts: [String] = [game.title] + if game.isAccessRevoked { + parts.append("access revoked") + } else if isCompleted { + parts.append("completed") + } else { + parts.append("in progress") + } + if game.hasUnreadOtherMoves { + parts.append("unseen changes") + } + if let sharedSummary { + parts.append(sharedSummary) + } + if let publisher = game.publisher, !publisher.isEmpty { + parts.append(publisher) + } + if let puzzleDate = game.puzzleDate { + parts.append(puzzleDate.formatted(.dateTime.day().month(.wide).year())) + } + if let updatedAt = game.updatedAt { + parts.append(lastUpdatedText(from: updatedAt)) + } + return parts.joined(separator: ", ") + } + + private var sharedSummary: String? { + guard game.isShared else { return nil } + let remoteParticipants = game.allParticipants.filter { !$0.isLocal } + if remoteParticipants.isEmpty { + return "shared puzzle" + } + if remoteParticipants.count == 1, let participant = remoteParticipants.first { + return "shared with \(participant.name)" + } + return "shared with \(remoteParticipants.count) players" + } + + private func lastUpdatedText(from date: Date) -> String { + let elapsed = max(0, Date.now.timeIntervalSince(date)) + if elapsed < 60 { + let seconds = max(1, Int(elapsed.rounded(.down))) + return "last updated \(seconds) \(seconds == 1 ? "second" : "seconds") ago" + } + if elapsed < 60 * 60 { + let minutes = Int((elapsed / 60).rounded(.down)) + return "last updated \(minutes) \(minutes == 1 ? "minute" : "minutes") ago" + } + if elapsed <= 48 * 60 * 60 { + let hours = Int((elapsed / (60 * 60)).rounded(.down)) + return "last updated \(hours) \(hours == 1 ? "hour" : "hours") ago" + } + return "last updated on \(date.formatted(.dateTime.day().month(.wide).year()))" + } +} + +extension View { + func gameSummaryAccessibility( + game: GameSummary, + onInvite: @escaping () -> Void, + onOpen: @escaping () -> Void, + onLeave: @escaping () -> Void, + onResign: @escaping () -> Void, + onDelete: @escaping () -> Void + ) -> some View { + modifier(GameSummaryAccessibility( + game: game, + onInvite: onInvite, + onOpen: onOpen, + onLeave: onLeave, + onResign: onResign, + onDelete: onDelete + )) + } +} + struct GameListThumbnailView: View { let game: GameSummary let showsUnreadBadge: Bool diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -13,6 +13,7 @@ struct GameListView: View { @Binding var navigationPath: NavigationPath @Environment(\.managedObjectContext) private var viewContext + @Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FetchRequest( @@ -71,11 +72,13 @@ struct GameListView: View { @State private var showTipOptOut = false @State private var tipOptOutHideTask: Task<Void, Never>? @State private var containerHeight: CGFloat = 0 + @AccessibilityFocusState private var isPuzzleListHeadingFocused: Bool private static let completedPageSize = 7 var body: some View { VStack(spacing: 0) { + accessibilityHeading if let announcement = announcements.currentGlobal() { AnnouncementBanner(announcement: announcement, contentSize: .prominent) { dismissAnnouncement(announcement) @@ -114,6 +117,7 @@ struct GameListView: View { } label: { Image(systemName: "gearshape") } + .accessibilityLabel("Settings") } ToolbarItem(placement: .topBarTrailing) { Button { @@ -121,6 +125,7 @@ struct GameListView: View { } label: { Image(systemName: "person.2") } + .accessibilityLabel("Crossmates") } if #available(iOS 26.0, *) { ToolbarSpacer(.fixed, placement: .topBarTrailing) @@ -132,6 +137,7 @@ struct GameListView: View { } label: { Image(systemName: "plus") } + .accessibilityLabel("New Puzzle") } } .sheet(isPresented: $showingSettings) { @@ -159,6 +165,7 @@ struct GameListView: View { .task { await onAppear() reconcilePendingInviteNotification() + focusPuzzleListHeading() } #if DEBUG .task { @@ -177,6 +184,11 @@ struct GameListView: View { .onChange(of: pendingInvites.count) { _, _ in reconcilePendingInviteNotification() } + .onChange(of: isVoiceOverEnabled) { _, enabled in + if enabled { + focusPuzzleListHeading() + } + } .onDisappear { onDisappear() } @@ -285,6 +297,20 @@ struct GameListView: View { } } + private var accessibilityHeading: some View { + Text("List of Puzzles") + .accessibilityAddTraits(.isHeader) + .accessibilityFocused($isPuzzleListHeadingFocused) + .frame(width: 1, height: 1) + .opacity(0.01) + .allowsHitTesting(false) + } + + private func focusPuzzleListHeading() { + guard isVoiceOverEnabled else { return } + isPuzzleListHeadingFocused = true + } + /// Dismisses the banner's announcement. For a tip, also records it as /// dismissed so it never returns, and surfaces the "Never show me tips" /// opt-out in its place for a few seconds. @@ -444,7 +470,7 @@ struct GameListView: View { inviteRow(for: invite) } } header: { - Text("Invited") + listSectionHeader("Invited") } } @@ -454,7 +480,7 @@ struct GameListView: View { rowView(for: game, usesRoomierType: usesRoomierType) } } header: { - Text("In Progress") + listSectionHeader("In Progress") } } @@ -464,7 +490,7 @@ struct GameListView: View { rowView(for: game, usesRoomierType: usesRoomierType) } } header: { - Text("Revoked") + listSectionHeader("Revoked") } } @@ -474,7 +500,7 @@ struct GameListView: View { rowView(for: game, usesRoomierType: usesRoomierType) } } header: { - Text("Completed") + listSectionHeader("Completed") } footer: { if hasMore { loadMoreButton @@ -487,6 +513,11 @@ struct GameListView: View { } } + private func listSectionHeader(_ title: String) -> some View { + Text(title) + .accessibilityAddTraits(.isHeader) + } + // MARK: - Grid layout (regular width / iPad) private var gridColumns: [GridItem] { @@ -580,6 +611,7 @@ struct GameListView: View { .padding(.horizontal, 16) .padding(.vertical, 8) .background(Color(.systemGroupedBackground)) + .accessibilityAddTraits(.isHeader) } private var loadMoreButton: some View { @@ -665,6 +697,13 @@ struct GameListView: View { Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) ) + .modifier(InviteAccessibility( + invite: invite, + isAccepting: acceptingInviteID == invite.objectID, + onAccept: { Task { await accept(invite) } }, + onDecline: { Task { await decline(invite) } }, + onBlock: { blockTarget = invite } + )) } private func inviteMenu(for invite: InviteEntity) -> some View { @@ -712,6 +751,49 @@ struct GameListView: View { .tint(.gray) Button("Block", role: .destructive) { blockTarget = invite } } + .modifier(InviteAccessibility( + invite: invite, + isAccepting: acceptingInviteID == invite.objectID, + onAccept: { Task { await accept(invite) } }, + onDecline: { Task { await decline(invite) } }, + onBlock: { blockTarget = invite } + )) + } + + private struct InviteAccessibility: ViewModifier { + let invite: InviteEntity + let isAccepting: Bool + let onAccept: () -> Void + let onDecline: () -> Void + let onBlock: () -> Void + + func body(content: Content) -> some View { + content + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(isAccepting ? "Invitation is being accepted" : "Accepts invitation") + .accessibilityAddTraits(.isButton) + .accessibilityAction { + guard !isAccepting else { return } + onAccept() + } + .accessibilityActions { + if !isAccepting { + Button("Accept", action: onAccept) + Button("Decline", action: onDecline) + Button("Block", role: .destructive, action: onBlock) + } + } + } + + private var accessibilityLabel: String { + let inviter = invite.resolvedInviterName ?? "A player" + let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" + if isAccepting { + return "\(title), invited by \(inviter), accepting" + } + return "\(title), invited by \(inviter)" + } } private func accept(_ invite: InviteEntity) async { diff --git a/Crossmate/Views/GameList/GameRowView.swift b/Crossmate/Views/GameList/GameRowView.swift @@ -51,6 +51,14 @@ struct GameRowView: View { ) } .padding(.vertical, 4) + .gameSummaryAccessibility( + game: game, + onInvite: { isShowingShareSheet = true }, + onOpen: onResume, + onLeave: onLeave, + onResign: onResign, + onDelete: onDelete + ) .sheet(isPresented: $isShowingShareSheet) { GameShareSheet( gameID: game.id, diff --git a/Crossmate/Views/Puzzle/KeyboardView.swift b/Crossmate/Views/Puzzle/KeyboardView.swift @@ -47,6 +47,9 @@ struct KeyboardView: View { .padding(.horizontal, 4) .padding(.top, 12) .padding(.bottom, 8) + .accessibilityLabel("Crossword Keyboard") + .accessibilityHint("Double-tap to type by touching keys directly. Language switching and dictation controls are not shown.") + .accessibilityDirectTouch(options: .requiresActivation) } private var letterRows: some View { diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift @@ -71,7 +71,7 @@ struct PuzzleToolbarModifier: ViewModifier { tint: preferences.color.tint ) } - .accessibilityLabel("Pencil") + .accessibilityLabel(session.isPencilMode ? "Turn Off Draft" : "Turn On Draft") .disabled(isSolved) } diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift @@ -50,6 +50,7 @@ struct PuzzleView: View { @Environment(PlayerPreferences.self) private var preferences @Environment(AnnouncementCenter.self) private var announcements @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled @State private var isRenaming = false @State private var renameDraft = "" @State private var showErrorsAlert = false @@ -73,6 +74,7 @@ struct PuzzleView: View { /// 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 + @State private var announcedIntroGameID: UUID? /// Drives the system keyboard for rebus entry. Bound to `isRebusActive`: /// focusing the rebus field raises the keyboard, and losing focus (e.g. the /// player swipes the keyboard away) commits the buffer. @@ -245,6 +247,7 @@ struct PuzzleView: View { session.onRecentChangesAcknowledged = markPuzzleViewed } .task(id: session.mutator.gameID) { + announcePuzzleIntroIfNeeded() // The shared open beat. A short hold lets the puzzle settle and the // on-open sync land; then we arm the banner and capture — once — // which cells a peer changed while we were away, so both reveal @@ -259,6 +262,48 @@ struct PuzzleView: View { } } + private func announcePuzzleIntroIfNeeded() { + guard isVoiceOverEnabled, + announcedIntroGameID != session.mutator.gameID + else { return } + announcedIntroGameID = session.mutator.gameID + AccessibilityNotification.Announcement(puzzleIntroAnnouncement).post() + } + + private var puzzleIntroAnnouncement: String { + var parts = [ + titleParts.title, + "\(session.puzzle.width) by \(session.puzzle.height) puzzle" + ] + if let shared = sharedPlayersAnnouncement { + parts.append(shared) + } + return parts.joined(separator: ". ") + } + + private var sharedPlayersAnnouncement: String? { + let names = roster.entries + .filter { !$0.isLocal } + .map(\.name) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard !names.isEmpty else { return nil } + return "Shared with \(Self.formattedList(names))" + } + + private static func formattedList(_ items: [String]) -> String { + switch items.count { + case 0: + return "" + case 1: + return items[0] + case 2: + return "\(items[0]) and \(items[1])" + default: + let prefix = items.dropLast().joined(separator: ", ") + return "\(prefix), and \(items[items.count - 1])" + } + } + private var phoneLayout: some View { VStack(spacing: 0) { puzzleArea() @@ -668,6 +713,14 @@ private struct RebusModal: View { let onCommit: () -> Void var body: some View { + styledField + .accessibilityLabel("Rebus entry") + .accessibilityValue(accessibilityValue) + .accessibilityHint("Enter the full text for this square") + .accessibilityAction(.default, onCommit) + } + + private var styledField: some View { // An editable field styled to read as the centred display card: the // system keyboard drives entry (so symbols, accents, and emoji are all // reachable) while the look and placement match the prior read-only modal. @@ -698,6 +751,10 @@ private struct RebusModal: View { .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } + + private var accessibilityValue: String { + text.isEmpty ? "Empty" : text + } } private struct ControlsView<Content: View>: View {