commit 0d044c222ebf0c61ccad7b4d1a9bb92228db9609
parent ec59ffe353eeee1ed7f51fe49e594b30797dd41f
Author: Michael Camilleri <[email protected]>
Date: Sun, 7 Jun 2026 14:53:47 +0900
Restyle announcement banners
This commit replaces the severity-tinted announcement box with a more
compact banner treatment. Announcements now use a neutral material
background, a narrow severity rail, a smaller leading symbol,
left-aligned text, and a close affordance only for manual-dismiss
announcements. Puzzle headers keep the banner aligned to the bottom of
the fixed header area so transient updates read in context without
stretching to fill the whole header.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
6 files changed, 70 insertions(+), 56 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -39,7 +39,7 @@ struct CrossmateApp: App {
id: "reset-database-error",
scope: .global,
severity: .error,
- title: "Couldn't Reset Database",
+ title: "Resetting Failed",
body: error.localizedDescription,
dismissal: .manual
))
@@ -483,7 +483,7 @@ private struct PuzzleDisplayView: View {
id: "mark-completed-error-\(gameID.uuidString)",
scope: .game(gameID),
severity: .error,
- title: "Couldn't Save Completion",
+ title: "Saving Failed",
body: error.localizedDescription,
dismissal: .manual
))
diff --git a/Crossmate/Services/AnnouncementCenter.swift b/Crossmate/Services/AnnouncementCenter.swift
@@ -3,7 +3,7 @@ import Observation
/// One-shot status message surfaced in a banner area — the puzzle header
/// when scoped to a game, the game list when global. Designed to host both
-/// info-class summaries (e.g. "Alice added 4 letters while you were away")
+/// info-class summaries (e.g. "Alice added 4 letters")
/// and error-class failures (e.g. "Couldn't accept invite") that previously
/// went through modal alerts.
struct Announcement: Identifiable, Equatable, Sendable {
@@ -84,6 +84,7 @@ extension Announcement {
id: "access-revoked-\(gameID.uuidString)",
scope: .game(gameID),
severity: .error,
+ title: "Puzzle Not Shared",
body: "This puzzle is no longer shared with you.",
dismissal: .sticky,
blocksInput: true
@@ -102,6 +103,7 @@ extension Announcement {
id: "game-removed-\(gameID.uuidString)",
scope: .game(gameID),
severity: .error,
+ title: "Puzzle Removed",
body: "This puzzle was removed.",
dismissal: .sticky,
blocksInput: true
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -580,7 +580,7 @@ final class AppServices {
id: "remove-pending-invite-error-\(gameID.uuidString)",
scope: .global,
severity: .error,
- title: "Couldn't Clear Invite",
+ title: "Clearing Failed",
body: error.localizedDescription,
dismissal: .manual
))
@@ -2580,7 +2580,7 @@ final class AppServices {
id: "block-friend-error-\(authorID)",
scope: .global,
severity: .error,
- title: "Couldn't Block Collaborator",
+ title: "Blocking Failed",
body: error.localizedDescription,
dismissal: .manual
))
@@ -2820,6 +2820,7 @@ final class AppServices {
id: "session-summary-\(gameID.uuidString)",
scope: .game(gameID),
severity: .info,
+ title: "Puzzle Updated",
body: body,
dismissal: .transient(after: 6)
))
diff --git a/Crossmate/Views/AnnouncementBanner.swift b/Crossmate/Views/AnnouncementBanner.swift
@@ -1,39 +1,64 @@
import SwiftUI
-/// Banner-style surface for an `Announcement`. Renders a severity icon and
-/// body text over a severity-tinted background, taps to dismiss when
-/// `.manual`, and is intended to live in the puzzle header (and eventually
-/// the game list) behind a `.transition(.move(edge: .top))` driven by the
-/// parent.
+/// Banner-style surface for an `Announcement`. Renders a severity rail, icon,
+/// and body text over a neutral material background. Manual announcements get
+/// a close affordance; transient and sticky announcements do not.
struct AnnouncementBanner: View {
let announcement: Announcement
let onDismiss: (() -> Void)?
var body: some View {
- let tint = backgroundTint(for: announcement.severity)
- HStack(spacing: 10) {
- Image(systemName: iconName(for: announcement.severity))
- // `.title` (~28pt) against the `.subheadline` (~15pt) body
- // text — roughly 1.9× — so the severity reads at a glance.
- .font(.title.weight(.semibold))
- .foregroundStyle(iconTint(for: announcement.severity))
+ let severityColor = tint(for: announcement.severity)
+ HStack(spacing: 0) {
+ Rectangle()
+ .fill(severityColor)
+ .frame(width: 4)
.accessibilityHidden(true)
- VStack(spacing: 2) {
- if let title = announcement.title, !title.isEmpty {
- Text(title)
- .font(.subheadline.weight(.semibold))
- .lineLimit(1)
+
+ HStack(alignment: .center, spacing: 8) {
+ Image(systemName: iconName(for: announcement.severity))
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(severityColor)
+ .frame(width: 24)
+ .accessibilityHidden(true)
+
+ VStack(alignment: .leading, spacing: 3) {
+ if let title = announcement.title, !title.isEmpty {
+ Text(title)
+ .font(.footnote.weight(.semibold))
+ .lineLimit(1)
+ }
+ Text(announcement.body)
+ .font(.footnote)
+ .foregroundStyle(.primary)
+ .multilineTextAlignment(.leading)
+ .lineLimit(2)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if case .manual = announcement.dismissal {
+ Button {
+ onDismiss?()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.callout)
+ .symbolRenderingMode(.hierarchical)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel("Dismiss announcement")
}
- Text(announcement.body)
- .font(.subheadline)
- .multilineTextAlignment(.center)
- .lineLimit(3)
}
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
}
- .frame(maxWidth: .infinity, alignment: .center)
- .padding(.horizontal, 14)
- .padding(.vertical, 10)
- .background(tint, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.contentShape(Rectangle())
.onTapGesture {
// `.transient` self-dismisses; `.sticky` requires programmatic
@@ -44,34 +69,20 @@ struct AnnouncementBanner: View {
.accessibilityElement(children: .combine)
}
- private func backgroundTint(for severity: Announcement.Severity) -> Color {
- switch severity {
- case .info: return Color.blue.opacity(0.18)
- case .warning: return Color.orange.opacity(0.18)
- case .error: return Color.red.opacity(0.18)
- }
- }
-
- /// SF Symbol that leads the banner text, one per severity — an attention
- /// cue that reads at a glance even before the text does.
+ /// SF Symbol that leads the banner text, one per severity.
private func iconName(for severity: Announcement.Severity) -> String {
switch severity {
- case .info: return "bell.circle"
+ case .info: return "bell"
case .warning: return "exclamationmark.triangle"
case .error: return "xmark.octagon"
}
}
- /// Foreground colour for the severity icon. Mixed toward `.primary` so the
- /// icon reads as the "ink" version of `backgroundTint` — darker than the
- /// background hue in light mode, lighter in dark mode — giving real tonal
- /// contrast instead of saturated-on-tinted same-hue mush.
- private func iconTint(for severity: Announcement.Severity) -> Color {
- let base: Color = switch severity {
- case .info: .blue
- case .warning: .orange
- case .error: .red
+ private func tint(for severity: Announcement.Severity) -> Color {
+ switch severity {
+ case .info: return .blue
+ case .warning: return .orange
+ case .error: return .red
}
- return base.mix(with: .primary, by: 0.4)
}
}
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -112,7 +112,7 @@ struct GameListView: View {
id: Self.destructiveActionErrorID,
scope: .global,
severity: .error,
- title: "Couldn't Resign Puzzle",
+ title: "Resigning Failed",
body: error.localizedDescription,
dismissal: .manual
))
@@ -153,7 +153,7 @@ struct GameListView: View {
id: Self.destructiveActionErrorID,
scope: .global,
severity: .error,
- title: "Couldn't Delete Puzzle",
+ title: "Deleting Failed",
body: error.localizedDescription,
dismissal: .manual
))
@@ -528,7 +528,7 @@ struct GameListView: View {
id: Self.inviteErrorID,
scope: .global,
severity: .error,
- title: "Couldn't Accept Invite",
+ title: "Accepting Failed",
body: error.localizedDescription,
dismissal: .manual
))
@@ -552,7 +552,7 @@ struct GameListView: View {
id: Self.destructiveActionErrorID,
scope: .global,
severity: .error,
- title: "Couldn't Decline Invite",
+ title: "Declining Failed",
body: error.localizedDescription,
dismissal: .manual
))
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -1241,7 +1241,7 @@ private struct PuzzleHeader: View {
announcements.dismiss(id: announcement.id)
}
.padding(.horizontal, 12)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
headerPages