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
+52
View File
@@ -238,6 +238,58 @@ public enum BannerRow: Identifiable, Sendable {
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
}
}
/// **This row's buttons, in the order Tab visits them** Cancel, then Dismiss.
///
/// It exists because 10-accessibility.md Full Keyboard Access rules the banner's buttons in by
/// name (2026-07-29): "'Every control' is literal and includes banner-row buttons a Dismiss or
/// Cancel on a banner must be a Tab stop Cancel on an in-progress operation is exactly the
/// control that cannot require a pointer". A claim about *which* controls a row has is then a
/// fact about the row's data rather than about a view's `if` ladder, so it can be pinned
/// headlessly and the strip can render straight from it (`BannerStripView`) which is the same
/// posture the rest of this type already takes ("the per-kind affordances hang off the row's
/// data, not off separate views").
///
/// No row has both today: the two conditions are disjoint by construction (only an in-progress
/// row cancels, and an in-progress row is never dismissable). The order is stated anyway, since
/// it is the Tab order the moment one does.
public var controls: [BannerRowControl] {
var controls: [BannerRowControl] = []
if case let .inProgress(operation) = self, let cancel = operation.cancel {
controls.append(.cancel(cancel))
}
if let dismissID {
controls.append(.dismiss(dismissID))
}
return controls
}
}
// MARK: - A row's buttons
/// One button on a banner row the strip's whole vocabulary of per-row controls, as data.
///
/// **Identified by its label**, which is legitimate rather than lazy here: a row carries at most one
/// of each kind, the label is the user-facing name of exactly that kind, and it is what both
/// surfaces the ruling cares about need the button's title (Cancel) or its accessibility label
/// (Dismiss, whose face is an glyph).
public enum BannerRowControl: Identifiable, Sendable {
/// Stop the operation this row is reporting and remove its partial work carried by cancelable
/// in-progress rows only (`InProgressOperation`: "safe copies only").
case cancel(@MainActor @Sendable () -> Void)
/// Clear this row, by the id `BannerCenter.dismiss(_:)` takes.
case dismiss(UUID)
public var label: String {
switch self {
case .cancel: "Cancel"
case .dismiss: "Dismiss"
}
}
public var id: String { label }
}
// MARK: - BannerCenter