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
+167 -2
View File
@@ -251,8 +251,8 @@ struct BoardDiffTests {
// MARK: - The trash and the backstop
/// A purge moves nothing on the board, and the trash column is hidden by default not worth
/// interrupting a VoiceOver user for.
@Test("Churn inside the trash is not a change to the board")
/// interrupting a VoiceOver user for. The other half of the ruling is `ShownTrashDiffTests`.
@Test("Churn inside the hidden trash is not a change to the board")
func trashChurnIsSilent() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -281,3 +281,168 @@ struct BoardDiffTests {
#expect(!diff.isSilent)
}
}
// MARK: - The shown trash
/// **The digest covers the trash only while the trash lane is shown** (10-accessibility.md Live
/// board announcements, ruled 2026-07-29): "a foreign purge, restore, or Empty Trash joins the
/// digest like any lane's churn a user working in the shown trash must hear it emptied under
/// them; while hidden, trash churn stays silent".
///
/// Every case is asserted **both ways round the toggle**, because the ruling is a claim about the
/// difference between them and because the property the implementation leans on is that the flag
/// widens the universe without re-wording a single event a hidden trash could already see
/// (`BoardDiff.between(_:_:includingTrash:)`).
@Suite("BoardDiff ▸ the shown trash")
struct ShownTrashDiffTests {
@Test("A purge is a deletion while the trash is shown, and silence while it is hidden")
func purgeCountsWhileShown() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(card4, order: "1024", title: "Old")
let before = try fixture.snapshot()
try FileManager.default.removeItem(at: fixture.url(".trash/\(card4)"))
let after = try fixture.snapshot()
#expect(BoardDiff.between(before, after, includingTrash: true).cards.deleted == [ItemID(rawValue: card4)])
#expect(BoardDiff.between(before, after).isSilent)
}
/// The sentence the ruling is written around: "a user working in the shown trash must hear it
/// emptied under them".
@Test("Empty Trash reads as its cards deleted, one per card")
func emptyTrashCountsItsCards() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(card4, order: "1024", title: "Old")
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
let before = try fixture.snapshot()
try FileManager.default.removeItem(at: fixture.url(".trash"))
let after = try fixture.snapshot()
let diff = BoardDiff.between(before, after, includingTrash: true)
#expect(diff.cards.deleted == [ItemID(rawValue: card4), ItemID(rawValue: card1)])
#expect(diff.cards.added.isEmpty && diff.cards.moved.isEmpty)
#expect(BoardDiff.between(before, after).isSilent)
}
@Test("A card dropped straight into the shown trash by an outside writer is an arrival")
func arrivalIntoTheTrashCountsWhileShown() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.snapshot()
try fixture.trashCard(card4, order: "1024", title: "Filed by an agent")
let after = try fixture.snapshot()
#expect(BoardDiff.between(before, after, includingTrash: true).cards.added == [ItemID(rawValue: card4)])
#expect(BoardDiff.between(before, after).isSilent)
}
@Test("A trash card retitled in place is an edit while the trash is shown")
func editInsideTheTrashCountsWhileShown() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(card4, order: "1024", title: "Old")
let before = try fixture.snapshot()
try fixture.trashCard(card4, order: "1024", title: "Renamed in the trash")
let after = try fixture.snapshot()
#expect(BoardDiff.between(before, after, includingTrash: true).cards.edited == [ItemID(rawValue: card4)])
#expect(BoardDiff.between(before, after).isSilent)
}
@Test("A trash card reordered in place is a move while the trash is shown")
func reorderInsideTheTrashCountsWhileShown() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(card4, order: "1024", title: "Old")
let before = try fixture.snapshot()
try fixture.trashCard(card4, order: "4096", title: "Old")
let after = try fixture.snapshot()
#expect(BoardDiff.between(before, after, includingTrash: true).cards.moved == [ItemID(rawValue: card4)])
#expect(BoardDiff.between(before, after).isSilent)
}
/// The backstop follows the buckets: a stamp bumped on a trash card changes nothing anyone can
/// see, but the snapshot did differ, and while the column is shown that is a mutating board.
@Test("A bumped stamp on a trash card trips the backstop only while the trash is shown")
func trashBackstopFollowsVisibility() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(card4, order: "1024", title: "Old")
let before = try fixture.snapshot()
try fixture.item(
".trash/\(card4)",
"---\nschema: 1\ntitle: Old\norder: 1024\nmodified: 2026-07-29T09:00:00Z\n---\n\n"
)
let after = try fixture.snapshot()
let shown = BoardDiff.between(before, after, includingTrash: true)
#expect(shown.boardChanged)
#expect(shown.cards.isEmpty, "a stamp is not a change a card's face can show")
#expect(BoardDiff.between(before, after).isSilent)
}
// MARK: The crossings same word on both sides of the toggle
/// "A card entering the trash already counts as a card deleted which is the user's own reading
/// of both." Showing the column must not re-word a delete into "1 card moved".
@Test("A delete is a departure whether the trash is shown or hidden")
func deleteIsADepartureEitherWay() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.snapshot()
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
let after = try fixture.snapshot()
for shown in [true, false] {
let diff = BoardDiff.between(before, after, includingTrash: shown)
#expect(diff.cards.deleted == [ItemID(rawValue: card1)], "shown: \(shown)")
#expect(diff.cards.moved.isEmpty && diff.cards.added.isEmpty, "shown: \(shown)")
}
}
@Test("A restore is an arrival whether the trash is shown or hidden")
func restoreIsAnArrivalEitherWay() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(card4, order: "1024", title: "Old")
let before = try fixture.snapshot()
try fixture.move(".trash/\(card4)", toLane: lane2, card: card4)
let after = try fixture.snapshot()
for shown in [true, false] {
let diff = BoardDiff.between(before, after, includingTrash: shown)
#expect(diff.cards.added == [ItemID(rawValue: card4)], "shown: \(shown)")
#expect(diff.cards.moved.isEmpty && diff.cards.deleted.isEmpty, "shown: \(shown)")
}
}
/// The implied-events rule reaches the crossings: an agent that empties a lane into the trash
/// and removes the folder made one change to the board, and a shown trash must not turn it into
/// "1 lane deleted, 2 cards deleted".
@Test("A lane emptied into the shown trash and removed is still just the lane's event")
func deletedLaneStillSwallowsItsCardsWhenTheyLandInTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.snapshot()
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
try fixture.move("\(lane1)/\(card2)", toTrash: card2)
try FileManager.default.removeItem(at: fixture.url(lane1))
let diff = BoardDiff.between(before, try fixture.snapshot(), includingTrash: true)
#expect(diff.lanes.deleted == [ItemID(rawValue: lane1)])
#expect(diff.cards.deleted.isEmpty, "the two cards left with their lane — that is the lane's event")
}
}