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
+44
View File
@@ -0,0 +1,44 @@
import AppKit
// MARK: - AccessibilityAnnouncer
/// **The app's one `NSAccessibility.post` call site** the thin, untestable half of every spoken
/// announcement (10-accessibility.md Live board announcements, Trash lane).
///
/// Everything *decidable* about an announcement whether one is owed, which of several competing
/// sentences wins, and what it says is a pure function elsewhere (`BoardAnnouncer`,
/// `AccessibilityPhrases`). What is left is two lines of AppKit that cannot be asserted about
/// without a screen reader attached, so they are stated once, here, rather than copied to every
/// producer where they could quietly drift apart.
///
/// **Medium priority, always.** 10 asks for "one polite (non-interrupting) digest"; `.high` cuts off
/// speech already in progress, which is right for a modal failure and wrong for every announcement
/// this app makes a board narrating an agent's edit must never talk over the sentence the user is
/// currently listening to.
///
/// **Posted to the key window** so the announcement is attributed to the board the user is looking
/// at rather than to the application at large; `NSApplication.shared` is the fallback for the moment
/// between windows, where an attributed announcement is still better than a dropped one.
enum AccessibilityAnnouncer {
/// Says `phrase`, or says nothing.
///
/// `nil`-and-empty-tolerant on purpose: every producer above it answers with an *optional*
/// sentence "one announcement per reload, and usually none" is the whole shape of
/// `BoardAnnouncer.speech(for:)` so the `nil` check belongs here rather than at each call
/// site, where forgetting it would post an empty announcement (a spoken pause) instead of
/// staying quiet.
@MainActor
static func post(_ phrase: String?) {
guard let phrase, !phrase.isEmpty else { return }
let element: Any = NSApplication.shared.keyWindow ?? NSApplication.shared
NSAccessibility.post(
element: element,
notification: .announcementRequested,
userInfo: [
.announcement: phrase,
.priority: NSAccessibilityPriorityLevel.medium.rawValue
]
)
}
}