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
+36 -18
View File
@@ -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))
)
}
+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 = []
+11 -1
View File
@@ -666,7 +666,17 @@ public final class BoardStore {
// as cheap as it was.
let focus: BoardAnnouncer.FocusOutcome
if origin == .foreign, !endsWholesaleOperation {
facts.diff = BoardDiff.between(snapshot, result.model)
// **The digest covers the trash only while the trash lane is shown**
// (10-accessibility.md Live board announcements, ruled 2026-07-29). This is the
// seam that reading takes: visibility is view state on the board's own transient
// container one per store, shared by every window onto this board
// (`TransientBoardState.isTrashVisible`) so the store asks it here and the
// summarizer stays a pure function of two snapshots plus one fact.
facts.diff = BoardDiff.between(
snapshot,
result.model,
includingTrash: transient.isTrashVisible
)
focus = BoardAnnouncer.focusOutcome(
old: snapshot,
new: result.model,
+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)
}
}
}
+94
View File
@@ -441,6 +441,100 @@ private final class Box {
var value = false
}
// MARK: - A row's controls
/// **"Every control" is literal and includes banner-row buttons** (10-accessibility.md Full
/// Keyboard Access, ruled 2026-07-29): "a Dismiss or Cancel on a banner must be a Tab stop 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."
///
/// The Tab loop itself is the focus system's and needs a window; what is assertable headlessly and
/// what the strip renders straight from (`BannerStripView.trailingControls`) is **which** controls
/// a row has and in what order, which is the half that could silently go missing.
@MainActor
@Suite("BannerRow ▸ controls")
struct BannerRowControlsTests {
@Test("A cancelable in-progress row's one control is Cancel, and it runs the caller's closure")
func inProgressRowsOfferCancel() throws {
let cancelled = Box()
let row = BannerRow.inProgress(
InProgressOperation(label: "Importing 24 attachments…", cancel: { cancelled.value = true })
)
#expect(row.controls.map(\.label) == ["Cancel"], "a spinner is not dismissable — it completes or is cancelled")
guard case let .cancel(action) = try #require(row.controls.first) else {
Issue.record("expected a cancel control")
return
}
action()
#expect(cancelled.value)
}
@Test("A git bracket's row offers no control at all — no Cancel, nothing to dismiss")
func uncancelableInProgressRowsOfferNothing() {
let row = BannerRow.inProgress(InProgressOperation(label: "Pulling…"))
#expect(row.controls.isEmpty)
}
@Test("Every dismissable row offers Dismiss, carrying the id the center dismisses by")
func dismissableRowsOfferDismiss() throws {
let banner = OneShotBanner(error: error(.move(title: "Fix login")))
let loss = LossBanner(message: "Pasted 'Fix login' without its 3 attachments")
let signpost = InfoSignpost(message: "This card changed on the remote — your edits still win")
for (row, id) in [
(BannerRow.oneShot(banner), banner.id),
(BannerRow.loss(loss), loss.id),
(BannerRow.signpost(signpost), signpost.id),
] {
#expect(row.controls.map(\.label) == ["Dismiss"])
guard case let .dismiss(carried) = try #require(row.controls.first) else {
Issue.record("expected a dismiss control")
return
}
#expect(carried == id, "the button dismisses this row, not whichever row is first")
}
}
/// "A condition is never dismissed while it is still true" the rows that heal on their own
/// carry no button, so they contribute no tab stop either.
@Test("Condition rows carry no controls")
func conditionRowsCarryNoControls() {
let rows: [BannerRow] = [
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadError(path: "Todo/index.md", reason: .missingOrder)),
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
]
for row in rows {
#expect(row.controls.isEmpty, "\(row.id) is a condition — it heals, it is not waved away")
}
}
/// The inventory is the view's source of truth, so it has to agree with `dismissID`, which the
/// center's own dismissal path uses. One row, one answer.
@Test("The inventory agrees with dismissID on every row class")
func inventoryAgreesWithDismissID() {
let rows: [BannerRow] = [
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadError(path: "Todo/index.md", reason: .missingOrder)),
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
.historySuspended(HistorySuspension(reason: "disk full")),
.inProgress(InProgressOperation(label: "Pulling…")),
.signpost(InfoSignpost(message: "This card changed on the remote")),
]
for row in rows {
let dismisses = row.controls.contains { if case .dismiss = $0 { true } else { false } }
#expect(dismisses == (row.dismissID != nil), "\(row.id)")
}
}
}
// MARK: - Phrasing
@MainActor
+101 -3
View File
@@ -205,9 +205,11 @@ struct BoardAnnouncerFocusTests {
}
/// 02-architecture.md's re-resolution rule stands where it always did: a selection with
/// survivors is still the user's selection, and a reload may not re-aim it.
@Test("Surviving selection members veto the recovery")
func survivorsVetoRecovery() throws {
/// survivors is still the user's selection, and a reload may not re-aim it. **The sentence is
/// not the move's passenger** (ruled 2026-07-29): the head still vanished, and that is what the
/// rule exists to say.
@Test("Surviving selection members veto the recovery — and only the recovery")
func survivorsVetoRecoveryButNotTheSentence() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.snapshot()
@@ -220,6 +222,47 @@ struct BoardAnnouncerFocusTests {
focused: card1ID
)
#expect(outcome.vanished == .card(title: "Fix login"), "the thing under the cursor was deleted")
#expect(outcome.recovery == nil, "the survivors hold the selection where the user put it")
}
/// The lane rung of the same ruling: naming and recovery are independent whichever subject the
/// composition picked.
@Test("A vanished head's lane is still named when co-selected cards elsewhere survive")
func survivorsVetoTheLaneRecoveryButNotItsSentence() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.snapshot()
try FileManager.default.removeItem(at: fixture.url(lane1))
let outcome = BoardAnnouncer.focusOutcome(
old: before,
new: try fixture.snapshot(),
selection: selection([card1ID, card3ID]),
focused: card1ID
)
#expect(outcome.vanished == .lane(title: "Todo", cards: 2))
#expect(outcome.recovery == nil, "card 3 survived in Doing — the reload may not walk the selection away")
}
/// The complement, and the reason the head is the subject rather than the set: a *co-selected*
/// card vanishing under a surviving cursor is nothing to announce it falls to the digest's
/// counts, unnamed.
@Test("A vanished non-head member says nothing — the cursor is what the sentence is about")
func vanishedNonHeadIsTheDigestsBusiness() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.snapshot()
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card2)"))
let outcome = BoardAnnouncer.focusOutcome(
old: before,
new: try fixture.snapshot(),
selection: selection([card1ID, card2ID]),
focused: card1ID
)
#expect(outcome == .survived)
}
@@ -526,6 +569,61 @@ struct BoardAnnouncerStoreTests {
#expect(store.transient.selection.ids == [card2ID])
}
/// Both halves of the 2026-07-29 ruling at the seam that has to honour them together: the
/// sentence names the head, and the selection is left exactly where the user's surviving
/// members hold it.
@Test("A vanished head with a surviving co-selection is named but never moved")
func vanishedHeadWithSurvivorsIsNamed() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1ID, card2ID], in: .board, head: card1ID)
let log = listen(to: store)
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
await reload(store)
#expect(log.lines == ["Card 'Fix login' was deleted externally"])
#expect(store.transient.selection.ids == [card2ID], "no focus move — the survivor still holds it")
#expect(store.transient.selectionHead == nil, "the head re-anchors on the next arrow, per 02")
}
// MARK: The shown trash
/// **The digest covers the trash only while the trash lane is shown** (ruled 2026-07-29), at the
/// seam that reads the visibility: the store asks its own transient state, so nothing about the
/// summarizer knows what a window is.
@Test("A purge under a shown trash joins the digest")
func shownTrashPurgeIsAnnounced() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(Ident.card4, order: "1024", title: "Old")
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
let log = listen(to: store)
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card4)"))
await reload(store)
#expect(log.lines == ["Board changed: 1 card deleted"])
}
@Test("The same purge under a hidden trash is silent")
func hiddenTrashPurgeIsSilent() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.trashCard(Ident.card4, order: "1024", title: "Old")
let store = try BoardStore(rootURL: fixture.root)
let log = listen(to: store)
#expect(!store.transient.isTrashVisible, "hidden on every open — visiting the trash is an errand")
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card4)"))
await reload(store)
#expect(log.lines.isEmpty)
}
// MARK: What actually gets said
@Test("A foreign reload speaks its digest exactly once")
+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")
}
}