commit 589feeaea00a62dc8f2ffde711506883bfa1dc41
parent 412e38cdf3a44e290a3e4ab8e510af2cf6cd95f3
Author: Michael Camilleri <[email protected]>
Date: Thu, 25 Jun 2026 01:59:19 +0900
Improve user-facing messages
Crossmate's failure and confirmation copy did not use consistent
terminology and could be unhelpful. For example, when a fetched puzzle
failed to parse, an alert titled 'Couldn't Create Game' presented
XD.ParseError's default localizedDescription. If this occurred during a
random fetch, the user was left with no record of which date had been
chosen, so the failure could be neither understood nor reported.
This commit updates the user-facing messages to consistently use
'puzzle' rather than 'game'. It also fixes the parsing error to use the correct
terminology and log data that can identify the specific problem.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
8 files changed, 81 insertions(+), 19 deletions(-)
diff --git a/Crossmate/Models/TipStore.swift b/Crossmate/Models/TipStore.swift
@@ -54,7 +54,7 @@ enum TipCatalog {
Tip(
id: "get-attention",
title: "Announce Your Availability",
- body: "Nudge your friends from the in-game Players menu in a shared puzzle."
+ body: "Nudge your friends from the Players menu in a shared puzzle."
),
Tip(
id: "be-notified",
@@ -64,7 +64,7 @@ enum TipCatalog {
Tip(
id: "take-a-hint",
title: "Get Unstuck in Difficult Puzzles",
- body: "Use the in-game Hints menu to check your letters or get an answer."
+ body: "Use the Hints menu in a puzzle to check your letters or get an answer."
),
Tip(
id: "import-puzzles",
diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift
@@ -111,6 +111,32 @@ struct XD: Sendable {
return ".xd grid cell at row \(row + 1), column \(col + 1) has no inferred solution"
}
}
+
+ /// A non-technical sentence fragment for the user-facing alert, phrased
+ /// to follow "The puzzle for {date} …". The specifics that matter for a
+ /// bug report — the offending character, the malformed clue line, the
+ /// grid coordinates — stay out of this and ride in `description` to the
+ /// diagnostic log instead.
+ var userFacingReason: String {
+ switch self {
+ case .missingGrid:
+ return "is missing its grid"
+ case .missingClues:
+ return "is missing its clues"
+ case .raggedGrid:
+ return "has a grid Crossmate couldn't read"
+ case .malformedClue:
+ return "has a clue Crossmate couldn't read"
+ case .unknownGridCharacter:
+ return "contains a character Crossmate doesn't support"
+ case .clueAnswerMismatch:
+ return "has an answer that doesn't match its grid"
+ case .ambiguousClueAnswer:
+ return "has an answer Crossmate couldn't place in its grid"
+ case .missingInferredSolution:
+ return "has a square Crossmate couldn't solve"
+ }
+ }
}
static func parse(_ source: String) throws -> XD {
@@ -218,6 +244,15 @@ struct XD: Sendable {
}
}
+ /// Reads a single metadata header value (e.g. `Date`, `Title`) from raw
+ /// `.xd` source without a full parse. Lets a caller name the puzzle that
+ /// failed to parse — the metadata section is independent of the grid/clue
+ /// sections that the parser rejects.
+ static func metadataValue(_ key: String, in source: String) -> String? {
+ guard let header = splitIntoSections(source).first else { return nil }
+ return parseMetadata(header).first(key)
+ }
+
private static func parseMetadata(_ lines: [String]) -> Metadata {
var metadata = Metadata()
for line in lines {
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -61,13 +61,13 @@ final class ShareController {
var errorDescription: String? {
switch self {
case .gameNotFound:
- "Game not found."
+ "Puzzle not found."
case .invalidShareRecord:
"Invalid share record."
case .notAnOwner:
- "Only the puzzle owner can share this game."
+ "Only the owner can share this puzzle."
case .invalidGameRecord:
- "Invalid game record."
+ "Invalid puzzle record."
case .missingShareURL:
"CloudKit did not return a share URL."
case .collaborationLimitReached(let maxPeople):
diff --git a/Crossmate/Views/Browse/NewGameSheet.swift b/Crossmate/Views/Browse/NewGameSheet.swift
@@ -6,11 +6,18 @@ struct NewGameSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(NYTAuthService.self) private var nytAuth
+ @Environment(EventLog.self) private var eventLog
@AppStorage("lastPuzzleSource") private var storedSourceRaw = PuzzleSource.bundles.rawValue
@AppStorage("debugMode") private var debugMode = false
@State private var selection: PuzzleSource = .bundles
@State private var duplicateSource: String?
- @State private var createError: String?
+ @State private var createError: CreateError?
+
+ private struct CreateError: Identifiable {
+ let id = UUID()
+ let title: String
+ let message: String
+ }
private var availableSources: [PuzzleSource] {
var sources: [PuzzleSource] = [.bundles]
@@ -86,10 +93,10 @@ struct NewGameSheet: View {
}
Button("Cancel", role: .cancel) {}
} message: { _ in
- Text("You already have a game for this puzzle. Cancel and resume it from the library, or create a new copy.")
+ Text("You already have a copy of this puzzle in your library. Do you want to create a new copy?")
}
.alert(
- "Couldn't Create Game",
+ createError?.title ?? "Couldn't Create Puzzle",
isPresented: .init(
get: { createError != nil },
set: { if !$0 { createError = nil } }
@@ -97,8 +104,8 @@ struct NewGameSheet: View {
presenting: createError
) { _ in
Button("OK", role: .cancel) {}
- } message: { message in
- Text(message)
+ } message: { error in
+ Text(error.message)
}
}
@@ -115,8 +122,28 @@ struct NewGameSheet: View {
let gameID = try store.createGame(from: source)
dismiss()
onCreated(gameID)
+ } catch let parseError as XD.ParseError {
+ // Keep the alert plain-language and date-stamped; the specifics that
+ // pin down the converter bug — which character, which clue — go to
+ // the diagnostic log, which is what a tester actually shares back.
+ let lead = XD.metadataValue("Date", in: source).map { "The puzzle for \($0)" } ?? "This puzzle"
+ createError = CreateError(
+ title: "Could Not Parse Puzzle",
+ message: "\(lead) \(parseError.userFacingReason)."
+ )
+ eventLog.note(
+ "new puzzle parse failed [\(Self.puzzleDescriptor(from: source))]: \(parseError.description)",
+ level: "error"
+ )
} catch {
- createError = error.localizedDescription
+ createError = CreateError(title: "Couldn't Create Puzzle", message: error.localizedDescription)
}
}
+
+ /// A compact one-line identification of the puzzle for the diagnostic log,
+ /// built from whatever metadata headers survived in the raw source.
+ private static func puzzleDescriptor(from source: String) -> String {
+ let parts = ["Publisher", "Title", "Date"].compactMap { XD.metadataValue($0, in: source) }
+ return parts.isEmpty ? "unknown puzzle" : parts.joined(separator: " · ")
+ }
}
diff --git a/Crossmate/Views/Friends/FriendsView.swift b/Crossmate/Views/Friends/FriendsView.swift
@@ -75,7 +75,7 @@ struct FriendsView: View {
Button("Cancel", role: .cancel) {}
} message: {
let name = blockTarget?.resolvedDisplayName ?? "this player"
- Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.")
+ Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be removed from this device.")
}
}
}
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -219,7 +219,7 @@ struct GameListView: View {
Button("Cancel", role: .cancel) {}
} message: {
let name = blockTarget?.resolvedInviterName ?? "this player"
- Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.")
+ Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be removed from this device.")
}
.alert("Set Profile Name", isPresented: $showingNamePrompt) {
TextField("Name", text: $nameDraft)
diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift
@@ -244,18 +244,18 @@ struct PuzzleToolbarModifier: ViewModifier {
private var puzzleDestructiveSection: some View {
Section {
- Button("Resign Game", role: .destructive) {
+ Button("Resign Puzzle", role: .destructive) {
isConfirmingResign = true
}
.disabled(isSolved || !canResign)
if session.mutator.isShared && !session.mutator.isOwned {
- Button("Leave Game", role: .destructive) {
+ Button("Leave Puzzle", role: .destructive) {
isConfirmingLeave = true
}
.disabled(shareController == nil)
} else {
- Button("Delete Game", role: .destructive) {
+ Button("Delete Puzzle", role: .destructive) {
isConfirmingDelete = true
}
.disabled(!canDelete)
diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift
@@ -93,15 +93,15 @@ struct SettingsView: View {
showResetConfirmation = true
}
.alert(
- "Delete all games?",
+ "Delete all puzzles?",
isPresented: $showResetConfirmation
) {
- Button("Delete All Games", role: .destructive) {
+ Button("Delete All Puzzles", role: .destructive) {
Task { await resetDatabase?() }
}
Button("Cancel", role: .cancel) {}
} message: {
- Text("This removes every game and clears the sync state on this device. Games on other devices and in iCloud are not affected.")
+ Text("This removes every puzzle and clears the sync state on this device. Puzzles on other devices and in iCloud are not affected.")
}
}
}