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
+116 -33
View File
@@ -32,7 +32,7 @@ import Foundation
/// spoken digest only ever needs `count`, but a semantic commit message needs to name the items, and
/// a summarizer that threw the ids away would have to be rewritten rather than extended.
///
/// ### Two counting rules worth stating
/// ### Three counting rules worth stating
///
/// - **Implied events don't steal the subject** (06-history-undo.md's composer discipline, which 10
/// applies to speech in as many words). A lane that vanished takes its cards with it, and a board
@@ -43,13 +43,18 @@ import Foundation
/// agent that re-files a card *and* retitles it made one change to the board, and two fragments
/// counting the same card would read as two cards. Position wins because it is the change the
/// board's shape shows.
/// - **Crossing the trash boundary is a departure or an arrival, never a move** see
/// `between(_:_:includingTrash:)`, whose flag decides whether the trash is walked at all but never
/// what an event that touches it is *called*.
public struct BoardDiff: Sendable, Equatable {
/// One kind's four buckets. Disjoint by construction see the type's note on precedence.
public struct Changes: Sendable, Equatable {
/// Identities present in the new snapshot and not the old minus the ones whose arrival is
/// implied by their lane's.
/// Identities that **arrived on the visible board**: present in the new snapshot and not the
/// old, minus the ones whose arrival is implied by their lane's plus, while the trash is
/// being walked, the cards that crossed *out* of it, which is a restore (the crossing rule,
/// `between(_:_:includingTrash:)`).
public var added: Set<ItemID> = []
/// Identities present on both sides whose *rendered content* differs title, body, style,
@@ -59,13 +64,16 @@ public struct BoardDiff: Sendable, Equatable {
public var edited: Set<ItemID> = []
/// Identities present on both sides that changed position a different `order`, or (for a
/// card) a different lane.
/// card) a different lane. **Never a trash crossing**, which is a delete or a restore rather
/// than a re-filing however visible the column happens to be.
public var moved: Set<ItemID> = []
/// Identities present in the old snapshot and not the new minus the ones whose departure
/// is implied by their lane's. "Deleted" rather than "removed" because that is what it is
/// Identities that **left**: present in the old snapshot and not the new, minus the ones
/// whose departure is implied by their lane's plus, while the trash is being walked, the
/// cards that crossed *into* it. "Deleted" rather than "removed" because that is what it is
/// from the board's side: deletion is a move into `.trash/` (01-storage-format.md §
/// Deletion), and a folder moved clean out of the board reads the same way to a user.
/// Deletion), and a folder moved clean out of the board reads the same way to a user. A
/// purge and an Empty Trash land here too, once the trash is in the universe at all.
public var deleted: Set<ItemID> = []
public init() {}
@@ -81,11 +89,10 @@ public struct BoardDiff: Sendable, Equatable {
/// Whether the board side of the snapshot differs **at all** the backstop for changes no
/// bucket counts: a renamed board, an edited board description, a bumped stamp.
///
/// **The trash is excluded on purpose.** A card entering the trash already counts as a card
/// deleted (it left the board's universe) and one leaving it counts as added, which is the
/// user's own reading of both; churn *inside* the trash a purge, an Empty Trash run by an
/// agent moves nothing on the board and is not worth interrupting for. See the report's note:
/// a visible trash column changing under a VoiceOver user is a design gap 10 does not rule on.
/// **The trash counts here exactly when it is being diffed at all** `between(_:_:includingTrash:)`'s
/// flag reaches this too, so a trash card whose stamp an agent bumped trips the backstop while
/// the column is shown and is silent while it is hidden. Same rule as the buckets, one line
/// above them.
public var boardChanged = false
public init() {}
@@ -98,9 +105,42 @@ public struct BoardDiff: Sendable, Equatable {
/// The whole summarizer: two snapshots in, one grouped diff out. Pure, total, and free of any
/// notion of *why* the tree changed `WatchOrigin` decides whether anyone is told
/// (`BoardAnnouncer`), never this.
public static func between(_ old: BoardModel, _ new: BoardModel) -> BoardDiff {
///
/// ### `includingTrash` the digest's universe, not its vocabulary
///
/// **The digest covers the trash only while the trash lane is shown** (10-accessibility.md
/// Live board announcements, ruled 2026-07-29): with View Show Trash on, trash cards are
/// ordinary elements of the visible board, "so 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". Visibility is per-board view state
/// (`TransientBoardState.isTrashVisible`), which is why it arrives as an argument: this stays a
/// pure function of two snapshots plus one fact, and the *reading* of that fact belongs to the
/// reload seam that has a store to ask (`BoardStore.land`).
///
/// The flag widens the **universe** which cards are walked and deliberately nothing else.
/// The digest a shown trash produces is therefore always a superset of the hidden one's, with
/// every event both can see named identically:
///
/// - A card **entering** the trash is `deleted` and one **leaving** it is `added`, shown or
/// hidden. That is the user's own reading of a delete and a restore, and it is not something
/// flipping a view toggle should be able to re-word into "1 card moved" (the crossing rule,
/// above). Restores therefore already joined the digest before this ruling; the ruling's news
/// is the churn that never left the container.
/// - A **purge** and an **Empty Trash** are `deleted`, an outside writer dropping a folder
/// straight into `.trash/` is `added`, and a trash card retitled or reordered in place is
/// `edited` or `moved` all of them silent while the column is hidden, because then the trash
/// is not in the universe at all.
///
/// The implied-events rule reaches the crossings too: a lane deleted by moving its cards into
/// `.trash/` and then removing the folder is still one event, "1 lane deleted", not that plus
/// its cards.
public static func between(
_ old: BoardModel,
_ new: BoardModel,
includingTrash: Bool = false
) -> BoardDiff {
var diff = BoardDiff()
diff.boardChanged = boardSideDiffers(old, new)
diff.boardChanged = boardSideDiffers(old, new, includingTrash: includingTrash)
let oldLanes = laneIndex(of: old)
let newLanes = laneIndex(of: new)
@@ -120,24 +160,30 @@ public struct BoardDiff: Sendable, Equatable {
}
}
let oldCards = cardIndex(of: old)
let newCards = cardIndex(of: new)
let oldCards = cardIndex(of: old, includingTrash: includingTrash)
let newCards = cardIndex(of: new, includingTrash: includingTrash)
for (id, entry) in newCards where oldCards[id] == nil {
// The implied-arrival rule: a card that came in with a brand-new lane is the lane's
// event.
guard !diff.lanes.added.contains(entry.lane) else { continue }
diff.cards.added.insert(id)
noteArrival(of: id, at: entry.home, in: &diff)
}
for (id, entry) in oldCards where newCards[id] == nil {
// The implied-departure rule, and the one the vanishing-focus announcement leans on:
// when a lane goes, the announcement names the lane and its count, not five cards.
guard !diff.lanes.deleted.contains(entry.lane) else { continue }
diff.cards.deleted.insert(id)
noteDeparture(of: id, from: entry.home, in: &diff)
}
for (id, newEntry) in newCards {
guard let oldEntry = oldCards[id] else { continue }
if oldEntry.lane != newEntry.lane || oldEntry.card.order != newEntry.card.order {
if oldEntry.home.container != newEntry.home.container {
// **The crossing rule.** Both sides of the walk hold this card, so the position axis
// would ordinarily claim it but a card that crossed into `.trash/` is a *delete*
// and one that came back out is a *restore*, and those are the words a user would
// use for what they just watched happen. Routed through the same two notes as an
// outright arrival and departure so the implied-events rule covers them for free:
// an agent that empties a lane into the trash and removes it announces the lane.
if newEntry.home.container == .trash {
noteDeparture(of: id, from: oldEntry.home, in: &diff)
} else {
noteArrival(of: id, at: newEntry.home, in: &diff)
}
} else if oldEntry.home != newEntry.home || oldEntry.card.order != newEntry.card.order {
diff.cards.moved.insert(id)
} else if contentDiffers(oldEntry.card, newEntry.card) {
diff.cards.edited.insert(id)
@@ -147,22 +193,53 @@ public struct BoardDiff: Sendable, Equatable {
return diff
}
/// A card that is on the visible board now and was not before counted unless **its lane's own
/// arrival implies it** (a card that came in with a brand-new lane is the lane's event).
///
/// A trash home has no such implication to check: the trash is a standing container, never one
/// that arrives, so a folder an outside writer dropped into a shown `.trash/` is its own event.
private static func noteArrival(of id: ItemID, at home: ItemPath, in diff: inout BoardDiff) {
if case let .card(lane, _) = home, diff.lanes.added.contains(lane) { return }
diff.cards.added.insert(id)
}
/// The mirror, and the rule the vanishing-focus announcement leans on: when a lane goes, the
/// announcement names the lane and its count, not five cards.
private static func noteDeparture(of id: ItemID, from home: ItemPath, in diff: inout BoardDiff) {
if case let .card(lane, _) = home, diff.lanes.deleted.contains(lane) { return }
diff.cards.deleted.insert(id)
}
// MARK: - Indices
private static func laneIndex(of snapshot: BoardModel) -> [ItemID: Lane] {
Dictionary(uniqueKeysWithValues: snapshot.lanes.map { ($0.id, $0) })
}
/// Every board-side card, with the lane it sits in. The trash is not walked: its cards are not
/// on the board, and the two crossings that matter (in and out) are already visible as a
/// departure and an arrival from this universe.
private static func cardIndex(of snapshot: BoardModel) -> [ItemID: (lane: ItemID, card: Card)] {
var index: [ItemID: (lane: ItemID, card: Card)] = [:]
/// Every card the digest can see, with **where it sits** the lane it is in, or the trash.
///
/// `ItemPath` rather than a bare lane id because the home is now two-valued and the comparison
/// asks three questions of it: which container (the crossing rule), which lane (the move axis),
/// and whether that lane is itself new or gone (the implied-events rule). The type that already
/// spells "a card in a lane, or a card in the trash" answers all three, and a hand-rolled
/// optional-lane pair could spell a fourth thing that does not exist.
///
/// With `includingTrash` off the trash is simply not walked, which is what makes trash churn
/// silent rather than something filtered back out downstream.
private static func cardIndex(
of snapshot: BoardModel,
includingTrash: Bool
) -> [ItemID: (home: ItemPath, card: Card)] {
var index: [ItemID: (home: ItemPath, card: Card)] = [:]
for lane in snapshot.lanes {
for card in lane.cards {
index[card.id] = (lane.id, card)
index[card.id] = (.card(lane: lane.id, id: card.id), card)
}
}
guard includingTrash else { return index }
for card in snapshot.trash {
index[card.id] = (.trashCard(card.id), card)
}
return index
}
@@ -197,12 +274,18 @@ public struct BoardDiff: Sendable, Equatable {
|| old.width != new.width
}
/// Whether anything on the board side differs, trash excluded.
/// Whether anything the digest can see differs the whole value while the trash is being
/// walked, everything but the trash while it is not.
///
/// `trash` is the one `var` on `BoardModel`, which is what makes "everything except the trash"
/// expressible as value equality rather than as a second field-by-field list that would go
/// stale the moment the model grows a field.
private static func boardSideDiffers(_ old: BoardModel, _ new: BoardModel) -> Bool {
private static func boardSideDiffers(
_ old: BoardModel,
_ new: BoardModel,
includingTrash: Bool
) -> Bool {
guard !includingTrash else { return old != new }
var oldSansTrash = old
var newSansTrash = new
oldSansTrash.trash = []