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
+37 -9
View File
@@ -154,7 +154,12 @@ private struct BannerRowView: View {
.background(row.tone.fill)
// One element per row, tone included: a VoiceOver user must hear *that* this is an error
// before hearing what the error is, and colour cannot carry that. The dismiss and Cancel
// buttons survive as custom actions of the combined element rather than as separate stops.
// buttons stay inside the combined element, where VoiceOver surfaces them as the row's
// custom actions **and are Tab stops in their own right besides** (see `trailingControls`:
// 10-accessibility.md Full Keyboard Access, ruled 2026-07-29). The two are independent
// surfaces, so the combine is unconditional: nothing here is uncombined under FKA, because
// the focus loop does not need the AX tree's permission to include a button and a VoiceOver
// user would otherwise hear a different row depending on a keyboard setting.
//
// The label is composed by `AccessibilityPhrases` rather than spelled here because the two
// standing conditions are also *announced* on arrival and clearance (10-accessibility.md
@@ -180,26 +185,49 @@ private struct BannerRowView: View {
}
}
/// The row's buttons, rendered straight from `BannerRow.controls` **the Tab loop the FKA
/// ruling asks for** (10-accessibility.md Full Keyboard Access, 2026-07-29: "'Every control'
/// is literal and includes banner-row buttons FKA serves sighted keyboard-only users, to whom
/// VO custom actions are invisible, and Cancel on an in-progress operation is exactly the
/// control that cannot require a pointer").
///
/// Driven from the row's inventory rather than from a pair of `if`s over the same data, so what
/// a row's controls *are* is one testable fact (`BannerCenterTests`) instead of a view detail
/// that a headless suite cannot see.
@ViewBuilder
private var trailingControls: some View {
if case let .inProgress(operation) = row, let cancel = operation.cancel {
ForEach(row.controls) { control in
button(for: control)
// **A literal tab stop**, and the focus ring left on to show it the style editor's
// wells' rule, for its reason: these are controls, not window furniture, so unlike
// the board strip (`BoardView`) there is nothing here to suppress. Stated on the
// button rather than left to the button style, because `.plain` and `.link` render
// as bare content and the combine above puts them inside another accessibility
// element: the focus item is asked for outright so neither can quietly cost the row
// its keyboard reach.
.focusable()
}
}
@ViewBuilder
private func button(for control: BannerRowControl) -> some View {
switch control {
case let .cancel(cancel):
// Cancel appears on safe copies only (02, settled): it means "remove the partial copy,
// nothing lost". Git brackets pass no closure and therefore get no button.
Button("Cancel", action: cancel)
Button(control.label, action: cancel)
.buttonStyle(.link)
.font(.callout)
}
if let dismissID = row.dismissID {
case let .dismiss(id):
Button {
onDismiss(dismissID)
onDismiss(id)
} label: {
Image(systemName: "xmark")
.imageScale(.small)
}
.buttonStyle(.plain)
.accessibilityLabel("Dismiss")
.help("Dismiss")
.accessibilityLabel(control.label)
.help(control.label)
}
}
}