Implement live accessibility announcements

The board speaks when files change under the user, per DESIGN/10 § Live
board announcements. BoardDiff is the pure snapshot summarizer (identity
sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so
pro-m1's semantic commit engine can build on it; edited = rendered
content only, moved beats edited, implied events don't steal the
subject). BoardAnnouncer is the decision seam: focusOutcome computes the
vanishing-focus sentence and the walk-up-then-sideways recovery (next
lane by order, else previous, board container only when none remain,
never the trash); speech(for:) is the one-sentence precedence ladder —
raised condition > bracket completion > cleared condition > vanished
focus > digest — foreign-only for the last two rungs, so app-mediated
echoes stay silent.

BoardStore.land assembles ReloadFacts and posts exactly one sentence per
reload through the injectable announce outlet (AccessibilityAnnouncer,
medium priority, never interrupting). Selection recovery layers on top
of ItemReferenceSet re-resolution — survivors veto, the emptied
selection lands on the vanished item's lane and re-arms ⌘N's active-lane
memory. performWholesale(announcing:) arms a completion phrase consumed
by the closing reload — nil on every base bracket today; pro-m1 fills
git phrasings. Locks raised outside the reload path (vanished root,
unwritable location) announce through the same ladder, and the banner
strip is a labeled "Board status" container whose row labels are the
announced sentences (AccessibilityPhrases.bannerLabel — one string for
eye and ear).

Announcements classify at reload granularity (WatchOrigin) as a
deliberate interim: DESIGN/02's EchoLedger (per-file classification, the
announcer's specified input, git-free) was scheduled with the
auto-committer that the edition split moved to pro-m1 — filed on the
Redesign board for a ruling. 1533 unit tests green, both schemes build.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 08:15:53 -04:00
parent 273c182ef4
commit c339b4cecf
14 changed files with 1964 additions and 46 deletions
+268
View File
@@ -0,0 +1,268 @@
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[..<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
}
}