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