Lanes delete into the trash — rendering, grammar, drag, clipboard, a11y

Phase 2 completes the lanes-in-trash card. TrashEntry merges the
trash's two kinds by rank in exactly ONE place (ItemPath.resolve's
own merge deleted in favor of it — the three-merge-points finding
shrinks instead of growing). TrashLaneRowView renders the opaque
row — tertiary plate, level-default lane glyph never the lane's own
icon, title + card count, no accents, no expansion; the column badge
counts rendered rows. Selection grammar: kind-homogeneous trash
selections — ranges skip the other kind, ⇧-extension stops at the
kind boundary, plain arrows walk the merged order, marquee stays
card-only (now load-bearing: rows register frames for arrows),
Select All card-scoped; successor-on-purge crosses kinds like
navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop
accepts lane sessions (drop on shown trash deletes), restoreLanes
routes a trash-sourced strip drop as an arrival-ranked within-board
move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque
lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as
the root — a same-board restore looked like an import and would
have reminted the lane it was restoring (pinned by test). A11y:
row = one flattened "title, deleted lane, N cards" element with
Delete/Reveal actions; BoardDiff crossings read lanes as
deleted/restored, shown-trash churn digested at row level. Agent
guide stays v7 — the literal already teaches lanes-trash-by-move
and kind stamping; drift-guard pins those lines. README trash
paragraph notes lanes.

Both schemes 1893 tests / 322 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 17:04:30 -04:00
parent 8014bde7c6
commit f7c8088783
26 changed files with 1825 additions and 176 deletions
+28 -7
View File
@@ -104,11 +104,31 @@ enum AccessibilityPhrases {
/// Trash: one word, never "Hide Trash"-style state in the name).
static let trashLabel = "Trash"
/// The trash container's value: its card count, filtered exactly as the lane labels' are "the
/// The trash container's value: its counts, filtered exactly as the lane labels' are "the
/// shown trash's cards participate in the filter exactly like any other card" (03-board-ui.md §
/// Trash), so the column passes the collection its badge counts.
static func trashValue(cards count: Int) -> String {
cardCount(count)
/// Trash), so the column passes the collections its badge counts.
///
/// **Both kinds are named when both are there** (lanes rejoined the trash 2026-07-29): the
/// container's rows are cards *and* opaque lane units, and a value that spoke only the cards
/// would understate what the VoiceOver cursor is about to walk into. A trash holding no lane rows
/// overwhelmingly the common case reads exactly as it always did.
///
/// It stops at counting, deliberately: the *freight* phrasing ("2 lanes containing 9 more
/// cards") belongs to the purge confirmations, where the number is what the user is about to
/// lose (`TrashModel.subject(for:)`).
static func trashValue(cards: Int, lanes: Int = 0) -> String {
guard lanes > 0 else { return cardCount(cards) }
return "\(cardCount(cards)), \(laneCount(lanes))"
}
/// A trashed lane row **one flattened opaque element**, "title, deleted lane, N cards"
/// (10-accessibility.md Trash lane, lanes rejoined 2026-07-29).
///
/// "Deleted lane" and not "lane" is the whole of what the label has to carry beyond a live lane
/// container's: the row is in the trash, it is not a container, and its N is the freight it took
/// with it rather than a filtered count of children VoiceOver could enter (`TrashedLane`).
static func trashedLaneLabel(title: String?, cards count: Int) -> String {
"\(displayTitle(title)), deleted lane, \(cardCount(count))"
}
/// What View Show Trash announces "toggling visibility is announced"
@@ -125,10 +145,11 @@ enum AccessibilityPhrases {
// MARK: - Live board announcements
/// "3 lanes", "1 lane" `cardCount`'s twin, and the second half of the digest's plural folding.
/// Spelled here rather than borrowed from `TrashModel.phrase` because that one is a cards-only
/// container's phrase by design ("Lanes are never trashed").
/// Borrowed from `TrashModel.lanePhrase` for `cardCount`'s reason: since lanes rejoined the trash
/// (2026-07-29) the purge confirmations count lanes too, and one folding means a spoken count and
/// a confirmed one cannot drift.
static func laneCount(_ count: Int) -> String {
"\(count) lane\(count == 1 ? "" : "s")"
TrashModel.lanePhrase(count)
}
/// The digest's opening and, on its own, the whole sentence for a change no bucket counts.