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
}
}
+212
View File
@@ -0,0 +1,212 @@
import Foundation
// MARK: - BoardDiff
/// **What changed between two snapshots**, as identities grouped by what happened to them the
/// summarizer 10-accessibility.md Live board announcements asks for ("one polite digest per
/// reload debounce 'Board changed: 2 cards edited, 1 card added'").
///
/// ### Snapshots, not a journal
///
/// The watcher reports *that* the tree changed, never *what* (`WatcherEvent`), and 02-architecture.md
/// is explicit about why that is the right shape: "a value-type snapshot comparison is both cheaper
/// and more trustworthy than trying to reconstruct a tree mutation from a stream of paths that may
/// have been coalesced, dropped, or reordered". So this is a comparison of two `BoardModel` values
/// and nothing else no FSEvents paths, no receipts, no git. That is also what makes it work on a
/// no-git board, which 10 requires in as many words ("on no-git boards the same classifier runs
/// without the committer").
///
/// ### Why it lives in `LiveStore/` and not beside the announcement
///
/// 10 says announcements "reuse the auto-committer's summarizer" and 06-history-undo.md says the
/// committer synthesizes its commit messages from the same comparison. The committer is Pro-edition
/// work (pro-m1) and does not exist yet, so the summarizer is built here first, in the layer both
/// consumers can reach: base compiles `Kanban/`, Pro compiles `Kanban/` *plus* `KanbanPro/`
/// (12-editions.md Targets), so a type in the store layer is available to the committer by
/// construction while a type in `KanbanPro/` would not be available to the announcer. It imports
/// `Foundation` alone and touches no view, no window, and no `NSAccessibility` the phrasing is
/// `AccessibilityPhrases`' job, and pro-m1's message composer will phrase the very same counts
/// differently without either of them knowing about the other.
///
/// The per-category **identity sets** rather than bare tallies are for that second consumer: a
/// spoken digest only ever needs `count`, but a semantic commit message needs to name the items, and
/// a summarizer that threw the ids away would have to be rewritten rather than extended.
///
/// ### Two counting rules worth stating
///
/// - **Implied events don't steal the subject** (06-history-undo.md's composer discipline, which 10
/// applies to speech in as many words). A lane that vanished takes its cards with it, and a board
/// that says "1 lane deleted, 5 cards deleted" has reported one event twice. So a card is only
/// counted as added or deleted when its lane was *there on both sides*; cards arriving with a new
/// lane and cards leaving with a deleted one are the lane's event, not their own.
/// - **Every changed identity lands in exactly one bucket**, with `moved` outranking `edited`. An
/// agent that re-files a card *and* retitles it made one change to the board, and two fragments
/// counting the same card would read as two cards. Position wins because it is the change the
/// board's shape shows.
public struct BoardDiff: Sendable, Equatable {
/// One kind's four buckets. Disjoint by construction see the type's note on precedence.
public struct Changes: Sendable, Equatable {
/// Identities present in the new snapshot and not the old minus the ones whose arrival is
/// implied by their lane's.
public var added: Set<ItemID> = []
/// Identities present on both sides whose *rendered content* differs title, body, style,
/// attachments, lane width. Deliberately not `modified`, `modified-by`, or unknown keys: a
/// digest describes what the board looks like, and a bumped timestamp changes nothing a
/// user could see. Deliberately not `order` either, which is the `moved` axis.
public var edited: Set<ItemID> = []
/// Identities present on both sides that changed position a different `order`, or (for a
/// card) a different lane.
public var moved: Set<ItemID> = []
/// Identities present in the old snapshot and not the new minus the ones whose departure
/// is implied by their lane's. "Deleted" rather than "removed" because that is what it is
/// from the board's side: deletion is a move into `.trash/` (01-storage-format.md §
/// Deletion), and a folder moved clean out of the board reads the same way to a user.
public var deleted: Set<ItemID> = []
public init() {}
public var isEmpty: Bool {
added.isEmpty && edited.isEmpty && moved.isEmpty && deleted.isEmpty
}
}
public var cards = Changes()
public var lanes = Changes()
/// Whether the board side of the snapshot differs **at all** the backstop for changes no
/// bucket counts: a renamed board, an edited board description, a bumped stamp.
///
/// **The trash is excluded on purpose.** A card entering the trash already counts as a card
/// deleted (it left the board's universe) and one leaving it counts as added, which is the
/// user's own reading of both; churn *inside* the trash a purge, an Empty Trash run by an
/// agent moves nothing on the board and is not worth interrupting for. See the report's note:
/// a visible trash column changing under a VoiceOver user is a design gap 10 does not rule on.
public var boardChanged = false
public init() {}
/// Nothing to say: no counted change, and no uncounted one either.
public var isSilent: Bool { !boardChanged && cards.isEmpty && lanes.isEmpty }
// MARK: - The comparison
/// The whole summarizer: two snapshots in, one grouped diff out. Pure, total, and free of any
/// notion of *why* the tree changed `WatchOrigin` decides whether anyone is told
/// (`BoardAnnouncer`), never this.
public static func between(_ old: BoardModel, _ new: BoardModel) -> BoardDiff {
var diff = BoardDiff()
diff.boardChanged = boardSideDiffers(old, new)
let oldLanes = laneIndex(of: old)
let newLanes = laneIndex(of: new)
for id in newLanes.keys where oldLanes[id] == nil {
diff.lanes.added.insert(id)
}
for id in oldLanes.keys where newLanes[id] == nil {
diff.lanes.deleted.insert(id)
}
for (id, newLane) in newLanes {
guard let oldLane = oldLanes[id] else { continue }
if oldLane.order != newLane.order {
diff.lanes.moved.insert(id)
} else if contentDiffers(oldLane, newLane) {
diff.lanes.edited.insert(id)
}
}
let oldCards = cardIndex(of: old)
let newCards = cardIndex(of: new)
for (id, entry) in newCards where oldCards[id] == nil {
// The implied-arrival rule: a card that came in with a brand-new lane is the lane's
// event.
guard !diff.lanes.added.contains(entry.lane) else { continue }
diff.cards.added.insert(id)
}
for (id, entry) in oldCards where newCards[id] == nil {
// The implied-departure rule, and the one the vanishing-focus announcement leans on:
// when a lane goes, the announcement names the lane and its count, not five cards.
guard !diff.lanes.deleted.contains(entry.lane) else { continue }
diff.cards.deleted.insert(id)
}
for (id, newEntry) in newCards {
guard let oldEntry = oldCards[id] else { continue }
if oldEntry.lane != newEntry.lane || oldEntry.card.order != newEntry.card.order {
diff.cards.moved.insert(id)
} else if contentDiffers(oldEntry.card, newEntry.card) {
diff.cards.edited.insert(id)
}
}
return diff
}
// MARK: - Indices
private static func laneIndex(of snapshot: BoardModel) -> [ItemID: Lane] {
Dictionary(uniqueKeysWithValues: snapshot.lanes.map { ($0.id, $0) })
}
/// Every board-side card, with the lane it sits in. The trash is not walked: its cards are not
/// on the board, and the two crossings that matter (in and out) are already visible as a
/// departure and an arrival from this universe.
private static func cardIndex(of snapshot: BoardModel) -> [ItemID: (lane: ItemID, card: Card)] {
var index: [ItemID: (lane: ItemID, card: Card)] = [:]
for lane in snapshot.lanes {
for card in lane.cards {
index[card.id] = (lane.id, card)
}
}
return index
}
// MARK: - Content
/// A card's rendered content, field by field rather than by whole-value equality.
///
/// Spelled out because whole-value equality would fold in `order` (the `moved` axis) and the
/// `modified`/`modified-by` stamps, so every reorder would also read as an edit and every
/// touch-only rewrite would announce a change nobody could see. A rendered field added to `Card`
/// belongs on this list the cost of the explicitness, recorded so it is not rediscovered.
private static func contentDiffers(_ old: Card, _ new: Card) -> Bool {
old.schema != new.schema
|| old.title != new.title
|| old.body != new.body
|| old.background != new.background
|| old.icon != new.icon
|| old.iconColor != new.iconColor
|| old.attachments != new.attachments
}
/// A lane's rendered content the card's list, minus attachments (a lane has none) plus
/// `width`, which is a lane's own visible property (03-board-ui.md § Lane). A lane's *cards*
/// are diffed as cards, never folded in here.
private static func contentDiffers(_ old: Lane, _ new: Lane) -> Bool {
old.schema != new.schema
|| old.title != new.title
|| old.body != new.body
|| old.background != new.background
|| old.icon != new.icon
|| old.iconColor != new.iconColor
|| old.width != new.width
}
/// Whether anything on the board side differs, trash excluded.
///
/// `trash` is the one `var` on `BoardModel`, which is what makes "everything except the trash"
/// expressible as value equality rather than as a second field-by-field list that would go
/// stale the moment the model grows a field.
private static func boardSideDiffers(_ old: BoardModel, _ new: BoardModel) -> Bool {
var oldSansTrash = old
var newSansTrash = new
oldSansTrash.trash = []
newSansTrash.trash = []
return oldSansTrash != newSansTrash
}
}
+161 -2
View File
@@ -426,6 +426,24 @@ public final class BoardStore {
@ObservationIgnored
private var wholesaleReloadFloor: Int?
/// What the outstanding wholesale operation wants said when its reload lands, or `nil` for one
/// that has nothing to announce **"bracketed operations announce once, at completion never
/// their internal churn"** (10-accessibility.md Live board announcements).
///
/// Stored beside the floor and consumed by the same reload, because the announcement's whole
/// claim is that the operation *finished*: a phrase spoken when `performWholesale` returns would
/// be describing a tree the store has not read yet, and one spoken per file would be the churn
/// the design rules out. It is dropped along with the floor whichever way that reload went a
/// failed closing reload locks the board and says so instead (`BoardAnnouncer`'s ladder puts the
/// raised lock above the completion), and the phrase must not survive to be spoken by some
/// later, unrelated reload.
///
/// **`nil` on every base-edition bracket today.** Base has no git operations, and the design's
/// examples ("Pulled 3 commits", "Switched to branch 'redesign'") are pro-m1's; the parameter
/// exists so that milestone supplies phrasing rather than re-plumbing the seam.
@ObservationIgnored
private var wholesaleCompletion: String?
/// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running
/// and nothing is owed.
@ObservationIgnored
@@ -461,6 +479,24 @@ public final class BoardStore {
@ObservationIgnored
var loadBarrier: (@Sendable () async -> Void)?
/// **This board's one outlet for spoken announcements** `AccessibilityAnnouncer.post` in
/// production, and the second seam this type keeps (`loadBarrier` is the first, and this is the
/// same bargain).
///
/// Every *decision* about what the board says is already a pure function of values
/// (`BoardAnnouncer.speech(for:)`, `AccessibilityPhrases`), so the rules are not here and are not
/// tested through here. What this makes assertable is the **wiring**: that a foreign reload's
/// sentence actually reaches an outlet, that an app-mediated echo produces none, and that a
/// bracket's completion phrase is spoken by the reload that closed it and by no later one. Those
/// are claims about the reload path rather than about phrasing, and the alternative way to check
/// them is a screen reader and a human ear.
///
/// One outlet rather than a call per producer, for `setTrashVisible`'s own reason: the board's
/// announcements are one voice, and a producer that posted around this would be a second voice
/// nothing could see.
@ObservationIgnored
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
// MARK: - Init
@@ -592,15 +628,56 @@ public final class BoardStore {
// operation gets exactly one reload to prove itself, and a second failure after it is
// ordinary per-file breakage again.
let endsWholesaleOperation: Bool
let completion: String?
if let floor = wholesaleReloadFloor, generation >= floor {
wholesaleReloadFloor = nil
completion = wholesaleCompletion
wholesaleCompletion = nil
endsWholesaleOperation = true
} else {
completion = nil
endsWholesaleOperation = false
}
// The two standing conditions as they stood *before* this reload touched them
// 10-accessibility.md makes the live-reload-resilience banner an announced element "when it
// appears and when it clears", and appearing and clearing are transitions, not states. Read
// here rather than at each mutation below so there is one before-picture for the whole
// landing, whichever branch it takes.
var facts = BoardAnnouncer.ReloadFacts(origin: origin)
facts.endsBracketedOperation = endsWholesaleOperation
facts.completion = completion
facts.lockBefore = readOnlyLock
facts.breakageBefore = reloadFailure
switch outcome {
case let .success(result):
// **What changed, and what it cost the cursor** both computed against the *outgoing*
// snapshot, so they have to be taken before the assignment below replaces it. Both are
// pure functions of two value types; nothing here decides whether anyone is told.
//
// Asked only of a `.foreign` reload that is not closing a bracket, which is the only
// reload either answer is used by: 10-accessibility.md gives the app's own echoes
// silence, gives a bracket one sentence at completion rather than a description of its
// churn, and phrases the vanishing-focus case as "deleted *externally*" a sentence that
// would be a lie about an app-mediated delete, whose own command already chose a
// successor (04-interactions.md The map's rule) and must not have it overridden here.
// Skipping the comparison on the other origins keeps the ordinary echo's landing exactly
// as cheap as it was.
let focus: BoardAnnouncer.FocusOutcome
if origin == .foreign, !endsWholesaleOperation {
facts.diff = BoardDiff.between(snapshot, result.model)
focus = BoardAnnouncer.focusOutcome(
old: snapshot,
new: result.model,
selection: transient.selection,
focused: focusedItem
)
} else {
focus = .survived
}
facts.vanishedFocus = focus.vanished
// **The motion language's one decision point** (03-board-ui.md § Motion, via `Motion`).
// "User-initiated structural changes animate; foreign changes snap" cannot live at the
// call sites here the way it did in the pathfinder the one-way flow means the user's
@@ -627,6 +704,12 @@ public final class BoardStore {
// re-grounding that landed outside this one would be exactly the independent ease
// that rules out.
transient.resolve(against: result.model)
// And *then* the recovery, on top of the set rule rather than instead of it: the
// resolution leaves an emptied selection wherever the focused item used to be, and
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
// lane walks up then sideways"). Inside the same transaction for the highlight's
// sake, exactly like the resolution it follows.
recoverFocus(focus.recovery)
}
// Outside it, equally deliberately 03 keys transactions narrowly, and the banner
// conditions are not board structure. A lock clearing is not a thing the strip should
@@ -684,6 +767,50 @@ public final class BoardStore {
}
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
}
// **One announcement per reload**, chosen by `BoardAnnouncer`'s precedence ladder and posted
// last, after both branches have finished moving the store so the after-picture the
// decision reads is the settled one, and so a sentence is never spoken about a state that a
// line below it then changed.
facts.lockAfter = readOnlyLock
facts.breakageAfter = reloadFailure
announce(BoardAnnouncer.speech(for: facts))
}
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
/// reload the storage half of the rule, with every decision already made.
///
/// The board container case is spelled rather than skipped: `resolve(against:)` has already
/// emptied the selection by the time this runs, so `clearSelection()` is a no-op on membership
/// but it also drops the anchor and the head, which is the difference between "nothing is
/// selected" and "nothing is selected and the next -arrow ranges from a ghost".
///
/// `noteActiveLane` rides along on the lane case for `lastActiveLaneID`'s own reason: the
/// resolution just cleared that memory along with the lane it named, and a N after a foreign
/// delete should file the card where the user has been left, not at the far left of the board.
private func recoverFocus(_ recovery: BoardAnnouncer.FocusRecovery?) {
switch recovery {
case nil:
break
case let .lane(id):
transient.select([id], in: .board)
transient.noteActiveLane(id)
case .boardContainer:
transient.clearSelection()
}
}
/// **The cursor**, as 10-accessibility.md's announcements mean it: the navigation head when it
/// is still in the selection, else a sole selected item, else nothing.
///
/// Nothing for a multi-item selection with no head deliberately a vanishing-focus sentence
/// names *one* item ("Card 'Fix login' was deleted externally"), and picking one out of a
/// five-card selection by set order would name whichever the hash table happened to yield. That
/// case falls through to the digest, which describes all five honestly.
private var focusedItem: ItemID? {
let ids = transient.selection.ids
if let head = transient.selectionHead, ids.contains(head) { return head }
return ids.count == 1 ? ids.first : nil
}
private func startPendingReload() {
@@ -757,7 +884,9 @@ public final class BoardStore {
/// which can only happen if the root came back) is strictly the safer one to be holding.
public func enterVanishedRootLock() {
Self.logger.error("board root vanished — entering the read-only lock")
let before = readOnlyLock
readOnlyLock = .vanishedRoot
announceLockChange(from: before)
}
/// Raises the unwritable-location read-only lock the open flow's call, after probing the
@@ -772,6 +901,23 @@ public final class BoardStore {
guard readOnlyLock == nil else { return }
Self.logger.error("board location is not writable — entering the read-only lock")
readOnlyLock = .unwritableLocation
announceLockChange(from: nil)
}
/// Speaks a lock raised **outside** the reload path the registry's vanished-root call and the
/// open flow's writability probe, neither of which is a reload and neither of which therefore
/// competes with anything for the debounce's one sentence.
///
/// 10-accessibility.md makes the live-reload-resilience banner an announced element, and these
/// are the two ways it can appear without a reload landing. Routed through the same
/// `BoardAnnouncer.ReloadFacts` ladder rather than posting directly so the sentence is composed
/// exactly once, in one place, from the banner's own headline: a lock the user hears described
/// one way and reads another is two locks as far as they can tell.
private func announceLockChange(from before: ReadOnlyLockReason?) {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
facts.lockBefore = before
facts.lockAfter = readOnlyLock
announce(BoardAnnouncer.speech(for: facts))
}
// MARK: - Write gate
@@ -837,11 +983,20 @@ public final class BoardStore {
/// died partway is precisely the case where the tree's state is unknown and the next reload had
/// better be the authority on it.
///
/// - Parameter completion: what to announce when the closing reload lands
/// (10-accessibility.md Live board announcements: "bracketed operations announce once, at
/// completion" "Pulled 3 commits", "Switched to branch 'redesign'"). `nil`, the default, is
/// an operation whose completion is not worth speech, which is **every base-edition bracket
/// today**: base has no git operations, and the two app-initiated writes that do reach disk on
/// their own the loose-file relocation and the legacy-tombstone migration are ordinary
/// `performWrite` calls that already say what they did on the banner strip. The parameter is
/// the seam pro-m1 fills; see `wholesaleCompletion`.
///
/// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is already locked a locked
/// board refuses to *start* wholesale work, not just ordinary writes. Otherwise rethrows
/// `operation`'s error. (Spelled `throws` rather than `rethrows` because of that refusal: a
/// `rethrows` function may only throw errors its closure threw.)
public func performWholesale(_ operation: () throws -> Void) throws {
public func performWholesale(announcing completion: String? = nil, _ operation: () throws -> Void) throws {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
@@ -849,8 +1004,12 @@ public final class BoardStore {
defer {
// Ordered: arm first, then close the bracket. `endBracket()` is what schedules the
// post-bracket reload, and with a `nil` watcher a consumer may signal by hand the instant
// this returns either way the floor has to be in place before any walk can start.
// this returns either way the floor has to be in place before any walk can start. The
// completion phrase is armed with it, for the same reason and on the same exit paths: an
// operation that died partway still owes its closing reload, and 10 gives that reload one
// sentence whichever way it goes.
wholesaleReloadFloor = reloadGeneration + 1
wholesaleCompletion = completion
watcherBrackets?.end()
}
do {