crossmate

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

PuzzleView.swift (60472B)


      1 import SwiftUI
      2 
      3 private enum RevealScope {
      4     case square
      5     case word
      6     case puzzle
      7 
      8     var title: String {
      9         switch self {
     10         case .square: "Reveal Square?"
     11         case .word: "Reveal Word?"
     12         case .puzzle: "Reveal Puzzle?"
     13         }
     14     }
     15 
     16     var message: String {
     17         switch self {
     18         case .square: "This will reveal the current square."
     19         case .word: "This will reveal the current word."
     20         case .puzzle: "This will reveal the entire puzzle and mark it complete."
     21         }
     22     }
     23 }
     24 
     25 struct PuzzleView: View {
     26     @Bindable var session: PlayerSession
     27     var shareController: ShareController? = nil
     28     let roster: PlayerRoster
     29     var onComplete: ((_ notifyPeers: Bool) -> Void)? = nil
     30     var onResign: (() throws -> Void)? = nil
     31     var onDelete: (() throws -> Void)? = nil
     32     /// Loads the finished game's merged journal for the finish-banner replay
     33     /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it.
     34     var loadReplay: () async -> JournalReplayResult = { .unavailable }
     35     /// Cells a peer filled or cleared since this player last viewed the puzzle,
     36     /// mapped to the writing author. Read once on the open arm beat. Defaults to
     37     /// empty so previews/tests need not wire it.
     38     var loadRecentChanges: () -> [GridPosition: String] = { [:] }
     39     /// Stamps this game's last-viewed timestamp (device-local). Called when the
     40     /// away-change borders are acknowledged. Defaults to a no-op.
     41     var markPuzzleViewed: () -> Void = {}
     42     @Environment(InputMonitor.self) private var inputMonitor
     43     @Environment(PlayerPreferences.self) private var preferences
     44     @Environment(AnnouncementCenter.self) private var announcements
     45     @Environment(\.dismiss) private var dismiss
     46     @State private var isRenaming = false
     47     @State private var renameDraft = ""
     48     @State private var showErrorsAlert = false
     49     @State private var isConfirmingResign = false
     50     @State private var isConfirmingDelete = false
     51     @State private var isConfirmingLeave = false
     52     @State private var isConfirmingReveal = false
     53     @State private var pendingRevealScope: RevealScope = .square
     54     @State private var leaveError: String?
     55     @State private var destructiveActionError: String?
     56     @State private var isShowingShareSheet = false
     57     @State private var hasSolved = false
     58     @State private var replay = ReplayControls()
     59     @State private var padLayout: PadLayout?
     60     /// The shared open "arm" beat: flips a moment after open so the banner and
     61     /// the "changed while you were away" borders reveal together.
     62     @State private var isArmed = false
     63     @Environment(\.engagementStatus) private var engagementStatus
     64 
     65     private enum PadLayout {
     66         case landscape
     67         case portrait
     68     }
     69 
     70     private func swatchImage(for color: PlayerColor) -> Image {
     71         let tint = UIColor(color.tint)
     72         let base = UIImage(systemName: "circle.fill") ?? UIImage()
     73         return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal))
     74     }
     75 
     76     private struct TitleParts {
     77         let title: String
     78         let subtitle: String?
     79     }
     80 
     81     private var titleParts: TitleParts {
     82         let title = session.puzzle.title
     83         let formattedDate = session.puzzle.date?.formatted(date: .long, time: .omitted)
     84         let subtitle: String?
     85         if let publisher = session.puzzle.publisher, let formattedDate {
     86             subtitle = "\(publisher) · \(formattedDate)"
     87         } else if let publisher = session.puzzle.publisher {
     88             subtitle = publisher
     89         } else {
     90             subtitle = formattedDate
     91         }
     92         return TitleParts(title: title, subtitle: subtitle)
     93     }
     94 
     95     // Latched completion counts as solved for the read-only presentation
     96     // (hides the keyboard, shows the finish panel, disables the controls) even
     97     // when the locally merged grid drifted and no longer reads `.solved`.
     98     private var isSolved: Bool { hasSolved || session.mutator.isCompleted }
     99 
    100     /// Whether a sticky, input-blocking announcement (currently only
    101     /// access revocation) is showing for this game. Greys out the custom
    102     /// keyboard and makes the hardware-key handler a no-op.
    103     private var isInputBlocked: Bool {
    104         announcements.isInputBlocked(forGame: session.mutator.gameID)
    105     }
    106 
    107     var body: some View {
    108         Group {
    109             switch padLayout {
    110             case .landscape:
    111                 landscapePadLayout
    112             case .portrait:
    113                 portraitPadLayout
    114             case .none:
    115                 phoneLayout
    116             }
    117         }
    118         .background(Color(.systemBackground))
    119         .background {
    120             HardwareKeyboardInputView(onPress: handleHardwareKeyboardEvent)
    121                 .frame(width: 0, height: 0)
    122                 .allowsHitTesting(false)
    123         }
    124         .ignoresSafeArea(.keyboard)
    125         .modifier(PuzzleToolbarModifier(
    126             session: session,
    127             roster: roster,
    128             shareController: shareController,
    129             isSolved: isSolved,
    130             canResign: onResign != nil,
    131             canDelete: onDelete != nil,
    132             isRenaming: $isRenaming,
    133             renameDraft: $renameDraft,
    134             isConfirmingResign: $isConfirmingResign,
    135             isConfirmingDelete: $isConfirmingDelete,
    136             isConfirmingLeave: $isConfirmingLeave,
    137             isConfirmingReveal: $isConfirmingReveal,
    138             pendingRevealScope: $pendingRevealScope,
    139             isShowingShareSheet: $isShowingShareSheet
    140         ))
    141         .modifier(PuzzleLifecycleModifier(
    142             session: session,
    143             roster: roster,
    144             hasSolved: $hasSolved,
    145             onCompletionEvent: handleCompletionEvent,
    146             onSolvedOnAppear: {
    147                 onComplete?(false)
    148             }
    149         ))
    150         .modifier(PuzzlePresentationModifier(
    151             session: session,
    152             shareController: shareController,
    153             isRenaming: $isRenaming,
    154             renameDraft: $renameDraft,
    155             showErrorsAlert: $showErrorsAlert,
    156             isConfirmingResign: $isConfirmingResign,
    157             isConfirmingDelete: $isConfirmingDelete,
    158             isConfirmingLeave: $isConfirmingLeave,
    159             isConfirmingReveal: $isConfirmingReveal,
    160             pendingRevealScope: $pendingRevealScope,
    161             leaveError: $leaveError,
    162             destructiveActionError: $destructiveActionError,
    163             isShowingShareSheet: $isShowingShareSheet,
    164             performResign: performResign,
    165             performDelete: performDelete,
    166             leaveSharedGame: leaveSharedGame
    167         ))
    168         .onGeometryChange(for: CGSize.self) { proxy in
    169             proxy.size
    170         } action: { newSize in
    171             updateLayoutTrait(for: newSize)
    172         }
    173         .onAppear {
    174             session.onRecentChangesAcknowledged = markPuzzleViewed
    175         }
    176         .task(id: session.mutator.gameID) {
    177             // The shared open beat. A short hold lets the puzzle settle and the
    178             // on-open sync land; then we arm the banner and capture — once —
    179             // which cells a peer changed while we were away, so both reveal
    180             // together. Moves that arrive after this are live activity (peer
    181             // cursor tints), not part of the away-summary.
    182             isArmed = false
    183             try? await Task.sleep(for: .milliseconds(750))
    184             isArmed = true
    185             if session.mutator.isShared {
    186                 session.recentChanges = loadRecentChanges()
    187             }
    188         }
    189     }
    190 
    191     private var phoneLayout: some View {
    192         VStack(spacing: 0) {
    193             puzzleArea
    194             controlsArea(showClueBar: true)
    195         }
    196     }
    197 
    198     private var landscapePadLayout: some View {
    199         VStack(spacing: 0) {
    200             HStack(spacing: 0) {
    201                 VStack(spacing: 0) {
    202                     if !isSolved {
    203                         PuzzleScoreboard(session: session, roster: roster)
    204 
    205                         Divider()
    206                     }
    207 
    208                     ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame)
    209                 }
    210                     .frame(minWidth: 300, idealWidth: 360, maxWidth: 420)
    211                     .background(Color(.secondarySystemBackground))
    212 
    213                 Divider()
    214                     .ignoresSafeArea(edges: .top)
    215 
    216                 puzzleArea
    217                     .padding(.bottom, 12)
    218                     .frame(maxWidth: .infinity, maxHeight: .infinity)
    219             }
    220             .frame(maxWidth: .infinity, maxHeight: .infinity)
    221 
    222             controlsArea(showClueBar: false)
    223         }
    224     }
    225 
    226     private var portraitPadLayout: some View {
    227         VStack(spacing: 0) {
    228             WeightedVStack(weights: [3, 1]) {
    229                 puzzleArea
    230                     .frame(maxWidth: .infinity, maxHeight: .infinity)
    231                     .padding(.bottom, 12)
    232 
    233                 VStack(spacing: 0) {
    234                     Divider()
    235 
    236                     HStack(alignment: .top, spacing: 0) {
    237                         if !isSolved {
    238                             PuzzleScoreboard(session: session, roster: roster)
    239                                 .frame(minWidth: 240, idealWidth: 280, maxWidth: 320)
    240 
    241                             Divider()
    242                         }
    243 
    244                         ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame)
    245                             .frame(maxWidth: .infinity, maxHeight: .infinity)
    246                     }
    247                     .background(Color(.secondarySystemBackground))
    248                 }
    249             }
    250             .frame(maxWidth: .infinity, maxHeight: .infinity)
    251 
    252             controlsArea(showClueBar: false)
    253         }
    254     }
    255 
    256     private func updateLayoutTrait(for size: CGSize) {
    257         guard UIDevice.current.userInterfaceIdiom == .pad, size != .zero else {
    258             padLayout = nil
    259             return
    260         }
    261         padLayout = size.width > size.height ? .landscape : .portrait
    262     }
    263 
    264     private func performResign() {
    265         do {
    266             try onResign?()
    267             dismiss()
    268         } catch {
    269             destructiveActionError = String(describing: error)
    270         }
    271     }
    272 
    273     private func performDelete() {
    274         do {
    275             try onDelete?()
    276             dismiss()
    277         } catch {
    278             destructiveActionError = String(describing: error)
    279         }
    280     }
    281 
    282     private func handleCompletionEvent(_ event: PlayerSession.CompletionEvent) {
    283         switch (event.origin, event.state) {
    284         case (_, .incomplete):
    285             break
    286         case (.observed, .filledWithErrors):
    287             // A collaborator's wrong entry must not interrupt the local solver.
    288             break
    289         case (.local, .filledWithErrors):
    290             showErrorsAlert = true
    291         case (.local, .solved):
    292             guard !hasSolved else { return }
    293             hasSolved = true
    294             if session.isPencilMode {
    295                 session.togglePencil()
    296             }
    297             Task { @MainActor in
    298                 onComplete?(true)
    299             }
    300         case (.observed, .solved):
    301             guard !hasSolved else { return }
    302             hasSolved = true
    303             onComplete?(false)
    304         }
    305     }
    306 
    307     private var puzzleArea: some View {
    308         ZStack {
    309             VStack(spacing: 4) {
    310                 PuzzleHeader(
    311                     session: session,
    312                     roster: roster,
    313                     title: titleParts.title,
    314                     subtitle: titleParts.subtitle,
    315                     showsScoreboard: padLayout == nil,
    316                     gameID: session.mutator.gameID,
    317                     isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true,
    318                     isArmed: isArmed
    319                 )
    320                 GridView(
    321                     session: session,
    322                     roster: roster,
    323                     showsSharedAnnotations: session.mutator.isShared,
    324                     showsPeerCursors: !isSolved,
    325                     replayFrame: replay.frame
    326                 )
    327             }
    328             .frame(maxWidth: .infinity, maxHeight: .infinity)
    329             .padding(.top, -8)
    330 
    331             if session.isRebusActive {
    332                 Color.black.opacity(0.35)
    333                     .ignoresSafeArea(edges: .top)
    334                     .contentShape(Rectangle())
    335                     .onTapGesture {
    336                         session.commitRebus()
    337                     }
    338                 RebusModal(text: session.rebusBuffer)
    339                     .padding(.horizontal)
    340                     .contentShape(Rectangle())
    341                     .onTapGesture { /* swallow */ }
    342             }
    343         }
    344     }
    345 
    346     private func controlsArea(showClueBar: Bool) -> some View {
    347         VStack(spacing: 0) {
    348             if showClueBar {
    349                 ClueBarSlot(session: session, replayFrame: replay.frame)
    350             }
    351             controlsPanel
    352                 .frame(height: controlsPanelHeight)
    353         }
    354     }
    355 
    356     private var controlsPanel: some View {
    357         ZStack(alignment: .top) {
    358             if isSolved {
    359                 ControlsView(height: controlsPanelHeight) {
    360                     SuccessPanel(
    361                         session: session,
    362                         roster: roster,
    363                         replay: replay,
    364                         loadReplay: loadReplay
    365                     )
    366                 }
    367                 .transition(.move(edge: .bottom))
    368             } else if showsCustomKeyboard {
    369                 ControlsView(height: controlsPanelHeight) {
    370                     KeyboardView(session: session, showsNavigationKeys: padLayout != nil)
    371                         .opacity(isInputBlocked ? 0.4 : 1)
    372                         .allowsHitTesting(!isInputBlocked)
    373                         .animation(.easeInOut(duration: 0.3), value: isInputBlocked)
    374                 }
    375                 .transition(.move(edge: .bottom))
    376             }
    377         }
    378         .frame(height: controlsPanelHeight, alignment: .top)
    379         .background {
    380             Color(.systemGroupedBackground)
    381                 .ignoresSafeArea(edges: .bottom)
    382         }
    383         .overlay(alignment: .top) {
    384             if controlsPanelHeight > 0 {
    385                 Rectangle()
    386                     .fill(Color(.opaqueSeparator))
    387                     .frame(height: 0.5)
    388             }
    389         }
    390         .animation(.easeOut(duration: 0.25), value: isSolved)
    391         .ignoresSafeArea(edges: .bottom)
    392     }
    393 
    394     private var controlsPanelHeight: CGFloat {
    395         isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0
    396     }
    397 
    398     private var showsCustomKeyboard: Bool {
    399         !inputMonitor.isConnected
    400     }
    401 
    402     private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool {
    403         guard !isSolved, !isInputBlocked else { return false }
    404 
    405         // Cmd+Z undoes, Shift-Cmd-Z redoes. Caught before the letter switch so
    406         // the modified press isn't read as typing a "Z".
    407         if event.keyCode == .keyboardZ, event.modifierFlags.contains(.command) {
    408             guard !session.isRebusActive else { return false }
    409             if event.modifierFlags.contains(.shift) {
    410                 session.redo()
    411             } else {
    412                 session.undo()
    413             }
    414             return true
    415         }
    416 
    417         switch event.keyCode {
    418         case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE,
    419              .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ,
    420              .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO,
    421              .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT,
    422              .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY,
    423              .keyboardZ:
    424             guard !event.modifierFlags.contains(.command),
    425                   !event.modifierFlags.contains(.control),
    426                   !event.modifierFlags.contains(.alternate),
    427                   let letter = hardwareKeyboardLetter(from: event) else {
    428                 return false
    429             }
    430             if session.isRebusActive {
    431                 session.appendRebusLetter(letter)
    432             } else {
    433                 session.enter(letter)
    434             }
    435             return true
    436 
    437         case .keyboardDeleteOrBackspace, .keyboardDeleteForward:
    438             if session.isRebusActive {
    439                 session.deleteRebusLetter()
    440             } else {
    441                 session.deleteBackward()
    442             }
    443             return true
    444 
    445         case .keyboardLeftArrow:
    446             guard !session.isRebusActive else { return false }
    447             if event.modifierFlags.contains(.command) {
    448                 session.goToPreviousWord()
    449                 return true
    450             }
    451             moveWithHardwareArrow(direction: .across) {
    452                 session.goToPreviousLetter()
    453             }
    454             return true
    455 
    456         case .keyboardRightArrow:
    457             guard !session.isRebusActive else { return false }
    458             if event.modifierFlags.contains(.command) {
    459                 session.goToNextWord()
    460                 return true
    461             }
    462             moveWithHardwareArrow(direction: .across) {
    463                 session.goToNextLetter()
    464             }
    465             return true
    466 
    467         case .keyboardUpArrow:
    468             guard !session.isRebusActive else { return false }
    469             moveWithHardwareArrow(direction: .down) {
    470                 session.goToPreviousLetter()
    471             }
    472             return true
    473 
    474         case .keyboardDownArrow:
    475             guard !session.isRebusActive else { return false }
    476             moveWithHardwareArrow(direction: .down) {
    477                 session.goToNextLetter()
    478             }
    479             return true
    480 
    481         case .keyboardTab:
    482             guard !session.isRebusActive else { return false }
    483             if event.modifierFlags.contains(.shift) {
    484                 session.goToPreviousClue()
    485             } else {
    486                 session.goToNextClue()
    487             }
    488             return true
    489 
    490         case .keyboardSpacebar:
    491             guard !session.isRebusActive else { return false }
    492             session.toggleDirection()
    493             return true
    494 
    495         case .keyboardReturnOrEnter:
    496             if session.isRebusActive {
    497                 session.commitRebus()
    498             } else {
    499                 session.toggleDirection()
    500             }
    501             return true
    502 
    503         case .keyboardEscape:
    504             if session.isRebusActive {
    505                 session.commitRebus()
    506                 return true
    507             }
    508             return false
    509 
    510         default:
    511             return false
    512         }
    513     }
    514 
    515     private func hardwareKeyboardLetter(from event: HardwareKeyboardEvent) -> String? {
    516         let scalars = event.charactersIgnoringModifiers.unicodeScalars
    517         guard scalars.count == 1, let scalar = scalars.first else { return nil }
    518 
    519         switch scalar.value {
    520         case 65...90, 97...122:
    521             return String(Character(scalar)).uppercased()
    522         default:
    523             return nil
    524         }
    525     }
    526 
    527     private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) {
    528         if session.direction != direction {
    529             let previousDirection = session.direction
    530             session.setDirection(direction)
    531             if session.direction != previousDirection {
    532                 return
    533             }
    534         }
    535 
    536         move()
    537     }
    538 
    539     private func leaveSharedGame() async {
    540         guard let shareController else { return }
    541         do {
    542             try await shareController.leaveShare(gameID: session.mutator.gameID)
    543             dismiss()
    544         } catch {
    545             leaveError = String(describing: error)
    546         }
    547     }
    548 }
    549 
    550 private struct PuzzleScoreboard: View {
    551     @Bindable var session: PlayerSession
    552     let roster: PlayerRoster
    553     var layout: Layout = .vertical
    554     @Environment(PlayerPreferences.self) private var preferences
    555     @ScaledMetric(relativeTo: .subheadline) private var horizontalHeaderHeight: CGFloat = 56
    556 
    557     enum Layout {
    558         /// Side-panel style: stacked rows under a "Players" heading.
    559         case vertical
    560         /// Paged-header style: a horizontally scrollable strip of player
    561         /// chips, sized to scroll past two players when more arrive.
    562         case horizontal
    563     }
    564 
    565     private struct Score: Identifiable {
    566         let authorID: String?
    567         let name: String
    568         let color: PlayerColor?
    569         let filledCount: Int
    570 
    571         var id: String { authorID ?? "unattributed" }
    572     }
    573 
    574     private var fillableCellCount: Int {
    575         session.puzzle.cells.reduce(0) { count, row in
    576             count + row.filter { !$0.isBlock }.count
    577         }
    578     }
    579 
    580     private var filledCellCount: Int {
    581         var count = 0
    582         for r in 0..<session.puzzle.height {
    583             for c in 0..<session.puzzle.width {
    584                 guard !session.puzzle.cells[r][c].isBlock else { continue }
    585                 if !session.game.squares[r][c].entry.isEmpty {
    586                     count += 1
    587                 }
    588             }
    589         }
    590         return count
    591     }
    592 
    593     private var revealedSquareCount: Int {
    594         var count = 0
    595         for r in 0..<session.puzzle.height {
    596             for c in 0..<session.puzzle.width {
    597                 guard !session.puzzle.cells[r][c].isBlock else { continue }
    598                 if session.game.squares[r][c].mark.isRevealed {
    599                     count += 1
    600                 }
    601             }
    602         }
    603         return count
    604     }
    605 
    606     private var remainingCount: Int {
    607         max(0, fillableCellCount - filledCellCount)
    608     }
    609 
    610     private var remainingPhrase: String {
    611         switch remainingCount {
    612         case 0:
    613             return "no squares to go"
    614         case 1:
    615             return "1 square to go"
    616         default:
    617             return "\(remainingCount) squares to go"
    618         }
    619     }
    620 
    621     private var revealedPhrase: String {
    622         switch revealedSquareCount {
    623         case 0:
    624             return "No squares revealed"
    625         case 1:
    626             return "1 square revealed"
    627         default:
    628             return "\(revealedSquareCount) squares revealed"
    629         }
    630     }
    631 
    632     private var progressText: String {
    633         if revealedSquareCount > 0 {
    634             return "\(revealedPhrase), \(remainingPhrase)"
    635         }
    636         switch remainingCount {
    637         case 0:
    638             return "No squares to go"
    639         case 1:
    640             return "1 square to go"
    641         default:
    642             return "\(remainingCount) squares to go"
    643         }
    644     }
    645 
    646     private var scores: [Score] {
    647         var counts: [String?: Int] = [:]
    648         for r in 0..<session.puzzle.height {
    649             for c in 0..<session.puzzle.width {
    650                 guard !session.puzzle.cells[r][c].isBlock else { continue }
    651                 let square = session.game.squares[r][c]
    652                 guard !square.entry.isEmpty, !square.mark.isRevealed else { continue }
    653                 counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1
    654             }
    655         }
    656 
    657         let entries = roster.entries
    658         let usesLocalFallback = entries.isEmpty
    659         let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) })
    660         let rosterAuthorIDs = Set(entries.map(\.authorID))
    661 
    662         let rosterScores: [Score]
    663         if usesLocalFallback {
    664             rosterScores = [
    665                 Score(
    666                     authorID: nil,
    667                     name: preferences.name,
    668                     color: preferences.color,
    669                     filledCount: counts[nil] ?? 0
    670                 )
    671             ]
    672         } else {
    673             rosterScores = entries.map { entry in
    674                 Score(
    675                     authorID: entry.authorID,
    676                     name: entry.name,
    677                     color: entry.color,
    678                     filledCount: counts[entry.authorID] ?? 0
    679                 )
    680             }
    681         }
    682 
    683         let extraScores = counts.compactMap { authorID, count -> Score? in
    684             if let authorID, rosterAuthorIDs.contains(authorID) {
    685                 return nil
    686             }
    687             if authorID == nil && usesLocalFallback {
    688                 return nil
    689             }
    690             if let authorID, let entry = entryByAuthorID[authorID] {
    691                 return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count)
    692             }
    693             if authorID == nil {
    694                 // A `nil` author key only arises with remote players present
    695                 // (see `normalizedAuthorID`): an authorless square, e.g. a cell
    696                 // sealed to the solution at completion before its author's
    697                 // letter arrived. It belongs to no player, so drop it rather
    698                 // than tallying an "Unattributed" entry.
    699                 return nil
    700             }
    701             return Score(authorID: authorID, name: "Player", color: nil, filledCount: count)
    702         }
    703 
    704         return (rosterScores + extraScores)
    705             .sorted {
    706                 if $0.filledCount != $1.filledCount { return $0.filledCount > $1.filledCount }
    707                 return $0.name < $1.name
    708             }
    709     }
    710 
    711     private func normalizedAuthorID(_ authorID: String?) -> String? {
    712         guard let authorID else {
    713             return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID
    714         }
    715         return authorID
    716     }
    717 
    718     @ViewBuilder
    719     var body: some View {
    720         switch layout {
    721         case .vertical:
    722             verticalBody
    723         case .horizontal:
    724             horizontalBody
    725         }
    726     }
    727 
    728     private var verticalBody: some View {
    729         VStack(alignment: .leading, spacing: 12) {
    730             Text("Players")
    731                 .font(.headline)
    732 
    733             VStack(alignment: .leading, spacing: 6) {
    734                 ForEach(scores) { score in
    735                     scoreRow(score)
    736                 }
    737 
    738                 Text(progressText)
    739                     .font(.footnote)
    740                     .foregroundStyle(.secondary)
    741                     .padding(.top, 10)
    742                     .frame(maxWidth: .infinity, alignment: .center)
    743             }
    744         }
    745         .padding(.horizontal, 18)
    746         .padding(.vertical, 14)
    747         .frame(maxWidth: .infinity, alignment: .leading)
    748     }
    749 
    750     private var chipFlow: some View {
    751         FlowLayout(spacing: 18, lineSpacing: 8) {
    752             ForEach(scores) { score in
    753                 scoreChip(score)
    754             }
    755         }
    756         .padding(.horizontal, 18)
    757         .padding(.vertical, 4)
    758     }
    759 
    760     private var horizontalBody: some View {
    761         // A titled "Players" section mirroring the iPad side panel
    762         // (verticalBody). It sizes to its content and sits top-anchored
    763         // in a ScrollView, so it reads as a deliberate header section
    764         // rather than a stray chip, and scrolls when there are enough
    765         // players to overflow the band — no centring tricks required.
    766         ScrollView(.vertical, showsIndicators: false) {
    767             VStack(spacing: 6) {
    768                 Text("Players")
    769                     .font(.subheadline.weight(.semibold))
    770                 chipFlow
    771             }
    772             .frame(maxWidth: .infinity)
    773             .padding(.vertical, 8)
    774         }
    775         .frame(height: horizontalHeaderHeight)
    776     }
    777 
    778     private func scoreChip(_ score: Score) -> some View {
    779         HStack(spacing: 6) {
    780             Circle()
    781                 .fill(score.color?.tint ?? Color.secondary)
    782                 .frame(width: 8, height: 8)
    783             Text(score.name)
    784                 .font(.subheadline)
    785                 .lineLimit(1)
    786             Text("\(score.filledCount)")
    787                 .font(.subheadline.monospacedDigit().weight(.semibold))
    788         }
    789         .accessibilityElement(children: .combine)
    790     }
    791 
    792     private func scoreRow(_ score: Score) -> some View {
    793         HStack(spacing: 8) {
    794             Circle()
    795                 .fill(score.color?.tint ?? Color.secondary)
    796                 .frame(width: 8, height: 8)
    797             Text(score.name)
    798                 .font(.subheadline)
    799                 .lineLimit(1)
    800             Spacer(minLength: 8)
    801             Text("\(score.filledCount)")
    802                 .font(.subheadline.monospacedDigit().weight(.semibold))
    803         }
    804         .accessibilityElement(children: .combine)
    805     }
    806 }
    807 
    808 private struct PuzzleToolbarModifier: ViewModifier {
    809     let session: PlayerSession
    810     let roster: PlayerRoster
    811     let shareController: ShareController?
    812     let isSolved: Bool
    813     let canResign: Bool
    814     let canDelete: Bool
    815     @Binding var isRenaming: Bool
    816     @Binding var renameDraft: String
    817     @Binding var isConfirmingResign: Bool
    818     @Binding var isConfirmingDelete: Bool
    819     @Binding var isConfirmingLeave: Bool
    820     @Binding var isConfirmingReveal: Bool
    821     @Binding var pendingRevealScope: RevealScope
    822     @Binding var isShowingShareSheet: Bool
    823     @Environment(PlayerPreferences.self) private var preferences
    824     @AppStorage("debugMode") private var debugMode = false
    825 
    826     func body(content: Content) -> some View {
    827         content.toolbar {
    828             ToolbarItemGroup(placement: .topBarTrailing) {
    829                 pencilButton
    830                 entryMenu
    831                 hintsMenu
    832                 playersMenu
    833             }
    834         }
    835     }
    836 
    837     private func swatchImage(for color: PlayerColor) -> Image {
    838         let tint = UIColor(color.tint)
    839         let base = UIImage(systemName: "circle.fill") ?? UIImage()
    840         return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal))
    841     }
    842 
    843     private var pencilButton: some View {
    844         Button {
    845             session.togglePencil()
    846         } label: {
    847             Image(systemName: "pencil")
    848                 .foregroundStyle(pencilButtonForeground)
    849                 .padding(6)
    850                 .glassEffect(
    851                     !isSolved && session.isPencilMode
    852                         ? .regular.tint(preferences.color.tint)
    853                         : .identity,
    854                     in: Circle()
    855                 )
    856         }
    857         .accessibilityLabel("Pencil")
    858         .disabled(isSolved)
    859     }
    860 
    861     private var pencilButtonForeground: Color {
    862         if isSolved {
    863             return .secondary
    864         }
    865         return session.isPencilMode ? .white : .primary
    866     }
    867 
    868     private var entryMenu: some View {
    869         Menu {
    870             Section {
    871                 Button("Undo Move") { session.undo() }
    872                     .disabled(!session.canUndo)
    873                 Button("Redo Move") { session.redo() }
    874                     .disabled(!session.canRedo)
    875             }
    876 
    877             Section {
    878                 Button("Enter Rebus") { session.startRebus() }
    879                 Button("Toggle Direction") { session.toggleDirection() }
    880             }
    881 
    882             if debugMode {
    883                 Section {
    884                     NavigationLink {
    885                         DiagnosticsView()
    886                     } label: {
    887                         Text("Diagnostics Log")
    888                     }
    889                 }
    890             }
    891 
    892             Section {
    893                 Button("Clear Word") { session.clearCurrentWord() }
    894                 Button("Clear Puzzle", role: .destructive) { session.clearPuzzle() }
    895             }
    896         } label: {
    897             Label("Entry", systemImage: "squareshape.split.2x2")
    898         }
    899         .disabled(isSolved)
    900     }
    901 
    902     private var hintsMenu: some View {
    903         Menu {
    904             Section {
    905                 Button("Check Square") { session.checkSquare() }
    906                 Button("Check Word") { session.checkCurrentWord() }
    907                 Button("Check Puzzle") { session.checkPuzzle() }
    908             }
    909             Section {
    910                 Button("Reveal Square") { confirmReveal(.square) }
    911                 Button("Reveal Word") { confirmReveal(.word) }
    912                 Button("Reveal Puzzle") { confirmReveal(.puzzle) }
    913             }
    914         } label: {
    915             Label("Hints", systemImage: "lightbulb")
    916         }
    917         .disabled(isSolved)
    918     }
    919 
    920     private func confirmReveal(_ scope: RevealScope) {
    921         pendingRevealScope = scope
    922         isConfirmingReveal = true
    923     }
    924 
    925     private var playersMenu: some View {
    926         Menu {
    927             playerRosterSection
    928             playerPreferencesSection
    929             shareSection
    930             puzzleDestructiveSection
    931         } label: {
    932             Label("Players", systemImage: "person.2")
    933         }
    934         .disabled(isSolved)
    935     }
    936 
    937     @ViewBuilder
    938     private var playerRosterSection: some View {
    939         Section {
    940             if !roster.entries.isEmpty {
    941                 ForEach(roster.entries) { entry in
    942                     Button {} label: {
    943                         Label {
    944                             Text(entry.isLocal ? "\(entry.name) (you)" : entry.name)
    945                         } icon: {
    946                             swatchImage(for: entry.color)
    947                         }
    948                     }
    949                     .disabled(true)
    950                 }
    951             } else {
    952                 Button {} label: {
    953                     Label {
    954                         Text(preferences.name)
    955                     } icon: {
    956                         swatchImage(for: preferences.color)
    957                     }
    958                 }
    959                 .disabled(true)
    960             }
    961         }
    962     }
    963 
    964     private var playerPreferencesSection: some View {
    965         Section {
    966             Menu("Change Colour") {
    967                 ForEach(PlayerColor.palette) { color in
    968                     Button {
    969                         preferences.color = color
    970                         // Friend colours are derived with the local user's
    971                         // colour reserved, so refreshing re-derives and bumps
    972                         // any friend that now collides with the new choice.
    973                         Task { await roster.refresh() }
    974                     } label: {
    975                         Label {
    976                             Text(color.id == preferences.colorID ? "\(color.name)  ✓" : color.name)
    977                         } icon: {
    978                             swatchImage(for: color)
    979                         }
    980                     }
    981                 }
    982             }
    983 
    984             Button("Change Name") {
    985                 renameDraft = preferences.name
    986                 isRenaming = true
    987             }
    988         }
    989     }
    990 
    991     @ViewBuilder
    992     private var shareSection: some View {
    993         if shareController != nil {
    994             Section {
    995                 Button {
    996                     isShowingShareSheet = true
    997                 } label: {
    998                     Text("Share Game")
    999                 }
   1000                 .disabled(!session.mutator.isOwned)
   1001             }
   1002         }
   1003     }
   1004 
   1005     private var puzzleDestructiveSection: some View {
   1006         Section {
   1007             Button("Resign Game", role: .destructive) {
   1008                 isConfirmingResign = true
   1009             }
   1010             .disabled(isSolved || !canResign)
   1011 
   1012             if session.mutator.isShared && !session.mutator.isOwned {
   1013                 Button("Leave Game", role: .destructive) {
   1014                     isConfirmingLeave = true
   1015                 }
   1016                 .disabled(shareController == nil)
   1017             } else {
   1018                 Button("Delete Game", role: .destructive) {
   1019                     isConfirmingDelete = true
   1020                 }
   1021                 .disabled(!canDelete)
   1022             }
   1023         }
   1024     }
   1025 }
   1026 
   1027 private struct PuzzleLifecycleModifier: ViewModifier {
   1028     let session: PlayerSession
   1029     let roster: PlayerRoster
   1030     @Binding var hasSolved: Bool
   1031     let onCompletionEvent: (PlayerSession.CompletionEvent) -> Void
   1032     let onSolvedOnAppear: () -> Void
   1033 
   1034     func body(content: Content) -> some View {
   1035         content
   1036             .task {
   1037                 await roster.refresh()
   1038             }
   1039             .onAppear {
   1040                 if session.game.completionState == .solved {
   1041                     hasSolved = true
   1042                     onSolvedOnAppear()
   1043                 }
   1044             }
   1045             .onChange(of: session.completionEvent) { _, newValue in
   1046                 guard let newValue else { return }
   1047                 onCompletionEvent(newValue)
   1048             }
   1049     }
   1050 }
   1051 
   1052 private struct PuzzlePresentationModifier: ViewModifier {
   1053     let session: PlayerSession
   1054     let shareController: ShareController?
   1055     @Binding var isRenaming: Bool
   1056     @Binding var renameDraft: String
   1057     @Binding var showErrorsAlert: Bool
   1058     @Binding var isConfirmingResign: Bool
   1059     @Binding var isConfirmingDelete: Bool
   1060     @Binding var isConfirmingLeave: Bool
   1061     @Binding var isConfirmingReveal: Bool
   1062     @Binding var pendingRevealScope: RevealScope
   1063     @Binding var leaveError: String?
   1064     @Binding var destructiveActionError: String?
   1065     @Binding var isShowingShareSheet: Bool
   1066     let performResign: () -> Void
   1067     let performDelete: () -> Void
   1068     let leaveSharedGame: () async -> Void
   1069     @Environment(PlayerPreferences.self) private var preferences
   1070 
   1071     func body(content: Content) -> some View {
   1072         content
   1073             .alert("Not Quite Right", isPresented: $showErrorsAlert) {
   1074                 Button("OK", role: .cancel) {}
   1075             } message: {
   1076                 Text("One or more squares are incorrect.")
   1077             }
   1078             .alert("Resign Puzzle?", isPresented: $isConfirmingResign) {
   1079                 Button("Resign", role: .destructive) {
   1080                     performResign()
   1081                 }
   1082                 Button("Cancel", role: .cancel) {}
   1083             } message: {
   1084                 Text("This will reveal the puzzle and mark it complete.")
   1085             }
   1086             .alert("Delete Puzzle?", isPresented: $isConfirmingDelete) {
   1087                 Button("Delete", role: .destructive) {
   1088                     performDelete()
   1089                 }
   1090                 Button("Cancel", role: .cancel) {}
   1091             } message: {
   1092                 deleteConfirmationMessage
   1093             }
   1094             .alert("Leave Puzzle?", isPresented: $isConfirmingLeave) {
   1095                 Button("Leave", role: .destructive) {
   1096                     Task { await leaveSharedGame() }
   1097                 }
   1098                 Button("Cancel", role: .cancel) {}
   1099             } message: {
   1100                 Text("You will lose access to \"\(session.puzzle.title)\".")
   1101             }
   1102             .alert(pendingRevealScope.title, isPresented: $isConfirmingReveal) {
   1103                 Button("Reveal", role: .destructive) {
   1104                     performReveal(pendingRevealScope)
   1105                 }
   1106                 Button("Cancel", role: .cancel) {}
   1107             } message: {
   1108                 Text(pendingRevealScope.message)
   1109             }
   1110             .alert(
   1111                 "Couldn't Leave",
   1112                 isPresented: .init(
   1113                     get: { leaveError != nil },
   1114                     set: { if !$0 { leaveError = nil } }
   1115                 ),
   1116                 presenting: leaveError
   1117             ) { _ in
   1118                 Button("OK", role: .cancel) {}
   1119             } message: { message in
   1120                 Text(message)
   1121             }
   1122             .alert(
   1123                 "Couldn't Update Puzzle",
   1124                 isPresented: .init(
   1125                     get: { destructiveActionError != nil },
   1126                     set: { if !$0 { destructiveActionError = nil } }
   1127                 ),
   1128                 presenting: destructiveActionError
   1129             ) { _ in
   1130                 Button("OK", role: .cancel) {}
   1131             } message: { message in
   1132                 Text(message)
   1133             }
   1134             .alert("Change Name", isPresented: $isRenaming) {
   1135                 TextField("Name", text: $renameDraft)
   1136                     .textInputAutocapitalization(.never)
   1137                     .autocorrectionDisabled()
   1138                 Button("Cancel", role: .cancel) {}
   1139                 Button("Save") {
   1140                     let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
   1141                     if !trimmed.isEmpty {
   1142                         preferences.name = trimmed
   1143                     }
   1144                 }
   1145                 .keyboardShortcut(.defaultAction)
   1146             } message: {
   1147                 Text("Enter the name other players will see.")
   1148             }
   1149             .sheet(isPresented: $isShowingShareSheet) {
   1150                 if let shareController {
   1151                     GameShareSheet(
   1152                         gameID: session.mutator.gameID,
   1153                         title: session.puzzle.title,
   1154                         shareController: shareController
   1155                     )
   1156                 }
   1157             }
   1158     }
   1159 
   1160     private func performReveal(_ scope: RevealScope) {
   1161         switch scope {
   1162         case .square: session.revealSquare()
   1163         case .word: session.revealCurrentWord()
   1164         case .puzzle: session.revealPuzzle()
   1165         }
   1166     }
   1167 
   1168     private var deleteConfirmationMessage: Text {
   1169         if session.mutator.isOwned && session.mutator.isShared {
   1170             Text("This will permanently delete \"\(session.puzzle.title)\" from iCloud for everyone.")
   1171         } else {
   1172             Text("This will permanently delete \"\(session.puzzle.title)\" and all progress.")
   1173         }
   1174     }
   1175 }
   1176 
   1177 /// Swipeable header that sits above the grid. Page 1 is the title, the
   1178 /// last page is the credits, and on iPhone a scoreboard page sits between
   1179 /// them (iPad shows the scoreboard permanently in the side panel, so it is
   1180 /// omitted here). A fixed height is required because `.page` style fills
   1181 /// its container rather than sizing to content.
   1182 private struct PuzzleHeader: View {
   1183     @Bindable var session: PlayerSession
   1184     let roster: PlayerRoster
   1185     let title: String
   1186     let subtitle: String?
   1187     let showsScoreboard: Bool
   1188     let gameID: UUID
   1189     let isEngagementLive: Bool
   1190     /// The shared open "arm" beat, owned by `PuzzleView` so the banner and the
   1191     /// grid's "changed while you were away" borders reveal together. Until it
   1192     /// flips (a moment after open), the title is the only thing on screen;
   1193     /// then banner posts — including a session summary that arrived during the
   1194     /// hold — animate in.
   1195     let isArmed: Bool
   1196     @Environment(AnnouncementCenter.self) private var announcements
   1197     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
   1198     @State private var selection: Page = .title
   1199 
   1200     private enum Page: Hashable {
   1201         case title
   1202         case scoreboard
   1203         case credits
   1204     }
   1205 
   1206     /// Reconstructed as "© <year> <publisher>" from the puzzle's date and
   1207     /// publisher, falling back to whatever pieces exist, and finally to the
   1208     /// raw copyright string parsed from the source.
   1209     private var copyrightLine: String? {
   1210         let year = session.puzzle.date.map {
   1211             Calendar.current.component(.year, from: $0)
   1212         }
   1213         switch (year, session.puzzle.publisher) {
   1214         case let (year?, publisher?):
   1215             return "© \(year) \(publisher)"
   1216         case let (year?, nil):
   1217             return "© \(year)"
   1218         case let (nil, publisher?):
   1219             return "© \(publisher)"
   1220         case (nil, nil):
   1221             return session.puzzle.copyright
   1222         }
   1223     }
   1224 
   1225     private var hasCredits: Bool {
   1226         session.puzzle.author != nil || copyrightLine != nil
   1227     }
   1228 
   1229     private var pages: [Page] {
   1230         var result: [Page] = [.title]
   1231         if showsScoreboard { result.append(.scoreboard) }
   1232         if hasCredits { result.append(.credits) }
   1233         return result
   1234     }
   1235 
   1236     /// Above the default text size the clue bar below the grid grows to fit
   1237     /// the (must-read) clue, squeezing the grid. The title/scoreboard/credits
   1238     /// shown here are the least important text on screen, so the header yields
   1239     /// its own height as type scales up — shedding a few points per step down
   1240     /// to a legible-enough floor — and hands that space back to the grid. The
   1241     /// text inside just truncates within the smaller box. At or below the
   1242     /// default size the comfortable full height is preserved.
   1243     private var headerHeight: CGFloat {
   1244         let sizes = DynamicTypeSize.allCases
   1245         guard let current = sizes.firstIndex(of: dynamicTypeSize),
   1246               let baseline = sizes.firstIndex(of: .large)
   1247         else { return 80 }
   1248         let stepsAboveDefault = max(0, current - baseline)
   1249         return max(48, 80 - CGFloat(stepsAboveDefault) * 6)
   1250     }
   1251 
   1252     var body: some View {
   1253         let visibleAnnouncement = isArmed
   1254             ? announcements.current(forGame: gameID)
   1255             : nil
   1256         Group {
   1257             // Title/scoreboard/credits is the baseline — it renders
   1258             // immediately on open and stays put. After the open beat we
   1259             // start reacting to announcements: the banner slides down
   1260             // over the title and slides back out on dismissal. Both
   1261             // branches occupy the same fixed-height frame so the grid
   1262             // below doesn't jump.
   1263             if let announcement = visibleAnnouncement {
   1264                 AnnouncementBanner(
   1265                     announcement: announcement,
   1266                     fillsAvailableHeight: true
   1267                 ) {
   1268                     announcements.dismiss(id: announcement.id)
   1269                 }
   1270                 .padding(.horizontal, 12)
   1271                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
   1272                 .transition(.move(edge: .bottom).combined(with: .opacity))
   1273             } else {
   1274                 headerPages
   1275                     .transition(.move(edge: .bottom).combined(with: .opacity))
   1276             }
   1277         }
   1278         .frame(height: headerHeight)
   1279         .padding(.bottom, 14)
   1280         .animation(.easeInOut(duration: 0.3), value: visibleAnnouncement)
   1281         .animation(.easeInOut(duration: 0.2), value: isEngagementLive)
   1282     }
   1283 
   1284     private var headerPages: some View {
   1285         VStack(spacing: 10) {
   1286             TabView(selection: $selection) {
   1287                 ForEach(pages, id: \.self) { page in
   1288                     pageContent(page)
   1289                         .tag(page)
   1290                 }
   1291             }
   1292             .tabViewStyle(.page(indexDisplayMode: .never))
   1293 
   1294             if pages.count > 1 {
   1295                 HStack(spacing: 6) {
   1296                     ForEach(pages, id: \.self) { page in
   1297                         Circle()
   1298                             .fill(page == selection ? Color.secondary : Color.secondary.opacity(0.3))
   1299                             .frame(width: 6, height: 6)
   1300                     }
   1301                 }
   1302                 .animation(.easeInOut(duration: 0.2), value: selection)
   1303             }
   1304         }
   1305     }
   1306 
   1307     @ViewBuilder
   1308     private func pageContent(_ page: Page) -> some View {
   1309         switch page {
   1310         case .title:
   1311             PuzzleTitle(title: title, subtitle: subtitle, isEngagementLive: isEngagementLive)
   1312                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
   1313         case .scoreboard:
   1314             PuzzleScoreboard(session: session, roster: roster, layout: .horizontal)
   1315                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
   1316         case .credits:
   1317             PuzzleCredits(author: session.puzzle.author, copyright: copyrightLine)
   1318                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
   1319         }
   1320     }
   1321 }
   1322 
   1323 private struct PuzzleTitle: View {
   1324     let title: String
   1325     let subtitle: String?
   1326     let isEngagementLive: Bool
   1327     @State private var showsEngagementIcon = false
   1328 
   1329     var body: some View {
   1330         VStack(spacing: 2) {
   1331             Text(title)
   1332                 .font(.headline)
   1333                 .lineLimit(2)
   1334                 .overlay(alignment: .trailing) {
   1335                     engagementIcon
   1336                         .offset(x: 28)
   1337                         .opacity(showsEngagementIcon ? 1 : 0)
   1338                         .accessibilityLabel("Engagement live")
   1339                         .accessibilityHidden(!showsEngagementIcon)
   1340                 }
   1341             if let subtitle {
   1342                 Text(subtitle)
   1343                     .font(.subheadline)
   1344                     .foregroundStyle(.secondary)
   1345                     .lineLimit(1)
   1346             }
   1347         }
   1348         .multilineTextAlignment(.center)
   1349         .frame(maxWidth: .infinity)
   1350         .padding(.horizontal)
   1351         .animation(.easeInOut(duration: 0.2), value: showsEngagementIcon)
   1352         .onAppear {
   1353             showsEngagementIcon = isEngagementLive
   1354         }
   1355         .onChange(of: isEngagementLive) { _, isLive in
   1356             withAnimation(.easeInOut(duration: 0.2)) {
   1357                 showsEngagementIcon = isLive
   1358             }
   1359         }
   1360     }
   1361 
   1362     private var engagementIcon: some View {
   1363         Image(systemName: "bolt.circle")
   1364             .font(.headline)
   1365             .foregroundStyle(.green)
   1366             .symbolRenderingMode(.monochrome)
   1367     }
   1368 }
   1369 
   1370 private struct PuzzleCredits: View {
   1371     let author: String?
   1372     let copyright: String?
   1373 
   1374     var body: some View {
   1375         VStack(spacing: 2) {
   1376             if let author, !author.isEmpty {
   1377                 Text("By \(author)")
   1378                     .font(.subheadline)
   1379                     .lineLimit(2)
   1380             }
   1381             if let copyright {
   1382                 Text(copyright)
   1383                     .font(.footnote)
   1384                     .foregroundStyle(.secondary)
   1385                     .lineLimit(2)
   1386             }
   1387         }
   1388         .multilineTextAlignment(.center)
   1389         .frame(maxWidth: .infinity)
   1390         .padding(.horizontal)
   1391     }
   1392 }
   1393 
   1394 private struct ClueKey: Hashable {
   1395     let direction: Puzzle.Direction
   1396     let number: Int
   1397 }
   1398 
   1399 private struct ReplayClueTarget {
   1400     let position: GridPosition
   1401     let direction: Puzzle.Direction?
   1402 }
   1403 
   1404 private struct ClueBarSlot: View {
   1405     @Bindable var session: PlayerSession
   1406     let replayFrame: ReplayFrame?
   1407 
   1408     private var replayClueTarget: ReplayClueTarget? {
   1409         guard let cursor = replayFrame?.cursor else { return nil }
   1410         return ReplayClueTarget(position: cursor, direction: replayFrame?.cursorDirection)
   1411     }
   1412 
   1413     var body: some View {
   1414         ZStack(alignment: .bottom) {
   1415             ClueBarReservation()
   1416 
   1417             ClueBar(session: session, replayClueTarget: replayClueTarget)
   1418         }
   1419     }
   1420 }
   1421 
   1422 private struct ClueBarReservation: View {
   1423     var body: some View {
   1424         ClueBarContent(
   1425             label: "99 Across",
   1426             clueText: "Clue reservation",
   1427             reservesClueSpace: true
   1428         )
   1429             .opacity(0)
   1430             .accessibilityHidden(true)
   1431             .allowsHitTesting(false)
   1432     }
   1433 }
   1434 
   1435 private struct ClueBarContent: View {
   1436     let label: String
   1437     let clueText: String
   1438     var reservesClueSpace = false
   1439     var currentKey: ClueKey?
   1440     var slideEdge: Edge = .trailing
   1441     var onPrevious: (() -> Void)?
   1442     var onNext: (() -> Void)?
   1443     var onClueTap: (() -> Void)?
   1444     var onLabelTap: (() -> Void)?
   1445 
   1446     var body: some View {
   1447         HStack(alignment: .clueCenter, spacing: 8) {
   1448             ClueBarIcon(systemName: "chevron.left", action: onPrevious)
   1449 
   1450             VStack(alignment: .leading, spacing: 4) {
   1451                 Text(label)
   1452                     .font(.caption)
   1453                     .textCase(.uppercase)
   1454                     .foregroundStyle(.secondary)
   1455                     .contentShape(Rectangle())
   1456                     .highPriorityGesture(
   1457                         TapGesture()
   1458                             .onEnded {
   1459                                 onLabelTap?()
   1460                             }
   1461                     )
   1462                 ZStack(alignment: .leading) {
   1463                     clueTextView
   1464                 }
   1465                 .alignmentGuide(.clueCenter) { d in d[VerticalAlignment.center] }
   1466                 .frame(maxWidth: .infinity, alignment: .leading)
   1467                 .clipped()
   1468             }
   1469             .contentShape(Rectangle())
   1470             .onTapGesture {
   1471                 onClueTap?()
   1472             }
   1473 
   1474             ClueBarIcon(systemName: "chevron.right", action: onNext)
   1475         }
   1476         .padding(.horizontal, 8)
   1477         .padding(.top, 12)
   1478         .padding(.bottom, 6)
   1479     }
   1480 
   1481     @ViewBuilder
   1482     private var clueTextView: some View {
   1483         baseClueText
   1484             .id(currentKey)
   1485             .transition(.asymmetric(
   1486                 insertion: .move(edge: slideEdge),
   1487                 removal: .move(edge: slideEdge == .trailing ? .leading : .trailing)
   1488             ))
   1489     }
   1490 
   1491     private var baseClueText: some View {
   1492         Text(clueText)
   1493             .font(.headline)
   1494             .lineLimit(2, reservesSpace: reservesClueSpace)
   1495             .multilineTextAlignment(.leading)
   1496             .frame(maxWidth: .infinity, alignment: .leading)
   1497     }
   1498 }
   1499 
   1500 private struct ClueBarIcon: View {
   1501     let systemName: String
   1502     var action: (() -> Void)?
   1503 
   1504     var body: some View {
   1505         if let action {
   1506             Button(action: action) {
   1507                 icon
   1508             }
   1509             .buttonStyle(.plain)
   1510         } else {
   1511             icon
   1512         }
   1513     }
   1514 
   1515     private var icon: some View {
   1516         Image(systemName: systemName)
   1517             .font(.title3.weight(.semibold))
   1518             .frame(width: 44, height: 44)
   1519             .contentShape(Rectangle())
   1520     }
   1521 }
   1522 
   1523 private struct ClueBar: View {
   1524     @Bindable var session: PlayerSession
   1525     let replayClueTarget: ReplayClueTarget?
   1526     @Environment(PlayerPreferences.self) private var preferences
   1527     @Environment(\.colorScheme) private var colorScheme
   1528     @State private var slideEdge: Edge = .trailing
   1529     @State private var isShowingClueList = false
   1530 
   1531     private var backgroundColor: Color {
   1532         preferences.color.clueBarFill(dark: colorScheme == .dark)
   1533     }
   1534 
   1535     var body: some View {
   1536         let display = replayClueDisplay ?? liveClueDisplay
   1537         let isShowingReplayClue = replayClueDisplay != nil
   1538 
   1539         ClueBarContent(
   1540             label: label(for: display.clue, direction: display.direction),
   1541             clueText: display.clue?.text ?? "—",
   1542             currentKey: display.currentKey,
   1543             slideEdge: slideEdge,
   1544             onPrevious: isShowingReplayClue ? nil : {
   1545                 slideEdge = .leading
   1546                 session.goToPreviousClue()
   1547             },
   1548             onNext: isShowingReplayClue ? nil : {
   1549                 slideEdge = .trailing
   1550                 session.goToNextClue()
   1551             },
   1552             onClueTap: isShowingReplayClue ? nil : {
   1553                 isShowingClueList = true
   1554             },
   1555             onLabelTap: isShowingReplayClue ? nil : {
   1556                 session.toggleDirection()
   1557             }
   1558         )
   1559         .background(backgroundColor)
   1560         .animation(
   1561             isShowingReplayClue ? nil : .smooth(duration: 0.22),
   1562             value: display.currentKey
   1563         )
   1564         .sheet(isPresented: $isShowingClueList) {
   1565             ClueList(session: session)
   1566                 .presentationDetents([.medium, .large])
   1567                 .presentationDragIndicator(.visible)
   1568         }
   1569     }
   1570 
   1571     private var liveClueDisplay: ClueDisplay {
   1572         let clue = session.currentClue()
   1573         return ClueDisplay(clue: clue, direction: session.direction)
   1574     }
   1575 
   1576     private var replayClueDisplay: ClueDisplay? {
   1577         guard let replayClueTarget else { return nil }
   1578         let position = replayClueTarget.position
   1579         guard let direction = replayClueTarget.direction else { return nil }
   1580         return ClueDisplay(
   1581             clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction),
   1582             direction: direction
   1583         )
   1584     }
   1585 
   1586     private struct ClueDisplay {
   1587         let clue: Puzzle.Clue?
   1588         let direction: Puzzle.Direction
   1589 
   1590         var currentKey: ClueKey? {
   1591             clue.map { ClueKey(direction: direction, number: $0.number) }
   1592         }
   1593     }
   1594 
   1595     private func label(for clue: Puzzle.Clue?, direction: Puzzle.Direction) -> String {
   1596         let direction = direction == .across ? "Across" : "Down"
   1597         if let clue {
   1598             return "\(clue.number) \(direction)"
   1599         }
   1600         return direction
   1601     }
   1602 }
   1603 
   1604 private struct RebusModal: View {
   1605     let text: String
   1606 
   1607     var body: some View {
   1608         Text(text.isEmpty ? " " : text)
   1609             .font(.system(size: 32, weight: .semibold, design: .rounded))
   1610             .foregroundStyle(.primary)
   1611             .frame(maxWidth: .infinity, minHeight: 56)
   1612             .padding(.horizontal, 16)
   1613             .background(Color(.systemBackground))
   1614             .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
   1615             .padding(20)
   1616             .frame(maxWidth: .infinity)
   1617             .background(Color(.secondarySystemBackground))
   1618             .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
   1619     }
   1620 }
   1621 
   1622 private struct ControlsView<Content: View>: View {
   1623     let height: CGFloat
   1624     @ViewBuilder var content: () -> Content
   1625 
   1626     var body: some View {
   1627         VStack(spacing: 0) {
   1628             content()
   1629                 .frame(height: height)
   1630             Color(.systemGroupedBackground)
   1631         }
   1632         .background(Color(.systemGroupedBackground))
   1633         .ignoresSafeArea(edges: .bottom)
   1634     }
   1635 }
   1636 
   1637 private extension VerticalAlignment {
   1638     enum ClueCenterID: AlignmentID {
   1639         static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] }
   1640     }
   1641     static let clueCenter = VerticalAlignment(ClueCenterID.self)
   1642 }
   1643 
   1644 /// Lays subviews left-to-right, wrapping onto a new line when the next
   1645 /// subview would overflow the proposed width. Reports the wrapped
   1646 /// height for that width so a surrounding `ViewThatFits` can choose
   1647 /// between a centred (fits) and a scrolling (overflows) presentation.
   1648 private struct FlowLayout: Layout {
   1649     var spacing: CGFloat = 18
   1650     var lineSpacing: CGFloat = 8
   1651 
   1652     private struct Row {
   1653         var indices: [Int] = []
   1654         var width: CGFloat = 0
   1655         var height: CGFloat = 0
   1656     }
   1657 
   1658     private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [Row] {
   1659         var rows: [Row] = []
   1660         var current = Row()
   1661         for index in subviews.indices {
   1662             let size = subviews[index].sizeThatFits(.unspecified)
   1663             let needed = current.indices.isEmpty
   1664                 ? size.width
   1665                 : current.width + spacing + size.width
   1666             if !current.indices.isEmpty, needed > maxWidth {
   1667                 rows.append(current)
   1668                 current = Row(indices: [index], width: size.width, height: size.height)
   1669             } else {
   1670                 if !current.indices.isEmpty { current.width += spacing }
   1671                 current.indices.append(index)
   1672                 current.width += size.width
   1673                 current.height = max(current.height, size.height)
   1674             }
   1675         }
   1676         if !current.indices.isEmpty { rows.append(current) }
   1677         return rows
   1678     }
   1679 
   1680     func sizeThatFits(
   1681         proposal: ProposedViewSize,
   1682         subviews: Subviews,
   1683         cache: inout ()
   1684     ) -> CGSize {
   1685         let rows = rows(subviews, maxWidth: proposal.width ?? .infinity)
   1686         let height = rows.reduce(0) { $0 + $1.height }
   1687             + lineSpacing * CGFloat(max(0, rows.count - 1))
   1688         let widest = rows.map(\.width).max() ?? 0
   1689         return CGSize(width: proposal.width ?? widest, height: height)
   1690     }
   1691 
   1692     func placeSubviews(
   1693         in bounds: CGRect,
   1694         proposal: ProposedViewSize,
   1695         subviews: Subviews,
   1696         cache: inout ()
   1697     ) {
   1698         let rows = rows(subviews, maxWidth: bounds.width)
   1699         var y = bounds.minY
   1700         for row in rows {
   1701             // Centre each row within the available width so a short
   1702             // strip (e.g. a single player) sits in the middle.
   1703             var x = bounds.minX + max(0, (bounds.width - row.width) / 2)
   1704             for index in row.indices {
   1705                 let size = subviews[index].sizeThatFits(.unspecified)
   1706                 subviews[index].place(
   1707                     at: CGPoint(x: x, y: y),
   1708                     anchor: .topLeading,
   1709                     proposal: ProposedViewSize(size)
   1710                 )
   1711                 x += size.width + spacing
   1712             }
   1713             y += row.height + lineSpacing
   1714         }
   1715     }
   1716 }
   1717 
   1718 private struct WeightedVStack: Layout {
   1719     let weights: [CGFloat]
   1720 
   1721     func sizeThatFits(
   1722         proposal: ProposedViewSize,
   1723         subviews: Subviews,
   1724         cache: inout ()
   1725     ) -> CGSize {
   1726         CGSize(
   1727             width: proposal.width ?? 0,
   1728             height: proposal.height ?? 0
   1729         )
   1730     }
   1731 
   1732     func placeSubviews(
   1733         in bounds: CGRect,
   1734         proposal: ProposedViewSize,
   1735         subviews: Subviews,
   1736         cache: inout ()
   1737     ) {
   1738         let totalWeight = weights.reduce(0, +)
   1739         guard totalWeight > 0 else { return }
   1740 
   1741         var y = bounds.minY
   1742         for (index, subview) in subviews.enumerated() {
   1743             let weight = index < weights.count ? weights[index] : 0
   1744             let height = bounds.height * weight / totalWeight
   1745             subview.place(
   1746                 at: CGPoint(x: bounds.minX, y: y),
   1747                 anchor: .topLeading,
   1748                 proposal: ProposedViewSize(width: bounds.width, height: height)
   1749             )
   1750             y += height
   1751         }
   1752     }
   1753 }