Realign code with the 2026-07-29 accessibility rulings

Three ruled behavior changes (DESIGN/10, resolution session 2026-07-29):

- The board-change digest covers the trash while View > Show Trash is on:
  BoardDiff.between gains includingTrash, keying its card index by
  ItemPath so foreign purges, restores, and Empty Trash join the digest;
  crossings of the trash boundary still read deleted/restored, never
  moved, on both sides of the toggle. BoardStore.land passes the store's
  own isTrashVisible - no new injection seam.

- A vanished head with surviving co-selection is still named: naming and
  recovery are independent axes, so BoardAnnouncer's vanished-focus rung
  fires on all branches while the survivors-veto now gates only the
  recovery half (recovery implies vanished, no longer both-or-neither).

- Banner-row buttons are literal FKA Tab stops: BannerRow.controls is
  the row's testable button inventory, BannerRowView renders from it
  with .focusable() on each button; the combined VoiceOver element stays
  unconditional - custom actions and Tab stops are independent surfaces.

19 tests added, 2 expectations updated to the rulings. 1607 green on
both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 11:37:46 -04:00
parent 065c6f0678
commit 28ca2c3f50
8 changed files with 614 additions and 66 deletions
+36 -18
View File
@@ -68,9 +68,13 @@ public enum BoardAnnouncer {
/// A reload's effect on focus: what to say about it, and where to put it.
///
/// Both halves or neither, always: the sentence names what vanished and the recovery is where
/// the user is left, and a design that produced one without the other would either move focus
/// silently or describe a move that did not happen.
/// **Naming and recovery are independent axes** (10-accessibility.md, ruled 2026-07-29). A
/// recovery never arrives without a sentence describing a move without saying what caused it
/// would leave the user somewhere new for no stated reason but a sentence *can* arrive
/// without a move, and that is exactly the surviving-co-selection case: the thing under the
/// cursor was deleted, which is what the rule exists to say, while the survivors veto the move
/// because a reload never edits a selection the user still partly holds. So the pairing is
/// "recovery implies vanished", not "both or neither".
public struct FocusOutcome: Sendable, Equatable {
public var vanished: VanishedFocus?
public var recovery: FocusRecovery?
@@ -90,24 +94,29 @@ public enum BoardAnnouncer {
///
/// `focused` is the *cursor*, not the set: `TransientBoardState.selectionHead` when it is still
/// in the selection, else a sole selected item (`BoardStore.focusedItem`). 10 speaks of "the
/// selected or VO-focused card" in the singular, and a sentence naming one card out of five is a
/// worse answer than the digest.
/// selected or VO-focused card" in the singular, and naming the cursor's card out of five is
/// exactly the sentence the rule wants naming an *arbitrary* one of the five, which is what a
/// selection with no cursor could offer, is the worse answer the digest already beats.
///
/// ### Three guards, each of them a rule
/// ### Two guards and a veto, each of them a rule
///
/// - **The board container only.** A trash selection is not on the board, 10 keeps focus out of
/// the trash on principle, and there is no lane to recover to from in there.
/// - **The focus must actually be gone.** A reload that reordered the board around a surviving
/// card has nothing to announce and nothing to recover.
/// - **Survivors veto the recovery.** If any other selected item is still there, the selection
/// already sits somewhere the user chose; moving it to a lane would be the reload editing a
/// live selection, and 02-architecture.md's re-resolution rule ("vanished members leave
/// silently, no substitute is invented") stands untouched for that case. Recovery is the
/// *emptied* selection's answer, which is exactly when nothing else can be.
/// - **Survivors veto the recovery and only the recovery** (ruled 2026-07-29). If any other
/// selected item is still there, the selection already sits somewhere the user chose; moving
/// it to a lane would be the reload editing a live selection, and 02-architecture.md's
/// re-resolution rule (the head re-anchors within the surviving selection, no substitute is
/// invented) stands untouched for that case. The *sentence* is not the move's passenger,
/// though: "the thing under the cursor was deleted, and that is what the rule exists to say",
/// so the head is named whether or not anything survived it. Vanished non-head members stay
/// unnamed and fall to the digest's counts, which is what makes this the head's rule rather
/// than the set's.
///
/// The last guard is why this can be layered on `ItemReferenceSet.resolved(against:)` rather
/// than replacing it: the set rule still runs first and still invents nothing; this decides what
/// to do about the hole it leaves, which is 10's question and not the set's.
/// The veto is why this can be layered on `ItemReferenceSet.resolved(against:)` rather than
/// replacing it: the set rule still runs first and still invents nothing; this decides what to
/// say about the hole it leaves and, when the hole is the whole selection, what to do about it.
public static func focusOutcome(
old: BoardModel,
new: BoardModel,
@@ -121,13 +130,19 @@ public enum BoardAnnouncer {
let universe = ItemContainer.board.ids(in: new)
guard !universe.contains(focused) else { return .survived }
guard selection.ids.isDisjoint(with: universe) else { return .survived }
// Computed once and applied to every branch below: the three destinations differ, the veto
// over all of them does not.
let survivors = !selection.ids.isDisjoint(with: universe)
func recovery(_ destination: @autoclosure () -> FocusRecovery) -> FocusRecovery? {
survivors ? nil : destination()
}
// A focused *lane* that vanished is already the lane case no card to be displaced by.
if let lane = old.lanes.first(where: { $0.id == focused }) {
return FocusOutcome(
vanished: .lane(title: lane.title.value, cards: lane.cards.count),
recovery: successorLane(of: lane.id, old: old, new: new)
recovery: recovery(successorLane(of: lane.id, old: old, new: new))
)
}
@@ -140,11 +155,14 @@ public enum BoardAnnouncer {
}
if new.lanes.contains(where: { $0.id == home.id }) {
return FocusOutcome(vanished: .card(title: card.title.value), recovery: .lane(home.id))
return FocusOutcome(
vanished: .card(title: card.title.value),
recovery: recovery(.lane(home.id))
)
}
return FocusOutcome(
vanished: .lane(title: home.title.value, cards: home.cards.count),
recovery: successorLane(of: home.id, old: old, new: new)
recovery: recovery(successorLane(of: home.id, old: old, new: new))
)
}