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
296 lines
16 KiB
Swift
296 lines
16 KiB
Swift
import Foundation
|
|
|
|
// MARK: - BoardDiff
|
|
|
|
/// **What changed between two snapshots**, as identities grouped by what happened to them — the
|
|
/// summarizer 10-accessibility.md ▸ Live board announcements asks for ("one polite digest per
|
|
/// reload debounce — 'Board changed: 2 cards edited, 1 card added'").
|
|
///
|
|
/// ### Snapshots, not a journal
|
|
///
|
|
/// The watcher reports *that* the tree changed, never *what* (`WatcherEvent`), and 02-architecture.md
|
|
/// is explicit about why that is the right shape: "a value-type snapshot comparison is both cheaper
|
|
/// and more trustworthy than trying to reconstruct a tree mutation from a stream of paths that may
|
|
/// have been coalesced, dropped, or reordered". So this is a comparison of two `BoardModel` values
|
|
/// and nothing else — no FSEvents paths, no receipts, no git. That is also what makes it work on a
|
|
/// no-git board, which 10 requires in as many words ("on no-git boards the same classifier runs
|
|
/// without the committer").
|
|
///
|
|
/// ### Why it lives in `LiveStore/` and not beside the announcement
|
|
///
|
|
/// 10 says announcements "reuse the auto-committer's summarizer" and 06-history-undo.md says the
|
|
/// committer synthesizes its commit messages from the same comparison. The committer is Pro-edition
|
|
/// work (pro-m1) and does not exist yet, so the summarizer is built here first, in the layer both
|
|
/// consumers can reach: base compiles `Kanban/`, Pro compiles `Kanban/` *plus* `KanbanPro/`
|
|
/// (12-editions.md ▸ Targets), so a type in the store layer is available to the committer by
|
|
/// construction while a type in `KanbanPro/` would not be available to the announcer. It imports
|
|
/// `Foundation` alone and touches no view, no window, and no `NSAccessibility` — the phrasing is
|
|
/// `AccessibilityPhrases`' job, and pro-m1's message composer will phrase the very same counts
|
|
/// differently without either of them knowing about the other.
|
|
///
|
|
/// The per-category **identity sets** rather than bare tallies are for that second consumer: a
|
|
/// 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.
|
|
///
|
|
/// ### 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
|
|
/// that says "1 lane deleted, 5 cards deleted" has reported one event twice. So a card is only
|
|
/// counted as added or deleted when its lane was *there on both sides*; cards arriving with a new
|
|
/// lane and cards leaving with a deleted one are the lane's event, not their own.
|
|
/// - **Every changed identity lands in exactly one bucket**, with `moved` outranking `edited`. An
|
|
/// 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 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,
|
|
/// attachments, lane width. Deliberately not `modified`, `modified-by`, or unknown keys: a
|
|
/// digest describes what the board looks like, and a bumped timestamp changes nothing a
|
|
/// user could see. Deliberately not `order` either, which is the `moved` axis.
|
|
public var edited: Set<ItemID> = []
|
|
|
|
/// Identities present on both sides that changed position — a different `order`, or (for a
|
|
/// 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 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. 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() {}
|
|
|
|
public var isEmpty: Bool {
|
|
added.isEmpty && edited.isEmpty && moved.isEmpty && deleted.isEmpty
|
|
}
|
|
}
|
|
|
|
public var cards = Changes()
|
|
public var lanes = Changes()
|
|
|
|
/// 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 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() {}
|
|
|
|
/// Nothing to say: no counted change, and no uncounted one either.
|
|
public var isSilent: Bool { !boardChanged && cards.isEmpty && lanes.isEmpty }
|
|
|
|
// MARK: - The comparison
|
|
|
|
/// 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.
|
|
///
|
|
/// ### `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, includingTrash: includingTrash)
|
|
|
|
let oldLanes = laneIndex(of: old)
|
|
let newLanes = laneIndex(of: new)
|
|
|
|
for id in newLanes.keys where oldLanes[id] == nil {
|
|
diff.lanes.added.insert(id)
|
|
}
|
|
for id in oldLanes.keys where newLanes[id] == nil {
|
|
diff.lanes.deleted.insert(id)
|
|
}
|
|
for (id, newLane) in newLanes {
|
|
guard let oldLane = oldLanes[id] else { continue }
|
|
if oldLane.order != newLane.order {
|
|
diff.lanes.moved.insert(id)
|
|
} else if contentDiffers(oldLane, newLane) {
|
|
diff.lanes.edited.insert(id)
|
|
}
|
|
}
|
|
|
|
let oldCards = cardIndex(of: old, includingTrash: includingTrash)
|
|
let newCards = cardIndex(of: new, includingTrash: includingTrash)
|
|
|
|
for (id, entry) in newCards where oldCards[id] == nil {
|
|
noteArrival(of: id, at: entry.home, in: &diff)
|
|
}
|
|
for (id, entry) in oldCards where newCards[id] == nil {
|
|
noteDeparture(of: id, from: entry.home, in: &diff)
|
|
}
|
|
for (id, newEntry) in newCards {
|
|
guard let oldEntry = oldCards[id] else { continue }
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 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] = (.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
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
/// A card's rendered content, field by field rather than by whole-value equality.
|
|
///
|
|
/// Spelled out because whole-value equality would fold in `order` (the `moved` axis) and the
|
|
/// `modified`/`modified-by` stamps, so every reorder would also read as an edit and every
|
|
/// touch-only rewrite would announce a change nobody could see. A rendered field added to `Card`
|
|
/// belongs on this list — the cost of the explicitness, recorded so it is not rediscovered.
|
|
private static func contentDiffers(_ old: Card, _ new: Card) -> Bool {
|
|
old.schema != new.schema
|
|
|| old.title != new.title
|
|
|| old.body != new.body
|
|
|| old.background != new.background
|
|
|| old.icon != new.icon
|
|
|| old.iconColor != new.iconColor
|
|
|| old.attachments != new.attachments
|
|
}
|
|
|
|
/// A lane's rendered content — the card's list, minus attachments (a lane has none) plus
|
|
/// `width`, which is a lane's own visible property (03-board-ui.md § Lane). A lane's *cards*
|
|
/// are diffed as cards, never folded in here.
|
|
private static func contentDiffers(_ old: Lane, _ new: Lane) -> Bool {
|
|
old.schema != new.schema
|
|
|| old.title != new.title
|
|
|| old.body != new.body
|
|
|| old.background != new.background
|
|
|| old.icon != new.icon
|
|
|| old.iconColor != new.iconColor
|
|
|| old.width != new.width
|
|
}
|
|
|
|
/// 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,
|
|
includingTrash: Bool
|
|
) -> Bool {
|
|
guard !includingTrash else { return old != new }
|
|
var oldSansTrash = old
|
|
var newSansTrash = new
|
|
oldSansTrash.trash = []
|
|
newSansTrash.trash = []
|
|
return oldSansTrash != newSansTrash
|
|
}
|
|
}
|