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:
@@ -426,6 +426,24 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
private var wholesaleReloadFloor: Int?
|
||||
|
||||
/// What the outstanding wholesale operation wants said when its reload lands, or `nil` for one
|
||||
/// that has nothing to announce — **"bracketed operations announce once, at completion … never
|
||||
/// their internal churn"** (10-accessibility.md ▸ Live board announcements).
|
||||
///
|
||||
/// Stored beside the floor and consumed by the same reload, because the announcement's whole
|
||||
/// claim is that the operation *finished*: a phrase spoken when `performWholesale` returns would
|
||||
/// be describing a tree the store has not read yet, and one spoken per file would be the churn
|
||||
/// the design rules out. It is dropped along with the floor whichever way that reload went — a
|
||||
/// failed closing reload locks the board and says so instead (`BoardAnnouncer`'s ladder puts the
|
||||
/// raised lock above the completion), and the phrase must not survive to be spoken by some
|
||||
/// later, unrelated reload.
|
||||
///
|
||||
/// **`nil` on every base-edition bracket today.** Base has no git operations, and the design's
|
||||
/// examples ("Pulled 3 commits", "Switched to branch 'redesign'") are pro-m1's; the parameter
|
||||
/// exists so that milestone supplies phrasing rather than re-plumbing the seam.
|
||||
@ObservationIgnored
|
||||
private var wholesaleCompletion: String?
|
||||
|
||||
/// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running
|
||||
/// and nothing is owed.
|
||||
@ObservationIgnored
|
||||
@@ -461,6 +479,24 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
var loadBarrier: (@Sendable () async -> Void)?
|
||||
|
||||
/// **This board's one outlet for spoken announcements** — `AccessibilityAnnouncer.post` in
|
||||
/// production, and the second seam this type keeps (`loadBarrier` is the first, and this is the
|
||||
/// same bargain).
|
||||
///
|
||||
/// Every *decision* about what the board says is already a pure function of values
|
||||
/// (`BoardAnnouncer.speech(for:)`, `AccessibilityPhrases`), so the rules are not here and are not
|
||||
/// tested through here. What this makes assertable is the **wiring**: that a foreign reload's
|
||||
/// sentence actually reaches an outlet, that an app-mediated echo produces none, and that a
|
||||
/// bracket's completion phrase is spoken by the reload that closed it and by no later one. Those
|
||||
/// are claims about the reload path rather than about phrasing, and the alternative way to check
|
||||
/// them is a screen reader and a human ear.
|
||||
///
|
||||
/// One outlet rather than a call per producer, for `setTrashVisible`'s own reason: the board's
|
||||
/// announcements are one voice, and a producer that posted around this would be a second voice
|
||||
/// nothing could see.
|
||||
@ObservationIgnored
|
||||
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
|
||||
|
||||
// MARK: - Init
|
||||
@@ -592,15 +628,56 @@ public final class BoardStore {
|
||||
// operation gets exactly one reload to prove itself, and a second failure after it is
|
||||
// ordinary per-file breakage again.
|
||||
let endsWholesaleOperation: Bool
|
||||
let completion: String?
|
||||
if let floor = wholesaleReloadFloor, generation >= floor {
|
||||
wholesaleReloadFloor = nil
|
||||
completion = wholesaleCompletion
|
||||
wholesaleCompletion = nil
|
||||
endsWholesaleOperation = true
|
||||
} else {
|
||||
completion = nil
|
||||
endsWholesaleOperation = false
|
||||
}
|
||||
|
||||
// The two standing conditions as they stood *before* this reload touched them —
|
||||
// 10-accessibility.md makes the live-reload-resilience banner an announced element "when it
|
||||
// appears and when it clears", and appearing and clearing are transitions, not states. Read
|
||||
// here rather than at each mutation below so there is one before-picture for the whole
|
||||
// landing, whichever branch it takes.
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: origin)
|
||||
facts.endsBracketedOperation = endsWholesaleOperation
|
||||
facts.completion = completion
|
||||
facts.lockBefore = readOnlyLock
|
||||
facts.breakageBefore = reloadFailure
|
||||
|
||||
switch outcome {
|
||||
case let .success(result):
|
||||
// **What changed, and what it cost the cursor** — both computed against the *outgoing*
|
||||
// snapshot, so they have to be taken before the assignment below replaces it. Both are
|
||||
// pure functions of two value types; nothing here decides whether anyone is told.
|
||||
//
|
||||
// Asked only of a `.foreign` reload that is not closing a bracket, which is the only
|
||||
// reload either answer is used by: 10-accessibility.md gives the app's own echoes
|
||||
// silence, gives a bracket one sentence at completion rather than a description of its
|
||||
// churn, and phrases the vanishing-focus case as "deleted *externally*" — a sentence that
|
||||
// would be a lie about an app-mediated delete, whose own command already chose a
|
||||
// successor (04-interactions.md ▸ The map's ⌫ rule) and must not have it overridden here.
|
||||
// Skipping the comparison on the other origins keeps the ordinary echo's landing exactly
|
||||
// as cheap as it was.
|
||||
let focus: BoardAnnouncer.FocusOutcome
|
||||
if origin == .foreign, !endsWholesaleOperation {
|
||||
facts.diff = BoardDiff.between(snapshot, result.model)
|
||||
focus = BoardAnnouncer.focusOutcome(
|
||||
old: snapshot,
|
||||
new: result.model,
|
||||
selection: transient.selection,
|
||||
focused: focusedItem
|
||||
)
|
||||
} else {
|
||||
focus = .survived
|
||||
}
|
||||
facts.vanishedFocus = focus.vanished
|
||||
|
||||
// **The motion language's one decision point** (03-board-ui.md § Motion, via `Motion`).
|
||||
// "User-initiated structural changes animate; foreign changes snap" cannot live at the
|
||||
// call sites here the way it did in the pathfinder — the one-way flow means the user's
|
||||
@@ -627,6 +704,12 @@ public final class BoardStore {
|
||||
// re-grounding that landed outside this one would be exactly the independent ease
|
||||
// that rules out.
|
||||
transient.resolve(against: result.model)
|
||||
// And *then* the recovery, on top of the set rule rather than instead of it: the
|
||||
// resolution leaves an emptied selection wherever the focused item used to be, and
|
||||
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
|
||||
// lane … walks up then sideways"). Inside the same transaction for the highlight's
|
||||
// sake, exactly like the resolution it follows.
|
||||
recoverFocus(focus.recovery)
|
||||
}
|
||||
// Outside it, equally deliberately — 03 keys transactions narrowly, and the banner
|
||||
// conditions are not board structure. A lock clearing is not a thing the strip should
|
||||
@@ -684,6 +767,50 @@ public final class BoardStore {
|
||||
}
|
||||
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
|
||||
}
|
||||
|
||||
// **One announcement per reload**, chosen by `BoardAnnouncer`'s precedence ladder and posted
|
||||
// last, after both branches have finished moving the store — so the after-picture the
|
||||
// decision reads is the settled one, and so a sentence is never spoken about a state that a
|
||||
// line below it then changed.
|
||||
facts.lockAfter = readOnlyLock
|
||||
facts.breakageAfter = reloadFailure
|
||||
announce(BoardAnnouncer.speech(for: facts))
|
||||
}
|
||||
|
||||
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
|
||||
/// reload — the storage half of the rule, with every decision already made.
|
||||
///
|
||||
/// The board container case is spelled rather than skipped: `resolve(against:)` has already
|
||||
/// emptied the selection by the time this runs, so `clearSelection()` is a no-op on membership —
|
||||
/// but it also drops the anchor and the head, which is the difference between "nothing is
|
||||
/// selected" and "nothing is selected and the next ⇧-arrow ranges from a ghost".
|
||||
///
|
||||
/// `noteActiveLane` rides along on the lane case for `lastActiveLaneID`'s own reason: the
|
||||
/// resolution just cleared that memory along with the lane it named, and a ⌘N after a foreign
|
||||
/// delete should file the card where the user has been left, not at the far left of the board.
|
||||
private func recoverFocus(_ recovery: BoardAnnouncer.FocusRecovery?) {
|
||||
switch recovery {
|
||||
case nil:
|
||||
break
|
||||
case let .lane(id):
|
||||
transient.select([id], in: .board)
|
||||
transient.noteActiveLane(id)
|
||||
case .boardContainer:
|
||||
transient.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/// **The cursor**, as 10-accessibility.md's announcements mean it: the navigation head when it
|
||||
/// is still in the selection, else a sole selected item, else nothing.
|
||||
///
|
||||
/// Nothing for a multi-item selection with no head deliberately — a vanishing-focus sentence
|
||||
/// names *one* item ("Card 'Fix login' was deleted externally"), and picking one out of a
|
||||
/// five-card selection by set order would name whichever the hash table happened to yield. That
|
||||
/// case falls through to the digest, which describes all five honestly.
|
||||
private var focusedItem: ItemID? {
|
||||
let ids = transient.selection.ids
|
||||
if let head = transient.selectionHead, ids.contains(head) { return head }
|
||||
return ids.count == 1 ? ids.first : nil
|
||||
}
|
||||
|
||||
private func startPendingReload() {
|
||||
@@ -757,7 +884,9 @@ public final class BoardStore {
|
||||
/// which can only happen if the root came back) is strictly the safer one to be holding.
|
||||
public func enterVanishedRootLock() {
|
||||
Self.logger.error("board root vanished — entering the read-only lock")
|
||||
let before = readOnlyLock
|
||||
readOnlyLock = .vanishedRoot
|
||||
announceLockChange(from: before)
|
||||
}
|
||||
|
||||
/// Raises the unwritable-location read-only lock — the open flow's call, after probing the
|
||||
@@ -772,6 +901,23 @@ public final class BoardStore {
|
||||
guard readOnlyLock == nil else { return }
|
||||
Self.logger.error("board location is not writable — entering the read-only lock")
|
||||
readOnlyLock = .unwritableLocation
|
||||
announceLockChange(from: nil)
|
||||
}
|
||||
|
||||
/// Speaks a lock raised **outside** the reload path — the registry's vanished-root call and the
|
||||
/// open flow's writability probe, neither of which is a reload and neither of which therefore
|
||||
/// competes with anything for the debounce's one sentence.
|
||||
///
|
||||
/// 10-accessibility.md makes the live-reload-resilience banner an announced element, and these
|
||||
/// are the two ways it can appear without a reload landing. Routed through the same
|
||||
/// `BoardAnnouncer.ReloadFacts` ladder rather than posting directly so the sentence is composed
|
||||
/// exactly once, in one place, from the banner's own headline: a lock the user hears described
|
||||
/// one way and reads another is two locks as far as they can tell.
|
||||
private func announceLockChange(from before: ReadOnlyLockReason?) {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
facts.lockBefore = before
|
||||
facts.lockAfter = readOnlyLock
|
||||
announce(BoardAnnouncer.speech(for: facts))
|
||||
}
|
||||
|
||||
// MARK: - Write gate
|
||||
@@ -837,11 +983,20 @@ public final class BoardStore {
|
||||
/// died partway is precisely the case where the tree's state is unknown and the next reload had
|
||||
/// better be the authority on it.
|
||||
///
|
||||
/// - Parameter completion: what to announce when the closing reload lands
|
||||
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
|
||||
/// completion" — "Pulled 3 commits", "Switched to branch 'redesign'"). `nil`, the default, is
|
||||
/// an operation whose completion is not worth speech, which is **every base-edition bracket
|
||||
/// today**: base has no git operations, and the two app-initiated writes that do reach disk on
|
||||
/// their own — the loose-file relocation and the legacy-tombstone migration — are ordinary
|
||||
/// `performWrite` calls that already say what they did on the banner strip. The parameter is
|
||||
/// the seam pro-m1 fills; see `wholesaleCompletion`.
|
||||
///
|
||||
/// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is already locked — a locked
|
||||
/// board refuses to *start* wholesale work, not just ordinary writes. Otherwise rethrows
|
||||
/// `operation`'s error. (Spelled `throws` rather than `rethrows` because of that refusal: a
|
||||
/// `rethrows` function may only throw errors its closure threw.)
|
||||
public func performWholesale(_ operation: () throws -> Void) throws {
|
||||
public func performWholesale(announcing completion: String? = nil, _ operation: () throws -> Void) throws {
|
||||
if let readOnlyLock {
|
||||
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
|
||||
}
|
||||
@@ -849,8 +1004,12 @@ public final class BoardStore {
|
||||
defer {
|
||||
// Ordered: arm first, then close the bracket. `endBracket()` is what schedules the
|
||||
// post-bracket reload, and with a `nil` watcher a consumer may signal by hand the instant
|
||||
// this returns — either way the floor has to be in place before any walk can start.
|
||||
// this returns — either way the floor has to be in place before any walk can start. The
|
||||
// completion phrase is armed with it, for the same reason and on the same exit paths: an
|
||||
// operation that died partway still owes its closing reload, and 10 gives that reload one
|
||||
// sentence whichever way it goes.
|
||||
wholesaleReloadFloor = reloadGeneration + 1
|
||||
wholesaleCompletion = completion
|
||||
watcherBrackets?.end()
|
||||
}
|
||||
do {
|
||||
|
||||
Reference in New Issue
Block a user