From 28ca2c3f50923e6876ccf9b417967304b5d79a56 Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 29 Jul 2026 11:37:46 -0400 Subject: [PATCH] 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 --- Kanban/LiveStore/BannerCenter.swift | 52 ++++++++ Kanban/LiveStore/BoardAnnouncer.swift | 54 +++++--- Kanban/LiveStore/BoardDiff.swift | 149 ++++++++++++++++++----- Kanban/LiveStore/BoardStore.swift | 12 +- Kanban/UI/BannerStripView.swift | 46 +++++-- KanbanTests/BannerCenterTests.swift | 94 ++++++++++++++ KanbanTests/BoardAnnouncerTests.swift | 104 +++++++++++++++- KanbanTests/BoardDiffTests.swift | 169 +++++++++++++++++++++++++- 8 files changed, 614 insertions(+), 66 deletions(-) diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 5018c0e..648d167 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -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 diff --git a/Kanban/LiveStore/BoardAnnouncer.swift b/Kanban/LiveStore/BoardAnnouncer.swift index f910eeb..dde7ca5 100644 --- a/Kanban/LiveStore/BoardAnnouncer.swift +++ b/Kanban/LiveStore/BoardAnnouncer.swift @@ -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)) ) } diff --git a/Kanban/LiveStore/BoardDiff.swift b/Kanban/LiveStore/BoardDiff.swift index e017c95..22905d0 100644 --- a/Kanban/LiveStore/BoardDiff.swift +++ b/Kanban/LiveStore/BoardDiff.swift @@ -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 = [] /// 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 = [] /// 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 = [] - /// 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 = [] 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 = [] diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index e9e6360..ee067f2 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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, diff --git a/Kanban/UI/BannerStripView.swift b/Kanban/UI/BannerStripView.swift index e5a5979..b90a0ed 100644 --- a/Kanban/UI/BannerStripView.swift +++ b/Kanban/UI/BannerStripView.swift @@ -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) } } } diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift index f20f7a0..c66175b 100644 --- a/KanbanTests/BannerCenterTests.swift +++ b/KanbanTests/BannerCenterTests.swift @@ -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 diff --git a/KanbanTests/BoardAnnouncerTests.swift b/KanbanTests/BoardAnnouncerTests.swift index 235b9fc..bfcb94c 100644 --- a/KanbanTests/BoardAnnouncerTests.swift +++ b/KanbanTests/BoardAnnouncerTests.swift @@ -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") diff --git a/KanbanTests/BoardDiffTests.swift b/KanbanTests/BoardDiffTests.swift index d1bddc2..dfbf67c 100644 --- a/KanbanTests/BoardDiffTests.swift +++ b/KanbanTests/BoardDiffTests.swift @@ -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") + } +}