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