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
+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
}
}