Three ruled behavior changes (DESIGN/10, resolution session 2026-07-29): - The board-change digest covers the trash while View > Show Trash is on: BoardDiff.between gains includingTrash, keying its card index by ItemPath so foreign purges, restores, and Empty Trash join the digest; crossings of the trash boundary still read deleted/restored, never moved, on both sides of the toggle. BoardStore.land passes the store's own isTrashVisible - no new injection seam. - A vanished head with surviving co-selection is still named: naming and recovery are independent axes, so BoardAnnouncer's vanished-focus rung fires on all branches while the survivors-veto now gates only the recovery half (recovery implies vanished, no longer both-or-neither). - Banner-row buttons are literal FKA Tab stops: BannerRow.controls is the row's testable button inventory, BannerRowView renders from it with .focusable() on each button; the combined VoiceOver element stays unconditional - custom actions and Tab stops are independent surfaces. 19 tests added, 2 expectations updated to the rulings. 1607 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
287 lines
16 KiB
Swift
287 lines
16 KiB
Swift
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.
|
|
///
|
|
/// **Naming and recovery are independent axes** (10-accessibility.md, ruled 2026-07-29). A
|
|
/// recovery never arrives without a sentence — describing a move without saying what caused it
|
|
/// would leave the user somewhere new for no stated reason — but a sentence *can* arrive
|
|
/// without a move, and that is exactly the surviving-co-selection case: the thing under the
|
|
/// cursor was deleted, which is what the rule exists to say, while the survivors veto the move
|
|
/// because a reload never edits a selection the user still partly holds. So the pairing is
|
|
/// "recovery implies vanished", not "both or neither".
|
|
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 naming the cursor's card out of five is
|
|
/// exactly the sentence the rule wants — naming an *arbitrary* one of the five, which is what a
|
|
/// selection with no cursor could offer, is the worse answer the digest already beats.
|
|
///
|
|
/// ### Two guards and a veto, 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 — and only the recovery** (ruled 2026-07-29). 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 (the head re-anchors within the surviving selection, no substitute is
|
|
/// invented) stands untouched for that case. The *sentence* is not the move's passenger,
|
|
/// though: "the thing under the cursor was deleted, and that is what the rule exists to say",
|
|
/// so the head is named whether or not anything survived it. Vanished non-head members stay
|
|
/// unnamed and fall to the digest's counts, which is what makes this the head's rule rather
|
|
/// than the set's.
|
|
///
|
|
/// The veto 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
|
|
/// say about the hole it leaves and, when the hole is the whole selection, what to do about it.
|
|
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 }
|
|
|
|
// Computed once and applied to every branch below: the three destinations differ, the veto
|
|
// over all of them does not.
|
|
let survivors = !selection.ids.isDisjoint(with: universe)
|
|
func recovery(_ destination: @autoclosure () -> FocusRecovery) -> FocusRecovery? {
|
|
survivors ? nil : destination()
|
|
}
|
|
|
|
// 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: 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: recovery(.lane(home.id))
|
|
)
|
|
}
|
|
return FocusOutcome(
|
|
vanished: .lane(title: home.title.value, cards: home.cards.count),
|
|
recovery: 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[..<position].reversed() where surviving.contains(lane.id) {
|
|
return .lane(lane.id)
|
|
}
|
|
return new.lanes.first.map { .lane($0.id) } ?? .boardContainer
|
|
}
|
|
|
|
// MARK: - The reload's one sentence
|
|
|
|
/// Everything one landed reload knows that could bear on what it says — gathered into a value so
|
|
/// the decision below reads as a ladder rather than as a nine-argument call, and so a test can
|
|
/// state one fact and default the rest.
|
|
public struct ReloadFacts: Sendable, Equatable {
|
|
|
|
/// The reload's provenance — the classification 10's first rule is stated in terms of.
|
|
///
|
|
/// **The merge is lossy and that is accepted** (`WatchOrigin.merged`): a foreign edit landing
|
|
/// inside an app-mediated span arrives labeled `.appMediated` and stays silent. `Motion`
|
|
/// documents the same blur on the same seam and for the same reason — the alternative is
|
|
/// splitting deliveries, which `FolderWatcher.schedule` rejects for the coalescing it costs.
|
|
public var origin: WatchOrigin
|
|
|
|
/// Whether this is the reload that closes a bracketed wholesale operation.
|
|
public var endsBracketedOperation = false
|
|
|
|
/// What that operation wants said when it lands — "Pulled 3 commits", "Switched to branch
|
|
/// 'redesign'". `nil` for a bracket whose completion is not worth speech, which is every
|
|
/// base-edition bracket today (see `BoardStore.performWholesale(announcing:_:)`).
|
|
public var completion: String?
|
|
|
|
/// The snapshot comparison, empty by default so a test about origins need not build one.
|
|
public var diff = BoardDiff()
|
|
|
|
/// What went out from under the cursor, if anything (`focusOutcome(old:new:selection:focused:)`).
|
|
public var vanishedFocus: VanishedFocus?
|
|
|
|
/// The read-only lock before and after this reload landed. Compared rather than merely
|
|
/// presence-checked so a lock whose *cause* changed re-announces: "this board's folder is
|
|
/// gone" replacing "this board's location can't be written to" is news.
|
|
public var lockBefore: ReadOnlyLockReason?
|
|
public var lockAfter: ReadOnlyLockReason?
|
|
|
|
/// The reload-breakage condition before and after, same rule: a different file failing to
|
|
/// load is a different sentence and is worth saying.
|
|
public var breakageBefore: BoardLoadError?
|
|
public var breakageAfter: BoardLoadError?
|
|
|
|
public init(origin: WatchOrigin) {
|
|
self.origin = origin
|
|
}
|
|
}
|
|
|
|
/// The ladder — see the type's doc comment for the five rungs and why they are in that order.
|
|
/// `nil` is the answer for the overwhelming majority of reloads, which is the design's intent:
|
|
/// the board is quiet unless something happened that a user who cannot see it needs told.
|
|
public static func speech(for facts: ReloadFacts) -> 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
|
|
}
|
|
}
|