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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user