import Foundation // MARK: - BoardAnnouncer /// **What a landing reload says out loud, and where focus goes when it lands on a hole** — /// 10-accessibility.md ▸ Live board announcements, as pure functions of the two snapshots, the /// reload's origin, and the standing banner conditions. /// /// ### Why the whole decision is a value function /// /// The rules 10 states are all *decisions*, not effects: which origins speak ("foreign changes /// announce, app-mediated echoes never do"), what one reload is allowed to say ("one polite digest /// per reload debounce … never per-file chatter"), which sentence wins when two apply, and where a /// vanished focus recovers to ("walks up then sideways"). Every one of them is checkable without a /// window, a screen reader, or a running app — so every one of them lives here, and the store keeps /// only the two thin acts a pure function cannot perform: posting the notification and moving the /// selection. `Motion` is the same shape for the same reason, on the same seam. /// /// ### The ladder, and why speech is rationed /// /// "Exactly one announcement per reload" is the whole point of the debounce — a board being /// rewritten by an agent must not turn VoiceOver into a ticker — so `speech(for:)` returns **one /// optional sentence**, chosen by precedence rather than concatenated: /// /// 1. **A standing condition that just appeared** — the read-only lock or reload breakage. 10 makes /// the live-reload-resilience banner an announced element in its own right, and a board that has /// just stopped being writable outranks any description of what changed in it. /// 2. **A bracketed operation's completion phrase** — "announce once, at completion, never their /// internal churn". A bracket's reload is the operation's result; the churn inside it is not the /// user's business and its digest would describe a tree they never saw. /// 3. **A standing condition that just cleared** — the other half of "announced when it appears and /// when it clears". Below completion because a completion phrase already implies the board is /// reading and writing again. /// 4. **A vanished focus** — the specific sentence, which beats the generic one (below). /// 5. **The board digest** — the ordinary foreign-change case. /// /// Rungs 4 and 5 are foreign-only; 1 through 3 are not, because a lock raised by the app's own /// bracketed operation is exactly the case 10 names ("including the read-only lock after a failed /// bracketed reload"), and silence there would be the app hiding its own failure. public enum BoardAnnouncer { // MARK: - A vanishing focus /// What disappeared under the focus, as the announcement's *subject*. /// /// Two cases and not three, because 10 settles the composition: when the lane itself vanished /// the announcement names the **lane**, not the card — "the implied-events-don't-steal-the- /// subject discipline of 06-history-undo.md's composer, applied to speech". So a focused card /// whose lane was deleted produces `.lane`, and `.card` is reserved for a card that vanished out /// of a lane that is still there. public enum VanishedFocus: Sendable, Equatable { case card(title: String?) case lane(title: String?, cards: Int) } /// Where focus lands after the item under it stopped existing. /// /// **Never the trash.** 10 is explicit — "never into the trash, which stays hidden — no layout /// side effects from a foreign edit" — so the two cases are the two board-side destinations and /// there is no third to spell. public enum FocusRecovery: Sendable, Equatable { /// Select this lane: the focused card's own lane when it survived, otherwise the lane now /// occupying the vanished lane's position. case lane(ItemID) /// Select nothing — the board container itself, "only when no lanes remain". case boardContainer } /// A reload's effect on focus: what to say about it, and where to put it. /// /// Both halves or neither, always: the sentence names what vanished and the recovery is where /// the user is left, and a design that produced one without the other would either move focus /// silently or describe a move that did not happen. public struct FocusOutcome: Sendable, Equatable { public var vanished: VanishedFocus? public var recovery: FocusRecovery? public init(vanished: VanishedFocus? = nil, recovery: FocusRecovery? = nil) { self.vanished = vanished self.recovery = recovery } /// Nothing happened to focus — the overwhelmingly common outcome, and the one every guard /// below falls out to. public static let survived = FocusOutcome() } /// Whether this reload pulled the ground out from under the focused item, and where focus goes /// if it did. /// /// `focused` is the *cursor*, not the set: `TransientBoardState.selectionHead` when it is still /// in the selection, else a sole selected item (`BoardStore.focusedItem`). 10 speaks of "the /// selected or VO-focused card" in the singular, and a sentence naming one card out of five is a /// worse answer than the digest. /// /// ### Three guards, each of them a rule /// /// - **The board container only.** A trash selection is not on the board, 10 keeps focus out of /// the trash on principle, and there is no lane to recover to from in there. /// - **The focus must actually be gone.** A reload that reordered the board around a surviving /// card has nothing to announce and nothing to recover. /// - **Survivors veto the recovery.** If any other selected item is still there, the selection /// already sits somewhere the user chose; moving it to a lane would be the reload editing a /// live selection, and 02-architecture.md's re-resolution rule ("vanished members leave /// silently, no substitute is invented") stands untouched for that case. Recovery is the /// *emptied* selection's answer, which is exactly when nothing else can be. /// /// The last guard is why this can be layered on `ItemReferenceSet.resolved(against:)` rather /// than replacing it: the set rule still runs first and still invents nothing; this decides what /// to do about the hole it leaves, which is 10's question and not the set's. public static func focusOutcome( old: BoardModel, new: BoardModel, selection: ItemReferenceSet, focused: ItemID? ) -> FocusOutcome { guard selection.container == .board, let focused, selection.ids.contains(focused) else { return .survived } let universe = ItemContainer.board.ids(in: new) guard !universe.contains(focused) else { return .survived } guard selection.ids.isDisjoint(with: universe) else { return .survived } // A focused *lane* that vanished is already the lane case — no card to be displaced by. if let lane = old.lanes.first(where: { $0.id == focused }) { return FocusOutcome( vanished: .lane(title: lane.title.value, cards: lane.cards.count), recovery: successorLane(of: lane.id, old: old, new: new) ) } guard let home = old.lanes.first(where: { lane in lane.cards.contains { $0.id == focused } }), let card = home.cards.first(where: { $0.id == focused }) else { // The focus was not on the board's old side either — a stale reference no reload can // describe. Silence and no movement is the honest answer. return .survived } if new.lanes.contains(where: { $0.id == home.id }) { return FocusOutcome(vanished: .card(title: card.title.value), recovery: .lane(home.id)) } return FocusOutcome( vanished: .lane(title: home.title.value, cards: home.cards.count), recovery: successorLane(of: home.id, old: old, new: new) ) } /// **Walk up, then sideways** (10, settled): the lane now occupying the vanished lane's /// position — the next lane by `order`, else the previous one — and the board container only /// when no lanes remain. /// /// This is 04-interactions.md's ⌫-successor pattern applied to external change, and it is /// computed against the **old** lane list because that is the only place the vanished lane still /// has a position. `BoardModel.lanes` is already in display order (`Ranks.sortedForDisplay`), so /// "next by `order`" is the next surviving element and needs no sort of its own. /// /// The final fallback is `new.lanes.first` rather than the board container: 10 reserves the /// container for "when no lanes remain", so a reload that replaced every lane with different /// ones — a checkout, a template applied by an agent — lands on the leftmost of what is actually /// there rather than nowhere. static func successorLane(of vanished: ItemID, old: BoardModel, new: BoardModel) -> FocusRecovery { let surviving = Set(new.lanes.map(\.id)) guard let position = old.lanes.firstIndex(where: { $0.id == vanished }) else { return new.lanes.first.map { .lane($0.id) } ?? .boardContainer } for lane in old.lanes[old.lanes.index(after: position)...] where surviving.contains(lane.id) { return .lane(lane.id) } for lane in old.lanes[.. String? { if let raised = raisedCondition(facts) { return raised } if facts.endsBracketedOperation { return facts.completion } if let cleared = clearedCondition(facts) { return cleared } // **App-mediated echoes never do.** The user's own action already had its feedback — the // gesture, the animation, the menu it came from — and narrating it back is the app talking // over the user. A reconciling sweep is silent for the neighbouring reason: it makes no claim // that anything changed (a wake, an activation, a missed-events flag), and a board that // announced a digest every time the app came forward would be announcing the *absence* of an // event, the way `Motion` refuses to animate one. guard facts.origin == .foreign else { return nil } if let vanished = facts.vanishedFocus { return AccessibilityPhrases.vanishedFocus(vanished) } return AccessibilityPhrases.boardChanged(facts.diff) } /// A standing condition that was not there before and is now — announced with the banner row's /// own label, so the sentence a VoiceOver user hears is the sentence the strip is showing. private static func raisedCondition(_ facts: ReloadFacts) -> String? { if let lock = facts.lockAfter, lock != facts.lockBefore { return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: lock)) } if let breakage = facts.breakageAfter, breakage != facts.breakageBefore { return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage)) } return nil } /// A standing condition that has healed. The lock leads when both clear at once: it is the one /// that was refusing writes, and "you can edit again" subsumes "it is loading again". private static func clearedCondition(_ facts: ReloadFacts) -> String? { if facts.lockBefore != nil, facts.lockAfter == nil { return AccessibilityPhrases.readOnlyLockCleared } if facts.breakageBefore != nil, facts.breakageAfter == nil { return AccessibilityPhrases.reloadBreakageCleared } return nil } }