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:
@@ -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
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user