commit 85f8972543255f0855b912c4b2d317c8f2e8ddd5
parent 26703a665ada5331272668651d42560a4e05c3f7
Author: Michael Camilleri <[email protected]>
Date: Tue, 7 Jul 2026 08:55:33 +0900
Render the import marketing screenshot from real app views
The import marketing scene was a hand-built mock of the New Puzzle sheet
and the Game List behind it, so it drifted from the real UI in row
styling, chrome, and layout that had to be kept in sync by hand. This
commit drives the scene from the real machinery instead: it presents the
app's own NewGameSheet, on the Imported tab, over the real seeded Game
List.
DriveMonitor gains a DEBUG seedMarketingImports seam that injects a
fixed set of imported files, so ImportedBrowseView renders its real rows
without a live iCloud container — a fresh screenshot simulator has none,
and the real query would otherwise leave the tab in its empty state.
CrossmateApp routes the import scene to the real root, GameListView
opens its own sheet on the Imported tab when it detects marketing mode,
and the mock views are deleted.
DemoSeed now backs the Game List with real bundled 15×15 starters rather
than a 3×3 toy, so the backdrop shows full grids. It also splits into
two profiles: development keeps a four-player board for eyeballing all
four attribution tints at once, while marketing caps every game at three
players. seedFilledLetters takes an explicit author set so the Game List
colour strip is bounded per game, and the profile is chosen from
`--crossmate-seed-marketing` rather than `--crossmate-seed-demo`.
The screenshot script also corrects the iOS 26 Simulator's Device ▸
Orientation ▸ Landscape Left menu path — the old path silently failed,
rotating every iPad shot 90° — and pins the status bar to 9:41 on the
date of the first commit to master, passed as a full ISO 8601 string so
it sets both the date and the time on devices whose status bar shows a
date.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
6 files changed, 255 insertions(+), 401 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -15,7 +15,12 @@ struct CrossmateApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
- if ProcessInfo.processInfo.arguments.contains("--crossmate-marketing-screenshot") {
+ if MarketingLaunch.isImportScene {
+ // The import scene drives the real Game List + New Puzzle sheet
+ // (seeded via `--crossmate-seed-demo`); `GameListView` opens the
+ // sheet and seeds the Imported tab when it detects this mode.
+ rootContent
+ } else if MarketingLaunch.isScreenshot {
MarketingScreenshotView(services: services)
.environment(services.preferences)
.environment(services.inputMonitor)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -12,42 +12,99 @@ import UserNotifications
/// both games on purpose, so one friend visibly takes a *different* colour in
/// each game — the per-game colour derivation made visible.
enum DemoSeed {
- private static let puzzleSource = """
- Title: Demo Puzzle
- Author: Crossmate
-
-
- ABC
- D#E
- FGH
-
-
- A1. Across 1 ~ ABC
- A4. Across 4 ~ DE
- A5. Across 5 ~ FGH
- D1. Down 1 ~ ADF
- D2. Down 2 ~ BG
- D3. Down 3 ~ CEH
- """
-
- /// A bundled 15×15 used for the one demo game seeded with letters already
- /// filled in, so the faint filled-cell author-attribution tints can be
- /// compared in a realistic grid rather than a 3×3 toy.
- private static let filledPuzzleResource = "cm-starter-0001"
+ /// Which library to seed. The two differ only in the collaborative
+ /// showcase game's roster: development wants four players (you + three
+ /// friends) so all four attribution tints can be eyeballed at once, while
+ /// marketing must never depict more than three players.
+ enum Profile {
+ case development
+ case marketing
+ }
/// The local user's authorID in demo mode, injected into `AuthorIdentity`
/// so the seeded friends classify as remote players. Kept distinct from
/// every friend id below.
static let localAuthorID = "_demo-you"
+ private static let alice = "_demo-alice"
+ private static let bob = "_demo-bob"
+ private static let carol = "_demo-carol"
+
private static let friends: [(id: String, name: String)] = [
- ("_demo-alice", "Alice"),
- ("_demo-bob", "Bob"),
- ("_demo-carol", "Carol"),
+ (alice, "Alice"),
+ (bob, "Bob"),
+ (carol, "Carol"),
]
+ /// One seeded game backed by a real bundled 15×15 starter. `fillAuthors`
+ /// are the authors given filled cells (drives the desaturated attribution
+ /// tints and, together with `participants`, the Game List colour strip);
+ /// empty leaves the grid blank (or fully solved, when `completed`).
+ private struct GameSpec {
+ let title: String
+ let resourceID: String
+ let participants: [String]
+ let fillAuthors: [String]
+ let completed: Bool
+ }
+
+ /// The demo library for `profile`, backed by real bundled 15×15 starters so
+ /// the Game List shows full grids rather than toy puzzles. Each game draws a
+ /// distinct puzzle so the row thumbnails differ; the mix spans shared/solo
+ /// and in-progress/completed to exercise every card state.
+ private static func games(for profile: Profile) -> [GameSpec] {
+ // The only per-profile difference: how many players share (and have
+ // filled cells in) the in-progress showcase game.
+ let showcase: GameSpec = switch profile {
+ case .development:
+ GameSpec(
+ title: "Tuesday Crossword",
+ resourceID: "cm-starter-0001",
+ participants: [alice, bob, carol],
+ fillAuthors: [localAuthorID, alice, bob, carol],
+ completed: false
+ )
+ case .marketing:
+ GameSpec(
+ title: "Tuesday Crossword",
+ resourceID: "cm-starter-0001",
+ participants: [alice, bob],
+ fillAuthors: [localAuthorID, alice, bob],
+ completed: false
+ )
+ }
+ return [
+ showcase,
+ GameSpec(
+ title: "Sunday Special",
+ resourceID: "cm-starter-0002",
+ participants: [alice, bob],
+ fillAuthors: [localAuthorID, alice, bob],
+ completed: false
+ ),
+ GameSpec(
+ title: "Coffee Break",
+ resourceID: "cm-starter-0003",
+ participants: [],
+ fillAuthors: [],
+ completed: true
+ ),
+ GameSpec(
+ title: "Weekend Challenge",
+ resourceID: "cm-starter-0004",
+ participants: [alice, carol],
+ fillAuthors: [],
+ completed: true
+ ),
+ ]
+ }
+
@MainActor
- static func populate(persistence: PersistenceController, preferences: PlayerPreferences) {
+ static func populate(
+ persistence: PersistenceController,
+ preferences: PlayerPreferences,
+ profile: Profile
+ ) {
let ctx = persistence.viewContext
// Keep the Game List out of its "set your profile name" empty state.
@@ -55,44 +112,29 @@ enum DemoSeed {
preferences.name = "You"
}
- guard let xd = try? XD.parse(puzzleSource) else { return }
- let puzzle = Puzzle(xd: xd)
-
for friend in friends {
seedFriend(friend, in: ctx)
}
- seedGame(
- title: "Tuesday Mini",
- participants: ["_demo-alice", "_demo-bob"],
- puzzle: puzzle,
- source: puzzleSource,
- in: ctx
- )
- seedGame(
- title: "Sunday Giant",
- participants: ["_demo-alice", "_demo-bob", "_demo-carol"],
- puzzle: puzzle,
- source: puzzleSource,
- in: ctx
- )
-
- // A full 15×15 with letters already filled in, attributed across all
- // four players, so the desaturated filled-cell tints can be eyeballed
- // in a real grid. Reuse the catalog the new-game picker uses; it
- // resolves the bundled `.xd` for us and reads its source on demand.
- if let entry = PuzzleCatalog.source(matchingResourceID: filledPuzzleResource, title: nil),
- let bigSource = try? entry.loadSource(),
- let bigXD = try? XD.parse(bigSource) {
- let bigPuzzle = Puzzle(xd: bigXD)
+ // Reuse the catalog the new-game picker uses; it resolves each bundled
+ // `.xd` for us and reads its source on demand.
+ for spec in games(for: profile) {
+ guard let entry = PuzzleCatalog.source(matchingResourceID: spec.resourceID, title: nil),
+ let source = try? entry.loadSource(),
+ let xd = try? XD.parse(source) else { continue }
+ let puzzle = Puzzle(xd: xd)
let game = seedGame(
- title: entry.title,
- participants: ["_demo-alice", "_demo-bob", "_demo-carol"],
- puzzle: bigPuzzle,
- source: bigSource,
+ title: spec.title,
+ resourceID: spec.resourceID,
+ participants: spec.participants,
+ puzzle: puzzle,
+ source: source,
+ completed: spec.completed,
in: ctx
)
- seedFilledLetters(in: game, puzzle: bigPuzzle, in: ctx)
+ if !spec.fillAuthors.isEmpty {
+ seedFilledLetters(in: game, puzzle: puzzle, authors: spec.fillAuthors, in: ctx)
+ }
}
try? ctx.save()
@@ -116,20 +158,34 @@ enum DemoSeed {
@discardableResult
private static func seedGame(
title: String,
+ resourceID: String,
participants: [String],
puzzle: Puzzle,
source: String,
+ completed: Bool,
in ctx: NSManagedObjectContext
) -> GameEntity {
+ let now = Date()
let game = GameEntity(context: ctx)
game.id = UUID()
game.title = title
game.puzzleSource = source
- game.createdAt = Date()
- game.updatedAt = Date()
+ game.puzzleCmVersion = Int64(XD.currentCmVersion)
+ game.puzzleResourceID = resourceID
+ game.createdAt = now
+ game.updatedAt = now
// A non-nil share record name is what marks the game as shared, which is
- // the gate for the Game List participant colour strip.
- game.ckShareRecordName = "demo-share-\(title)"
+ // the gate for the Game List participant colour strip; a solo game (no
+ // participants) leaves it nil.
+ if !participants.isEmpty {
+ game.ckShareRecordName = "demo-share-\(title)"
+ }
+ // A completed game is terminal: the Game List files it under "Completed"
+ // and renders it as a fully-solved board.
+ if completed {
+ game.completedAt = now
+ game.completedBy = participants.first ?? localAuthorID
+ }
game.populateCachedSummaryFields(from: puzzle)
for authorID in participants {
@@ -138,24 +194,25 @@ enum DemoSeed {
player.authorID = authorID
player.name = friends.first { $0.id == authorID }?.name
player.ckRecordName = "demo-player-\(title)-\(authorID)"
- player.updatedAt = Date()
+ player.updatedAt = now
}
return game
}
/// Fills a realistic share of `game`'s grid with correct letters, handing
- /// each cell to one of the four demo players (you + the three friends)
- /// in small diagonal patches and leaving scattered gaps so the puzzle reads
- /// as in-progress. One `MovesEntity` per author carries that author's cells,
- /// exactly as a real co-solve would, so `GridStateMerger` rebuilds the
- /// attributed grid — and each filled cell renders that player's faint
- /// attribution tint.
+ /// each cell to one of `authors` in small diagonal patches and leaving
+ /// scattered gaps so the puzzle reads as in-progress. One `MovesEntity` per
+ /// author carries that author's cells, exactly as a real co-solve would, so
+ /// `GridStateMerger` rebuilds the attributed grid — and each filled cell
+ /// renders that player's faint attribution tint. The author set also caps
+ /// how many colours the Game List strip shows for this game.
private static func seedFilledLetters(
in game: GameEntity,
puzzle: Puzzle,
+ authors: [String],
in ctx: NSManagedObjectContext
) {
- let authors = [localAuthorID] + friends.map(\.id)
+ guard !authors.isEmpty else { return }
let now = Date()
var cellsByAuthor: [String: [GridPosition: TimestampedCell]] = [:]
@@ -400,8 +457,19 @@ final class AppServices {
// app up against a throwaway in-memory store pre-filled with a couple of
// shared games and a few friends, purely so the Game List colour
// strips and the friends list can be eyeballed in the Simulator without
- // iCloud. It never touches the real on-disk store.
- let isDemoSeed = ProcessInfo.processInfo.arguments.contains("--crossmate-seed-demo")
+ // iCloud. It never touches the real on-disk store. `--crossmate-seed-
+ // marketing` seeds the same way but caps every game at three players,
+ // for the import marketing screenshot's backdrop.
+ let arguments = ProcessInfo.processInfo.arguments
+ let seedProfile: DemoSeed.Profile? =
+ if arguments.contains("--crossmate-seed-marketing") {
+ .marketing
+ } else if arguments.contains("--crossmate-seed-demo") {
+ .development
+ } else {
+ nil
+ }
+ let isDemoSeed = seedProfile != nil
// The demo seed writes a scratch profile name; give it a throwaway
// preferences store so that write can't sync into a real launch via the
// shared iCloud key-value store.
@@ -409,8 +477,8 @@ final class AppServices {
self.preferences = preferences
let persistence = PersistenceController(inMemory: isDemoSeed, eventLog: eventLog)
self.persistence = persistence
- if isDemoSeed {
- DemoSeed.populate(persistence: persistence, preferences: preferences)
+ if let seedProfile {
+ DemoSeed.populate(persistence: persistence, preferences: preferences, profile: seedProfile)
// Preview the one-time v4 reset notice on demand
// (`run-demo.sh --v4-notice`); set explicitly so a normal demo run
// clears any leftover flag on the reused demo simulator.
diff --git a/Crossmate/Services/DriveMonitor.swift b/Crossmate/Services/DriveMonitor.swift
@@ -113,6 +113,38 @@ final class DriveMonitor {
}
}
+#if DEBUG
+ /// Injects a fixed set of imported files so the marketing "Imported" tab
+ /// renders the real `ImportedBrowseView` without a live iCloud container
+ /// (a fresh screenshot simulator has none, so the real query would leave
+ /// the tab in its empty state). DEBUG-only, used by the import marketing
+ /// scene.
+ func seedMarketingImports() {
+ let names = ["Sunday Sample.xd", "Tournament Pack.puz", "Cryptic Practice.xd"]
+ let base = URL(fileURLWithPath: "/marketing", isDirectory: true)
+ let children = names.map { name -> DriveItem in
+ let url = base.appendingPathComponent(name)
+ return DriveItem(
+ id: url,
+ name: name,
+ url: url,
+ isDirectory: false,
+ isDownloaded: true,
+ children: []
+ )
+ }
+ containerAvailable = true
+ root = DriveItem(
+ id: base,
+ name: "Crossmate",
+ url: base,
+ isDirectory: true,
+ isDownloaded: true,
+ children: children
+ )
+ }
+#endif
+
func importFile(from sourceURL: URL) throws {
guard let documentsURL else { throw DriveError.containerUnavailable }
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -47,6 +47,9 @@ struct GameListView: View {
@Environment(AnnouncementCenter.self) private var announcements
@Environment(EventLog.self) private var eventLog
@Environment(TipStore.self) private var tips
+ #if DEBUG
+ @Environment(DriveMonitor.self) private var driveMonitor
+ #endif
@State private var acceptingInviteID: NSManagedObjectID?
@State private var blockTarget: InviteEntity?
@@ -157,6 +160,17 @@ struct GameListView: View {
await onAppear()
reconcilePendingInviteNotification()
}
+ #if DEBUG
+ .task {
+ // Marketing "import" scene: render the real New Puzzle sheet over the
+ // seeded library instead of a mock. Seed the Imported tab with fake
+ // files, preselect it, then present the real sheet.
+ guard MarketingLaunch.isImportScene else { return }
+ driveMonitor.seedMarketingImports()
+ UserDefaults.standard.set(PuzzleSource.imported.rawValue, forKey: "lastPuzzleSource")
+ showingNewGame = true
+ }
+ #endif
.onChange(of: pendingInviteNotificationGameID) { _, _ in
reconcilePendingInviteNotification()
}
diff --git a/Crossmate/Views/MarketingScreenshots.swift b/Crossmate/Views/MarketingScreenshots.swift
@@ -2,6 +2,21 @@ import SwiftUI
#if DEBUG
+/// Launch-argument helpers shared by the app root and the Game List so the
+/// marketing "import" scene can drive the real New Puzzle flow instead of a
+/// mock. DEBUG-only, mirroring the rest of this file.
+enum MarketingLaunch {
+ static var isScreenshot: Bool {
+ ProcessInfo.processInfo.arguments.contains("--crossmate-marketing-screenshot")
+ }
+
+ /// The import scene renders the real Game List + New Puzzle sheet at the
+ /// app root rather than routing through `MarketingScreenshotView`.
+ static var isImportScene: Bool {
+ isScreenshot && MarketingScene.current == .import
+ }
+}
+
@MainActor
struct MarketingScreenshotView: View {
private let services: AppServices
@@ -14,10 +29,12 @@ struct MarketingScreenshotView: View {
var body: some View {
switch scene {
- case .import:
- MarketingImportScreenshotView()
case .together, .solo, .replay, .undoRedo:
MarketingPuzzleSceneView(scene: scene, services: services)
+ case .import:
+ // The import scene renders the real Game List + New Puzzle sheet at
+ // the app root (see `CrossmateApp`); it never routes through here.
+ Color(.systemBackground)
}
}
}
@@ -303,323 +320,4 @@ private struct MarketingPuzzleSceneView: View {
"""
}
-private struct MarketingImportScreenshotView: View {
- @State private var selection = PuzzleSource.imported
-
- var body: some View {
- GeometryReader { proxy in
- let isPad = UIDevice.current.userInterfaceIdiom == .pad
-
- ZStack {
- if isPad {
- MarketingGameListBackdrop()
- } else {
- Color(.systemGroupedBackground)
- .ignoresSafeArea()
- }
-
- sheetContent
- .frame(
- width: isPad ? min(proxy.size.width * 0.58, 760) : proxy.size.width,
- height: isPad ? min(proxy.size.height * 0.86, 920) : proxy.size.height
- )
- .clipShape(RoundedRectangle(cornerRadius: isPad ? 18 : 0, style: .continuous))
- .shadow(color: isPad ? Color.black.opacity(0.18) : .clear, radius: 22, y: 10)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- .background {
- if isPad {
- Color.black.opacity(0.08)
- .ignoresSafeArea()
- }
- }
- }
- .preferredColorScheme(.light)
- }
-
- private var sheetContent: some View {
- NavigationStack {
- VStack(spacing: 0) {
- Picker("Source", selection: $selection) {
- Text(PuzzleSource.bundles.title).tag(PuzzleSource.bundles)
- Text(PuzzleSource.imported.title).tag(PuzzleSource.imported)
- }
- .pickerStyle(.segmented)
- .padding()
-
- List {
- Section("Imported") {
- Label("Sunday Sample.xd", systemImage: "doc.text")
- Label("Tournament Pack.puz", systemImage: "doc.text")
- Label("Cryptic Practice.xd", systemImage: "doc.text")
- }
- }
- .listStyle(.insetGrouped)
- }
- .navigationTitle("New Puzzle")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button {} label: {
- Image(systemName: "xmark")
- }
- .accessibilityLabel("Cancel")
- }
- ToolbarItem(placement: .primaryAction) {
- Button {} label: {
- Label("Import", systemImage: "square.and.arrow.down")
- }
- }
- }
- }
- }
-}
-
-private struct MarketingGameListBackdrop: View {
- private let columns = [GridItem(.adaptive(minimum: 380), spacing: 12)]
-
- var body: some View {
- NavigationStack {
- ScrollView {
- LazyVStack(spacing: 8) {
- section("In Progress") {
- LazyVGrid(columns: columns, spacing: 12) {
- gameCard(
- title: "Crossmate",
- publisher: "Collaborative Crossword App",
- updated: "Last updated 2 minutes ago",
- isShared: true,
- cells: Self.crossmateCells
- )
- gameCard(
- title: "Weekend Cryptic",
- publisher: "Imported",
- updated: "Last updated 1 hour ago",
- isShared: false,
- cells: Self.crypticCells
- )
- }
- }
-
- section("Completed") {
- LazyVGrid(columns: columns, spacing: 12) {
- gameCard(
- title: "Morning Mini",
- publisher: "Crossmate",
- updated: "Last updated 3 hours ago",
- isShared: false,
- cells: Self.miniCells
- )
- gameCard(
- title: "Tournament Pack",
- publisher: "Across Lite",
- updated: "Last updated on 3 Jul 2026",
- isShared: true,
- cells: Self.tournamentCells
- )
- }
- }
- }
- .padding(.vertical, 8)
- }
- .background(Color(.systemGroupedBackground))
- .navigationTitle("")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarLeading) {
- Button {} label: {
- Image(systemName: "gearshape")
- }
- }
- ToolbarItem(placement: .topBarTrailing) {
- Button {} label: {
- Image(systemName: "person.2")
- }
- }
- ToolbarItem(placement: .topBarTrailing) {
- Button {} label: {
- Image(systemName: "plus")
- }
- }
- }
- }
- .disabled(true)
- .ignoresSafeArea()
- }
-
- private func section<Content: View>(
- _ title: String,
- @ViewBuilder content: () -> Content
- ) -> some View {
- Section {
- content()
- .padding(.horizontal)
- } header: {
- Text(title)
- .font(.footnote.weight(.semibold))
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 16)
- .padding(.vertical, 8)
- .background(Color(.systemGroupedBackground))
- }
- }
-
- private func gameCard(
- title: String,
- publisher: String,
- updated: String,
- isShared: Bool,
- cells: [GameThumbnailCell]
- ) -> some View {
- HStack(spacing: 12) {
- VStack(spacing: 0) {
- GridThumbnailView(width: 15, height: 15, cells: cells, size: 60)
- if isShared {
- participantStrip
- .frame(width: 60)
- .padding(.top, 2)
- }
- }
- .frame(width: 60)
-
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 4) {
- Text(title)
- .font(.headline)
- .lineLimit(1)
- if isShared {
- Image(systemName: "person.2.fill")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
- }
- }
- Text(publisher)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- Text(updated)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
-
- Spacer(minLength: 0)
-
- Image(systemName: "ellipsis.circle")
- .font(.title3)
- .foregroundStyle(.secondary)
- .frame(width: 32, height: 32)
- }
- .padding(12)
- .frame(maxWidth: .infinity)
- .frame(height: CardMetrics.height)
- .background(
- Color(.secondarySystemGroupedBackground),
- in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
- )
- }
-
- private var participantStrip: some View {
- HStack(spacing: 0) {
- Rectangle()
- .fill(Color.blue)
- Rectangle()
- .fill(Color(.separator))
- .frame(width: 0.5)
- Rectangle()
- .fill(Color.red)
- }
- .frame(maxWidth: .infinity)
- .frame(height: 6)
- }
-
- private static func cells(_ rows: [String]) -> [GameThumbnailCell] {
- rows.flatMap { row in
- row.map { ch in
- switch ch {
- case "#":
- .block
- case "x", "X":
- .filled
- default:
- .empty
- }
- }
- }
- }
-
- private static let crossmateCells = cells([
- "....#....#.....",
- "....#....#.....",
- "....#....#...##",
- ".............##",
- "...#....#......",
- "..#...##...#...",
- "....#.....#....",
- "###xxxxxx...###",
- ".....#...#.....",
- "....#..........",
- "...#...##...###",
- "......#....#...",
- "##.............",
- ".....#....#....",
- ".....#....#....",
- ])
-
- private static let crypticCells = cells([
- "....#....#.....",
- "..#......#.....",
- "......#.....#..",
- ".#.......#.....",
- ".....###.......",
- "....#.....#....",
- "...#.......#...",
- "##....#....#...",
- "...#.......#...",
- "....#.....#....",
- ".......###.....",
- ".....#.......#.",
- "..#.....#......",
- ".....#......#..",
- ".....#....#....",
- ])
-
- private static let miniCells = cells([
- ".....#....#....",
- ".xxx.#....#....",
- ".xxx.#....#....",
- ".xxx...........",
- ".....#....#....",
- "##....#....#...",
- ".....#....#....",
- "....#....#.....",
- "....#....#.....",
- "..#....#....##.",
- "....#....#.....",
- "..........#....",
- "....#....#.xxx.",
- "....#....#.xxx.",
- "....#....#.....",
- ])
-
- private static let tournamentCells = cells([
- "....#....#.....",
- ".xxxx....#.....",
- ".xxxx....#..##.",
- ".xxxx..........",
- "...#....#......",
- "..#...##...#...",
- "....#.....#....",
- "###....xxxx.###",
- ".....#...#.....",
- "....#..........",
- "...#...##...###",
- "......#...xxxx.",
- "##........xxxx.",
- ".....#....xxxx.",
- ".....#....#....",
- ])
-}
-
#endif
diff --git a/Scripts/screenshots-marketing.sh b/Scripts/screenshots-marketing.sh
@@ -26,19 +26,33 @@ case "$PLATFORM" in
esac
set_simulator_landscape_left() {
+ # The Device ▸ Orientation ▸ Landscape Left menu path (the extra
+ # `menu bar item "Device"` level is required on the iOS 26 Simulator).
+ # "Landscape Left" is absolute, so clicking it repeatedly is idempotent.
osascript \
-e 'tell application "Simulator" to activate' \
- -e 'delay 0.2' \
- -e 'tell application "System Events" to tell process "Simulator" to click menu item "Landscape Left" of menu 1 of menu item "Orientation" of menu "Device" of menu bar 1'
+ -e 'delay 0.3' \
+ -e 'tell application "System Events" to tell process "Simulator" to click menu item "Landscape Left" of menu "Orientation" of menu item "Orientation" of menu "Device" of menu bar item "Device" of menu bar 1' \
+ >/dev/null 2>&1 || true
}
set_ipad_landscape() {
local udid="$1"
+ # Bring the target device's window to the front — the orientation menu acts on
+ # the frontmost device window, so this must run whether or not the Simulator
+ # (or this device) was already booted.
open -a Simulator --args -CurrentDeviceUDID "$udid" >/dev/null 2>&1 || true
sleep 1
- set_simulator_landscape_left
- sleep 1
+
+ # Click a few times: right after the window comes forward the orientation menu
+ # item can be briefly disabled, no-oping the click. The command is idempotent,
+ # so extra clicks are harmless once landscape has taken.
+ local attempt
+ for attempt in 1 2 3; do
+ set_simulator_landscape_left
+ sleep 1
+ done
}
if [ "$PLATFORM" = "iphone" ]; then
@@ -108,8 +122,23 @@ fi
xcrun simctl boot "$UDID" 2>/dev/null || true
xcrun simctl bootstatus "$UDID" -b
+# Marketing status-bar time/date: 9:41 AM (Apple's canonical time) on the day of
+# this repo's first commit to master. Passing a full ISO 8601 string to --time
+# sets BOTH the date and the time on devices whose status bar shows a date
+# (iPad); a bare "9:41" sets only the time. The date is derived from the UTC
+# instant, so parse the wall-clock 9:41 in the local zone first.
+# NOTE: current Simulator versions apply only the time from this string and fall
+# back to today's date; the date half may start working if Apple fixes that.
+FIRST_COMMIT_DAY=$(git -C "$REPO_DIR" log master --reverse --format='%cd' --date=format:'%Y-%m-%d' 2>/dev/null | head -1)
+if [ -n "$FIRST_COMMIT_DAY" ]; then
+ MARKETING_EPOCH=$(date -j -f "%Y-%m-%d %H:%M:%S" "${FIRST_COMMIT_DAY} 09:41:00" "+%s")
+ MARKETING_TIME=$(date -u -r "$MARKETING_EPOCH" "+%Y-%m-%dT%H:%M:%S.000Z")
+else
+ MARKETING_TIME="9:41"
+fi
+
xcrun simctl status_bar "$UDID" override \
- --time "9:41" \
+ --time "$MARKETING_TIME" \
--batteryState discharging \
--batteryLevel 100 \
--wifiBars 3 \
@@ -117,7 +146,11 @@ xcrun simctl status_bar "$UDID" override \
--cellularMode active \
--dataNetwork wifi
-echo "Status bar overridden"
+echo "Status bar overridden (time/date: ${MARKETING_TIME})"
+
+# The import scene renders the real (system-appearance) Game List + sheet; the
+# other scenes force .light in code. Pin the sim to light so all scenes match.
+xcrun simctl ui "$UDID" appearance light 2>/dev/null || true
DERIVED_DATA="/tmp/crossmate-marketing-derived"
WORK_DIR="/tmp/crossmate-marketing-${PLATFORM}"
@@ -174,6 +207,10 @@ for index in "${!SCENES[@]}"; do
if [ "$scene" = "replay" ]; then
launch_args+=(--crossmate-marketing-replay-position 11)
fi
+ if [ "$scene" = "import" ]; then
+ # Boots the real Game List (seeded, three-player-max) as the sheet backdrop.
+ launch_args+=(--crossmate-seed-marketing)
+ fi
echo ""
echo "Capturing ${PLATFORM}_${index}.png - ${caption}"