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
|
||||
|
||||
Reference in New Issue
Block a user