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
]
)
}
}
+114
View File
@@ -121,4 +121,118 @@ enum AccessibilityPhrases {
static func trashVisibility(shown: Bool) -> String {
shown ? "Trash shown" : "Trash hidden"
}
// MARK: - Live board announcements
/// "3 lanes", "1 lane" `cardCount`'s twin, and the second half of the digest's plural folding.
/// Spelled here rather than borrowed from `TrashModel.phrase` because that one is a cards-only
/// container's phrase by design ("Lanes are never trashed").
static func laneCount(_ count: Int) -> String {
"\(count) lane\(count == 1 ? "" : "s")"
}
/// The digest's opening and, on its own, the whole sentence for a change no bucket counts.
///
/// Named for the sentence rather than overloading `boardChanged(_:)`: a constant and a function
/// sharing a base name would make `"\(boardChanged)"` an ambiguity a reader has to resolve by
/// hand, and string interpolation accepts either.
static let boardChangedSubject = "Board changed"
/// **One polite digest per reload debounce** 10-accessibility.md Live board announcements,
/// whose own example sentence this reproduces: "Board changed: 2 cards edited, 1 card added".
///
/// Three phrasing rules, all pinned by `AccessibilityPhrasesTests`:
///
/// - **Zero categories are omitted**, never spoken as "0 cards moved". A digest is a summary,
/// and a summary that lists what did *not* happen is a list of noise with the news buried in
/// it.
/// - **Counts fold their plural** (`cardCount`, `laneCount`), like every other count in the app.
/// - **The order is fixed**: cards before lanes, and within each kind edited, added, moved,
/// deleted. Cards lead because they are what a board is mostly made of and what 10's example
/// leads with; within a kind the order runs from the change that leaves the board's shape
/// alone to the one that takes something out of it, so the sentence ends on the fragment most
/// likely to need acting on.
///
/// `nil` silence when nothing differs at all, which is the ordinary outcome of a reload that
/// re-read an unchanged tree. A change that no bucket counts (a renamed board, an edited board
/// description) still says *something*: "Board changed", bare. Announcing nothing there would be
/// the lie 10's principle names "silence about a mutating board is a lie to a VoiceOver user".
static func boardChanged(_ diff: BoardDiff) -> String? {
var fragments: [String] = []
appendFragments(of: diff.cards, counting: cardCount, to: &fragments)
appendFragments(of: diff.lanes, counting: laneCount, to: &fragments)
guard !fragments.isEmpty else {
return diff.boardChanged ? boardChangedSubject : nil
}
return "\(boardChangedSubject): \(fragments.joined(separator: ", "))"
}
/// One kind's fragments, in the fixed category order. `counting` is the kind's plural folding,
/// passed in so the two kinds are one piece of code rather than two that could drift.
private static func appendFragments(
of changes: BoardDiff.Changes,
counting count: (Int) -> String,
to fragments: inout [String]
) {
if !changes.edited.isEmpty { fragments.append("\(count(changes.edited.count)) edited") }
if !changes.added.isEmpty { fragments.append("\(count(changes.added.count)) added") }
if !changes.moved.isEmpty { fragments.append("\(count(changes.moved.count)) moved") }
if !changes.deleted.isEmpty { fragments.append("\(count(changes.deleted.count)) deleted") }
}
/// **A vanishing focus is called out specifically** (10 Live board announcements) the
/// design's own two sentences, "Card 'Fix login' was deleted externally" and "Lane 'Doing' was
/// deleted externally, with 5 cards".
///
/// The lane form's trailing clause is what makes the substitution honest: when the lane went, it
/// took cards with it, and naming the lane *without* the count would hide the larger half of
/// what happened. It is omitted at zero an empty lane vanishing has no second clause to add,
/// and "with 0 cards" would be an odd way to say "and nothing else".
///
/// "Externally" and not "by an agent" or "on disk": the announcement fires only on a foreign
/// reload (`BoardAnnouncer.speech(for:)`), and which outside writer did it an editor, an
/// agent, `git` in a terminal is exactly what the store cannot know.
static func vanishedFocus(_ vanished: BoardAnnouncer.VanishedFocus) -> String {
switch vanished {
case let .card(title):
"Card '\(displayTitle(title))' was deleted externally"
case let .lane(title, cards):
cards == 0
? "Lane '\(displayTitle(title))' was deleted externally"
: "Lane '\(displayTitle(title))' was deleted externally, with \(cardCount(cards))"
}
}
// MARK: - The banner strip
/// What VoiceOver says before a banner's headline. "Status" rather than "Info" because that is
/// the word the platform uses for a non-alarming state announcement.
///
/// Moved here from `BannerStripView` when the strip's rows started being *announced* as well as
/// read: 10 makes the live-reload-resilience banner "an accessibility element announced when
/// it appears and when it clears", and the row's label and its announcement must be the same
/// sentence or a user would hear the condition described two different ways.
static func bannerTonePrefix(_ tone: BannerTone) -> String {
switch tone {
case .error: "Error"
case .warning: "Warning"
case .info: "Status"
}
}
/// A banner row as one spoken element tone first, because a VoiceOver user must hear *that*
/// this is an error before hearing what the error is, and colour cannot carry that.
static func bannerLabel(tone: BannerTone, headline: String) -> String {
"\(bannerTonePrefix(tone)): \(headline)"
}
/// The read-only lock clearing. Stated as the regained capability rather than as the cause's
/// disappearance ("the volume came back") because the causes are three and the consequence is
/// one, and the consequence is what the user was waiting on.
static let readOnlyLockCleared = "The board is editable again"
/// Reload breakage clearing the board is reading its files again, which is a smaller claim
/// than the lock's and is deliberately phrased as one.
static let reloadBreakageCleared = "The board is loading again"
}
+17 -13
View File
@@ -61,6 +61,15 @@ public struct BannerStripView: View {
}
}
.background(.quaternary)
// **The strip is an accessibility element** (10-accessibility.md Live board
// announcements, which makes the live-reload-resilience banner one by name). `.contain`
// rather than `.combine`: the rows stay individually focusable each is already one
// element with its own tone-prefixed label, and a strip that fused three conditions into
// one utterance would bury the lock under the signposts. The group label is what a
// VoiceOver user hears on entering it, so "there are conditions here" arrives before the
// conditions do.
.accessibilityElement(children: .contain)
.accessibilityLabel("Board status")
}
}
@@ -136,10 +145,15 @@ private struct BannerRowView: View {
.background(row.tone.fill)
// One element per row, tone included: a VoiceOver user must hear *that* this is an error
// before hearing what the error is, and colour cannot carry that. The dismiss and Cancel
// buttons survive as custom actions of the combined element rather than as separate stops
// (10-accessibility.md; the announce-on-appear path arrives with that milestone).
// buttons survive as custom actions of the combined element rather than as separate stops.
//
// The label is composed by `AccessibilityPhrases` rather than spelled here because the two
// standing conditions are also *announced* on arrival and clearance (10-accessibility.md
// Live board announcements, via `BoardAnnouncer`): the sentence a user hears when the lock
// appears and the sentence they read off the row afterwards are one string, or they are two
// descriptions of one condition waiting to disagree.
.accessibilityElement(children: .combine)
.accessibilityLabel(Text("\(row.tone.accessibilityPrefix): \(row.headline)"))
.accessibilityLabel(Text(AccessibilityPhrases.bannerLabel(tone: row.tone, headline: row.headline)))
}
@ViewBuilder
@@ -210,16 +224,6 @@ private extension BannerTone {
case .info: AnyShapeStyle(.background.secondary)
}
}
/// What VoiceOver says before the headline. "Status" rather than "Info" because that is the
/// word the platform uses for a non-alarming state announcement.
var accessibilityPrefix: String {
switch self {
case .error: "Error"
case .warning: "Warning"
case .info: "Status"
}
}
}
// MARK: - Previews
+11 -27
View File
@@ -1,4 +1,3 @@
import AppKit
import Observation
import SwiftUI
@@ -291,32 +290,17 @@ extension BoardStore {
clearSelection()
}
}
announceTrashVisibility(shown)
}
/// **"Toggling visibility is announced"** (10-accessibility.md Trash lane).
///
/// A whole container joins or leaves the accessibility tree here and nothing else marks it: the
/// VoiceOver cursor does not move, no focus is lost, and the re-divide every lane performs is
/// silent by nature. Announced from the store rather than from either caller for
/// `setTrashVisible`'s own reason the View menu row and the toolbar item are one command with
/// two faces, and a consequence written at one of them would be missing from the other.
///
/// One post, and deliberately no machinery around it: the live board's announcements foreign
/// edits, vanishing focus, bracketed operations are their own design (10 Live board
/// announcements) with a summarizer and a debounce behind them, and this is not an instalment of
/// that. Posted to the key window so it is attributed to the board the user is looking at, at
/// medium priority: informative, and not worth interrupting speech already in progress.
private func announceTrashVisibility(_ shown: Bool) {
let element: Any = NSApplication.shared.keyWindow ?? NSApplication.shared
NSAccessibility.post(
element: element,
notification: .announcementRequested,
userInfo: [
.announcement: AccessibilityPhrases.trashVisibility(shown: shown),
.priority: NSAccessibilityPriorityLevel.medium.rawValue
]
)
// **"Toggling visibility is announced"** (10-accessibility.md Trash lane). A whole
// container joins or leaves the accessibility tree here and nothing else marks it: the
// VoiceOver cursor does not move, no focus is lost, and the re-divide every lane performs is
// silent by nature. Announced from the store rather than from either caller for
// `setTrashVisible`'s own reason the View menu row and the toolbar item are one command
// with two faces, and a consequence written at one of them would be missing from the other.
//
// Deliberately **not** on the reload's one-announcement-per-debounce ladder
// (`BoardAnnouncer`): this is a view state the user just toggled, not a change to the board's
// files, so there is no origin to classify and nothing for it to compete with.
announce(AccessibilityPhrases.trashVisibility(shown: shown))
}
}