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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user