Materialize the trash — faces, menus, and grammar

Phase 3 finishes the pivot at the surface. One card face serves two
containers: CardFaceView extracted with a role — board or trash — so
stripe, tint, chip, selection stroke, cut dim, marquee registration,
and drag are shared by construction, the trash side differing only in
its absences: no Open, no rename, no Style, no file-hover highlight,
and a Delete that goes through the confirmation host. The column
rewrote around the lanes' own single-column masonry so drag reflow
reads as positional slides; chrome stays the hatched header, symbol,
and count — 11 gives Empty Trash to the File menu alone. Two real
grammar bugs die here: plain Backspace on a trash selection purged
without the confirmation the menu raises, and the context menu's
Delete resolved against the standing selection, so right-clicking a
trash card under a board selection silently did nothing — it now
stages the clicked set explicitly. Open, Rename, Style, and Empty
Trash validation became testable store seams; the column is one named
accessibility container of ordinary card elements. The tombstone era
is swept: deleteItem, restoreItem, stripTombstonedChildren — dead
since lane copies stopped nesting trash — the restore verb, the
unreachable put-back banner row, and every quasi-lane doc comment.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 18:18:39 -04:00
parent 53bc71f7fb
commit 797d020d01
34 changed files with 1272 additions and 1422 deletions
+1 -1
View File
@@ -316,7 +316,7 @@ private final class DuplicateCancellation {
/// to a command the thing 04's contract forbids.
///
/// **The board scope reveals either side of the trash boundary and ignores every lock.** Reveal "is
/// not edit-shaped and stays enabled on tombstoned selections" (04 The trash), and inspection is a
/// not edit-shaped and stays enabled on trash selections" (04 The trash), and inspection is a
/// read, so neither the read-only lock nor the focused-editor rule applies the same posture the
/// trash row's own Reveal takes. A selection whose ids resolve to no folders (one the next reload
/// will drop) disables rather than falling back to the root: revealing the wrong thing is worse
+5 -9
View File
@@ -626,19 +626,15 @@ public final class AppModel {
/// The lane and card counts stamped into the registry at close **live items only** (02
/// § Per-board app state, settled).
///
/// > tombstoned lanes and cards and cards hidden beneath a tombstoned lane don't count; the
/// > row advertises the board's working size, and the trash is an errand, not inventory.
///
/// The nesting is the ancestor walk: a tombstoned lane is skipped whole, so its cards are never
/// reached whatever their own flags say. `Lane.isDeleted`/`Card.isDeleted` are presence-of-key,
/// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere
/// else.
/// > deleted lanes and cards don't count; the row advertises the board's working size, and the
/// > trash is an errand, not inventory.
///
/// **`.trash/` is excluded by construction** (02-architecture.md § Per-board app state,
/// re-grounded 2026-07-28 for the materialized trash): this walks `snapshot.lanes`, and the
/// trash is `snapshot.trash` a sibling container, never a lane so no filter is needed and
/// none could be forgotten. The flag walk above is the retiring half of the same rule, kept
/// while boards written by older versions still carry `deleted:` keys.
/// none could be forgotten. The tombstone era's ancestor walk over `deleted:` flags is gone with
/// the flag; a board an older version wrote counts its unmigrated cards until the migration moves
/// them, which is the safe direction and lasts exactly one write.
///
/// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
/// discover a counting bug.
+4 -4
View File
@@ -9,10 +9,10 @@ import Foundation
/// board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's
/// fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it
/// describes)".
/// - **Tombstoned items are carried too** (03, settled): "Duplicate is a full fork, trash included
/// dropping them would leave the copy's working tree disagreeing with its own copied HEAD". This
/// file does nothing to achieve that: a tombstone is a `deleted:` key inside a file, so a copy
/// carries it by declining to be clever.
/// - **`.trash/` is carried too** (03, settled, re-grounded 2026-07-28 for the materialized trash):
/// "Duplicate is a full fork, `.trash/` included dropping it would leave the copy's working tree
/// disagreeing with its own copied HEAD". This file does nothing to achieve that: the trash is an
/// ordinary folder under the root, so the tree walk carries it by declining to be clever.
/// - **`.git` comes along** a duplicate of a git board is a fork of its history with only its
/// remote configuration stripped, which is m7's.
/// - Timestamps, unknown keys, strays, `CLAUDE.user.md`, attachments: verbatim, for the same reason.
+1 -1
View File
@@ -16,7 +16,7 @@ import Foundation
// Application Support, each folder loaded through `BoardLoader` `name`/`blurb`/`icon` off the
// template board's own `index.md`, order off its `template.order`, an unloadable user template still
// listed (by folder name, marked unloadable, carrying the loader's specifics) but not instantiable.
// `laneTitles` stops existing at that point: instantiation becomes a tree copy that skips tombstones,
// `laneTitles` stops existing at that point: instantiation becomes a tree copy that skips `.trash/`,
// mints fresh GUIDs, and stamps `created`/`modified` fresh (`BoardWriter.CopyStamps.born`), never
// copying `.git`. The chooser's mini preview renders from the loaded `BoardModel` rather than from
// these strings.
+16 -13
View File
@@ -21,7 +21,7 @@ public struct CardPlacement: Equatable {
/// A named decision rather than a scattering of `if`s, because 05-card-window.md Deletion &
/// lifecycle and 02-architecture.md § Live-reload resilience state the same rule from two directions
/// and both have to be true of one piece of code. Making it a value also makes it a *pure* function
/// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window.
/// of a snapshot, which is the only way the deleted-lane case gets tested without a window.
public enum CardWindowFate: Equatable {
case shows(CardPlacement)
case dismisses
@@ -38,10 +38,10 @@ public enum CardWindowFate: Equatable {
/// nothing the second time, which is what makes those two paths safe to both exist.
///
/// **What ending means now**: the Edit buffer's debounce is cancelled and its text written the
/// "window close" third of 05's flush rule, and on a dismissal caused by a tombstone the surgical
/// body write 05 Deletion & lifecycle promises ("a dirty Edit buffer flushes into the tombstoned
/// card's folder before the window dismisses ... so the keystrokes survive Put Back").
/// `BoardStore.writeCardBody` resolves tombstoned cards on purpose for exactly this.
/// "window close" third of 05's flush rule, and on a dismissal caused by a delete the surgical body
/// write 05 Deletion & lifecycle promises ("a dirty Edit buffer flushes into the card's folder at
/// its new `.trash/` location before the window dismisses ... so the keystrokes survive a later
/// restore"). `BoardStore.writeCardBody` resolves trash cards on purpose for exactly this.
///
/// A *failing* close flush is not this object's problem to solve: it is `DirtyBufferGuard`'s modal
/// moment, which the host runs earlier, on `windowShouldClose`, while there is still a window to
@@ -100,10 +100,11 @@ final class CardWindowSession: CardSessionFlushing {
/// ### Its whole identity is `(board, card)`
///
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
/// lanes for free the key names neither and it dismisses in the three cases where the key stops
/// naming anything: the card is tombstoned, its *lane* is tombstoned (effective liveness is
/// ancestor-walked, 02 § Live-reload resilience), or the card is simply not in this board's snapshot
/// any more, which is what a cross-board move looks like from here.
/// lanes for free the key names neither and it dismisses whenever the key stops naming a card
/// **on the board**: the card moved into `.trash/` ("entering the trash counts as deleted"
/// 05-card-window.md Deletion & lifecycle, resettled 2026-07-28), its *lane* was deleted and took
/// it along, or the card is simply not in this board's snapshot any more, which is what a cross-board
/// move looks like from here.
///
/// ### It can never outlive its board window
///
@@ -164,10 +165,12 @@ struct CardWindowHost: View {
/// Whether a card window keyed on `cardID` still has a card, given this board's snapshot.
///
/// The three dismissal cases collapse into two lines: a card that is not in the snapshot is gone
/// (deleted outright, or moved to another board the board half of the key no longer names it),
/// and a card whose **effective** liveness is trashed renders nowhere, whether the tombstone is
/// its own or its lane's. Only a live card in a live lane keeps its window.
/// **One walk over the lanes is the whole rule** (05-card-window.md Deletion & lifecycle,
/// resettled 2026-07-28 the materialized trash): deletion is a *move*, so a trashed card has
/// physically left its lane and answers `.dismisses` by simply not being found "entering the
/// trash counts as deleted", with no liveness flag to read and no ancestor walk to run. A card
/// whose lane was deleted, one purged outright and one moved to another board all fall out of the
/// same absence. Only a card in one of this board's lanes keeps its window.
///
/// Takes the id as the ref stores it a raw folder name and compares it as an `ItemID`, so two
/// case-spellings of one UUID are one card here exactly as they are everywhere else.
+6 -5
View File
@@ -29,7 +29,7 @@ extension UTType {
///
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
/// live-XOR-tombstoned invariants are exactly the ones those two types already carry. Their raw
/// board-XOR-trash invariants are exactly the ones those two types already carry. Their raw
/// spellings are pasteboard API a manifest written before a quit is decoded after the relaunch.
///
/// `entries` are in the order the copy read them flatten order on the live side ("lane `order`,
@@ -83,10 +83,11 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
/// A **lane** entry's cards, index text and all "a lane entry embeds its cards' too,
/// attachment-less". Empty for a card entry.
///
/// **Live cards only**, which is not a shortcut: a lane *copy* strips tombstoned cards
/// (04-interactions.md Clipboard, The trash), and the fallback only ever materializes a
/// copy a cut's move carries the real folder whole and never comes near this array. So the
/// embedded set is exactly what a fallback paste should produce.
/// **Exactly the lane's cards**, which needs no filter: "a lane carries exactly its cards
/// the trash is board-level, so there is nothing lane-nested to strip or carry"
/// (04-interactions.md Drag and drop, resettled 2026-07-28), and the fallback only ever
/// materializes a copy a cut's move carries the real folder whole and never comes near this
/// array. So the embedded set is exactly what a fallback paste should produce.
public var cards: [Card]
/// One card inside a copied lane.
+1 -1
View File
@@ -38,7 +38,7 @@ import os
/// X stages, writes the pasteboard, and arms the source board's `transient.pendingCut` the items
/// dim in place. The cut is **armed** while the pasteboard still holds its `copyID`, the source store
/// is still open, and the pending cut still names something; "deletion voids per item" needs no code
/// here at all, because `TransientBoardState.resolve` already ejects a tombstoned or vanished member
/// here at all, because `TransientBoardState.resolve` already ejects a member that moved to the trash or vanished
/// on every reload, so "paste moves only the survivors" is the reload rule read at paste time.
/// Voiding undims and downgrades the paste to a copy from staging.
///
+1 -1
View File
@@ -42,7 +42,7 @@ struct FutureCommand: View {
///
// m9-templates: copies the open board into the user templates store, close-flushed first exactly as
// Duplicate is (09 Save as Template: "The copy is preceded by the close flush"), `.git` stripped,
// tombstones dropped, a `template:` key stamped. Validation will be `acceptsBoardMutations` plus 09's
// `.trash/` excluded, a `template:` key stamped. Validation will be `acceptsBoardMutations` plus 09's
// one carve-out from the read-only lock live under the unwritable-location state unless an open
// Edit/raw-source session holds unsaved content so it cannot simply borrow
// `DuplicateBoardCommand`'s predicate outright.
+6 -3
View File
@@ -38,10 +38,13 @@ public enum HistoryPhrase {
public enum Verb: String, Sendable, CaseIterable {
/// A create File New Lane, the new-card placeholder's commit, a Finder file drop's cards.
case add = "Add"
/// A tombstone (, drop-on-trash, the card window's Actions Delete).
/// A delete , drop-on-trash, the card window's Actions Delete which is a *move* into
/// `.trash/` for a card and a physical removal for a lane (03-board-ui.md § Trash).
///
/// **There is no `restore` verb**: restoring is an ordinary move out (drag or X/V), so it
/// registers as `move` like any other, and Put Back is retired with the tombstone model
/// (resettled 2026-07-28).
case delete = "Delete"
/// Put Back and drag-to-restore.
case restore = "Restore"
/// A drop that changes an item's parent.
case move = "Move"
/// A drop, a lane drag or / that changes rank among unchanged siblings.
+3 -6
View File
@@ -631,9 +631,8 @@ public final class BannerCenter {
/// does not: the enum knows an item's title, never its *kind*, so an untitled failure says
/// "the item" rather than guessing "card" and being wrong about a lane.
///
/// The trash verbs match the commands the user pressed **Delete**, Put Back, Delete
/// Immediately which is 03-board-ui.md § Trash's naming constraint, settled with the trash UI
/// copy: "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says
/// The trash verbs match the commands the user pressed **Delete**, Delete Immediately, Empty
/// Trash which is 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says
/// 'Delete'". A banner saying a card could not be *moved to the trash* would name the wrong one
/// of the app's two trashes (the card window's attachment Remove is the other).
private nonisolated static func actionPhrase(for operation: WriteOperation) -> String {
@@ -652,8 +651,6 @@ public final class BannerCenter {
if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
case let .delete(title):
if let title { "Couldn't delete '\(title)'" } else { "Couldn't delete the item" }
case let .restore(title):
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
case let .purge(title):
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
case let .migrateTombstone(title):
@@ -683,7 +680,7 @@ public final class BannerCenter {
// it. The naming constraint above reserves "move to the Trash" for the *system* Trash,
// and this is the operation that uses it: the file really did go (or fail to go) where
// Finder's own sends things, so saying anything else "remove", "delete" would
// describe the board's quasi-lane instead and promise the wrong recovery.
// describe the board's own trash instead and promise the wrong recovery.
"Couldn't move '\(filename)' to the Trash"
case .renumberChildren:
"Couldn't renumber cards"
+14 -130
View File
@@ -786,60 +786,6 @@ public enum BoardWriter: Sendable {
}
}
/// Physically removes every tombstoned card from a **just-copied** lane, and reports which
/// the tail of a lane copy (04-interactions.md Drag and drop: "A lane copy **strips
/// tombstoned cards**: the copy transfers content, and trash isn't content"; the same rule
/// governs a pasted lane copy).
///
/// **Removed, not tombstoned.** These folders were minted seconds ago by `copyItem` and were
/// never content in this board, so there is nothing here for a Put Back to recover and no
/// tombstone to leave standing the tombstoned *originals* stay recoverable in the source
/// board, which is where the recovery story lives. A lane **move** carries them whole and never
/// calls this: the folder travels as-is and its tombstones land in the destination's trash by
/// rendering.
///
/// **Only ever pointed at a fresh copy.** `copyItem` has no filter hook it copies the tree
/// verbatim by design, which is what makes attachments and strays arrive byte-identical so
/// the strip is a second step rather than a parameter, and a caller that aimed it at a lane the
/// user actually owns would be destroying their trash. Every call site in the app is the line
/// after a `copyItem` that materialized the folder.
///
/// A child whose `index.md` is missing or unreadable is **left alone**: the liveness question
/// cannot be answered for it, and the conservative direction is to keep the folder the same
/// leniency `copyItem` extends below its root. Liveness is read exactly as the loader reads it
/// (a present `deleted` key, malformed or not).
///
/// The operation vocabulary is `.copy`, not `.purge`: the user pressed nothing called "delete",
/// and a failure here must say the app could not copy the lane (02-architecture.md §
/// Write-failure surfacing).
@discardableResult
public static func stripTombstonedChildren(of laneFolder: URL) throws(BoardWriteError) -> [ItemID] {
let operation = WriteOperation.copy(title: nil)
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
try checkIsUUIDShaped(laneFolder, operation: operation)
var removed: [ItemID] = []
for child in childCandidates(of: laneFolder) {
let indexURL = child.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path),
let document = try? readDocument(at: indexURL, operation: operation),
!document.deleted.isMissing
else { continue }
do {
try FileManager.default.removeItem(at: child)
} catch {
throw BoardWriteError(
operation: operation,
path: child.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
removed.append(ItemID(rawValue: child.lastPathComponent))
}
return removed
}
/// Materializes an item from **supplied `index.md` text** rather than from a folder on disk
/// the clipboard's staging-less fallback (04-interactions.md Clipboard: "if the staged
/// snapshot is missing or unreadable at paste time, paste falls back to the embedded
@@ -1391,82 +1337,20 @@ public enum BoardWriter: Sendable {
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
}
// MARK: - Tombstone (retired, awaiting removal)
//
// The tombstone model is retired (01-storage-format.md § Deletion, resettled 2026-07-28): the
// app's delete is the physical move above, and no `deleted:` key is ever written again.
//
// **These have no app callers left.** Every consumer moved across with the store swap the
// delete is `deleteCardToTrash`, the lane delete is `removeLane`, the restore is an ordinary
// `moveItem`, and the legacy keys are handled by the two `migrate` calls above. They are kept
// here for exactly one more beat because `purgeItem` below is still live (Delete Immediately's
// board-side purge) and the three read as one family; the pair and
// `stripTombstonedChildren` go together in the trash's cleanup pass, with the suites that
// still pin their byte-level behaviour.
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md`
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
/// renames, and nothing beneath it is touched: hiding the subtree is the renderer's
/// ancestor walk, not a stored flag, so deleting a lane rewrites *only* the lane's own
/// file its cards' files are exactly as they were.
///
/// **Board-root deletion is structurally unreachable at this layer**: `checkIsUUIDShaped`
/// the same guard `moveItem`/`copyItem` lean on refuses any folder whose name isn't
/// UUID-shaped, and a board root never is (§ Board naming). A board-level `deleted:` key
/// is legal-but-meaningless per the frontmatter table (the loader ignores and warns on
/// it), but this call is simply never able to *produce* one: it has no board-root code
/// path to fall through, only a refusal.
///
/// Deleting an **already-tombstoned** item is not refused it just refreshes the
/// timestamp, a harmless rewrite (the gesture happened again; this layer does not police
/// liveness, the store's UI does). Goes through `updateIndex`, so the usual contract
/// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by`
/// cleared, atomic replace.
public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.delete(title: nil)
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
try checkIsUUIDShaped(itemFolder, operation: operation)
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
document.set(FrontmatterKeys.deleted, to: .date(Date()))
}
}
/// Put Back: removes the `deleted` key, undoing exactly what `deleteItem` wrote.
/// **Position-perfect by construction** the folder never moved, so the item simply
/// re-enters the visible set at its recorded `order` among its current siblings
/// (01-storage-format.md § Deletion). `FrontmatterDocument.remove` takes *every*
/// occurrence of the key, so a hand-duplicated `deleted` line cannot resurrect the
/// tombstone the instant the winning occurrence is gone.
///
/// Restoring an item that **isn't** tombstoned is not refused it is a harmless stamped
/// rewrite, the same shrug `deleteItem` gives an already-deleted item: this layer does not
/// police liveness (a second, independent liveness check here could only drift from the
/// store UI's own, which is what actually decides whether Put Back is offered at all).
public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.restore(title: nil)
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
try checkIsUUIDShaped(itemFolder, operation: operation)
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
document.remove(FrontmatterKeys.deleted)
}
}
/// Physical removal Delete Immediately / Empty Trash (03-board-ui.md): deletes the
/// folder tree from disk. Irreversible, and distinct from tombstoning this call does
/// **not** require the item to be tombstoned first, since Delete Immediately skips the
/// tombstone stage by design.
/// folder tree from disk. Irreversible, and distinct from the ordinary delete, which is a
/// *move* into `.trash/` this call does **not** require the item to be in the trash first,
/// since Delete Immediately "skips the trash from anywhere" by design.
///
/// **A folder that is already gone is success, not an error** checked first, before the
/// shape guard below. A Finder deletion converges on exactly the end state a purge would
/// produce (01-storage-format.md § Deletion, "a folder that disappears without a
/// tombstone... is also a delete"), so there is nothing left here to distinguish: a stray
/// path that never existed and a once-real item someone already threw away in Finder both
/// purge cleanly, silently, without inspecting what used to be there.
/// produce (01-storage-format.md § Deletion, "a folder that disappears ... is also a
/// delete"), so there is nothing left here to distinguish: a stray path that never existed
/// and a once-real item someone already threw away in Finder both purge cleanly, silently,
/// without inspecting what used to be there.
///
/// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability
/// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this
/// `moveItem`/`copyItem` rely on: a board root or a stray never purges through this
/// call, only a lane or a card.
public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.purge(title: nil)
@@ -1837,7 +1721,7 @@ public enum BoardWriter: Sendable {
/// The order of checks is the contract:
///
/// 1. **`cardFolder` must be an existing, UUID-shaped directory** attachments belong to
/// cards, the same shape guard `deleteItem`/`restoreItem`/`purgeItem` lean on
/// cards, the same shape guard `moveItem`/`copyItem`/`purgeItem` lean on
/// (`checkIsUUIDShaped`): a lane or a board root is refused before anything else happens.
/// 2. **`attachments/` is created if missing** (`.io` naming `cardFolder` on failure) the
/// one exception to "subfolders are never created by the app" (§ Attachments); every
@@ -2400,10 +2284,12 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case reorder(title: String?)
case copy(title: String?)
/// / a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
/// outright (`removeLane`). Still the word the user pressed; the retiring tombstone write
/// shares it while it lasts.
/// outright (`removeLane`). The word the user pressed, whichever staging it took.
///
/// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed
/// restore says the app couldn't *move* the card which is exactly what it couldn't do
/// (03-board-ui.md § Trash, resettled 2026-07-28 Put Back is retired with the tombstone model).
case delete(title: String?)
case restore(title: String?)
case purge(title: String?)
/// A legacy `deleted:` key being migrated away a card relocating into `.trash/` with the key
@@ -2511,7 +2397,6 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .reorder: .reorder(title: title)
case .copy: .copy(title: title)
case .delete: .delete(title: title)
case .restore: .restore(title: title)
case .purge: .purge(title: title)
case .migrateTombstone: .migrateTombstone(title: title)
case .style: .style(title: title)
@@ -2539,7 +2424,6 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .reorder(title): Self.phrase("reorder", title)
case let .copy(title): Self.phrase("copy", title)
case let .delete(title): Self.phrase("delete", title)
case let .restore(title): Self.phrase("restore", title)
case let .purge(title): Self.phrase("purge", title)
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
case let .style(title): Self.phrase("style", title)
+64 -44
View File
@@ -138,19 +138,7 @@ struct OpenCardCommand: View {
private var isEnabled: Bool {
guard let store, opener?.open != nil else { return false }
return store.isEditingInline || soleSelectedCard != nil
}
/// The sole selected **board card**, or `nil`. A lane, a multi-selection and a trash
/// selection all answer `nil` "everything edit-shaped is disabled on trash selections Open
/// Card, Rename, Style" (04 The trash), and a card window is tied to one card.
private var soleSelectedCard: ItemID? {
guard let store else { return nil }
let selection = store.selection
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil
else { return nil }
return id
return store.isEditingInline || store.openCardTarget != nil
}
private func open() {
@@ -176,7 +164,61 @@ struct OpenCardCommand: View {
return
}
if let card = soleSelectedCard { open(card) }
if let card = store.openCardTarget { open(card) }
}
}
// MARK: - The three edit-shaped targets, as pure predicates
/// **Everything edit-shaped refuses a trash selection** (04-interactions.md The trash: "Everything
/// edit-shaped is disabled on trash selections Open Card, Rename, Style"), and each of the three
/// answers that with one expression used for both its `disabled` state and its action the
/// `newCardTarget` idiom, for its reason: two derivations of a rule are two chances to disagree.
///
/// They live on the store rather than inside the three menu rows so the grammar can be pinned
/// without a menu (`SelectionGrammarTests`) the same reason `TrashModel`'s validation is a pure
/// function of a snapshot and a selection. A view-private predicate is a rule nobody can test.
extension BoardStore {
/// Board Open Card's target: the sole selected **board card**, or `nil`. A lane, a
/// multi-selection and a trash selection all answer `nil` a card window is tied to one card,
/// and trash cards don't open ("double-click stops at selection; move it out first" 03 §
/// Trash).
///
/// Deliberately free of `acceptsBoardMutations`: opening a window is not a mutation, and the
/// item's own mid-edit branch is the focused-editor rule's one carve-out (`OpenCardCommand`).
var openCardTarget: ItemID? {
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
Self.boardItem(id, in: snapshot)?.cardID != nil
else { return nil }
return id
}
/// Board Rename's target: the sole selected board item, card or lane, with the title to seed
/// the editor with or `nil`. A trash selection never enables it, which `ItemReferenceSet`'s
/// container answers directly.
var renameTarget: (id: ItemID, title: String?)? {
guard acceptsBoardMutations else { return nil }
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
let item = Self.boardItem(id, in: snapshot)
else { return nil }
return (id: id, title: item.title)
}
/// Board Style's target: the selected board items, or **the board itself** when nothing is
/// selected "Board window: selected cards or lane; nothing selected = the board".
///
/// **A trash selection disables it rather than falling through to the board**: quietly restyling
/// the board because the user had a trashed card selected would be the silent retarget 03
/// forbids.
var boardStyleTarget: StyleTarget? {
guard acceptsBoardMutations else { return nil }
guard !selection.isEmpty else { return .board }
guard selection.container == .board else { return nil }
// Re-resolved against the snapshot on the way in, so the session starts out holding only
// items that render the same universe its own reload rule will hold it to.
let live = selection.resolved(against: snapshot).ids
return live.isEmpty ? nil : .items(live)
}
}
@@ -187,7 +229,7 @@ struct OpenCardCommand: View {
/// half alongside the lane-width pair).
///
/// **Validation and action read one answer** (`BoardStore.sortPlan`), the width pair's rule: the
/// items disable on everything the design calls inert a lane selection, a tombstoned selection, a
/// items disable on everything the design calls inert a lane selection, a trash selection, a
/// card selection spanning lanes ("cards never change lanes by -arrow") and additionally on a
/// block already at its lane's end, where the only outcome would be a silent no-op.
///
@@ -227,7 +269,7 @@ struct MoveCardCommands: View {
/// multi-lane move has no single unambiguous meaning ("one slot" for a discontiguous pair is not one
/// answer). So the items validate on exactly one selected live lane.
///
/// **Never into the trash** costs nothing: the quasi-lane is not in the live lane order, so a step
/// **Never into the trash** costs nothing: the column is not in the lane order, so a step
/// past the last real lane is simply off the end which is also the disable rule at the walls,
/// following the width stepper's floor style rather than letting the store no-op silently.
///
@@ -391,19 +433,10 @@ struct BoardRenameCommand: View {
var body: some View {
Button("Rename") {
guard let store, let target = renameTarget else { return }
guard let store, let target = store.renameTarget else { return }
store.transient.beginRename(of: target.id, currentTitle: target.title)
}
.disabled(renameTarget == nil)
}
private var renameTarget: (id: ItemID, title: String?)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
let item = BoardStore.boardItem(id, in: store.snapshot)
else { return nil }
return (id: id, title: item.title)
.disabled(store?.renameTarget == nil)
}
}
@@ -420,32 +453,19 @@ struct BoardRenameCommand: View {
///
/// Validation is `acceptsBoardMutations` the lock and the focused-editor rule, the latter naming
/// Style in its own list of board-scoped commands (04-interactions.md Grammar) plus one rule of
/// its own: **a tombstoned selection disables it rather than falling through to the board.**
/// Everything edit-shaped is disabled on tombstoned selections (04 The trash), and quietly
/// restyling the board because the user had a trashed card selected would be the silent retarget
/// 03 forbids.
/// its own: **a trash selection disables it rather than falling through to the board**
/// (`BoardStore.boardStyleTarget`, where both halves live).
struct BoardStyleCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Style…") {
guard let store, let target = styleTarget else { return }
guard let store, let target = store.boardStyleTarget else { return }
store.transient.beginStyleEditor(for: target)
}
.keyboardShortcut("s", modifiers: [.option, .command])
.disabled(styleTarget == nil)
}
private var styleTarget: StyleTarget? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard !selection.isEmpty else { return .board }
guard selection.container == .board else { return nil }
// Re-resolved against the snapshot on the way in, so the session starts out holding only
// items that render the same universe its own reload rule will hold it to.
let live = selection.resolved(against: store.snapshot).ids
return live.isEmpty ? nil : .items(live)
.disabled(store?.boardStyleTarget == nil)
}
}
+9 -9
View File
@@ -128,7 +128,7 @@ struct BoardDropContext {
// MARK: Re-grounding
/// **Rule 2 of the mid-drag re-grounding trio** (04-interactions.md Drag and drop): a proposal
/// whose target lane was tombstoned or vanished in a reload is invalidated tombstoned lanes are
/// whose target lane vanished in a reload is invalidated deleted lanes are
/// never drop targets the shadow withdraws, and no proposal stands until the pointer reaches a
/// live target.
///
@@ -152,7 +152,7 @@ struct BoardDropContext {
/// this strip's standard width and the cursor is the physical mouse, so neither input is a
/// measured frame (03-board-ui.md § Motion).
///
/// **The terminal slot is clamped before the trash.** The quasi-lane consumes one unit while
/// **The terminal slot is clamped before the trash.** The column consumes one unit while
/// shown and is never a landing spot for anything (04-interactions.md The trash: "no move or
/// paste ever targets the trash"), so it is absent from the slot list by construction and the end
/// slot's uncapped reach past the last real lane lands *before* it.
@@ -264,7 +264,7 @@ struct BoardDropContext {
/// **A refusal falls through to the strip's own answer rather than withdrawing the proposal**,
/// which is precisely what this column did before it had a drop target of its own: a cursor over
/// it resolves to no lane, so `retargetCardsFromStrip` holds whatever the shadows already show
/// and `retargetLanes` clamps the terminal slot in front of the quasi-lane. That is the
/// and `retargetLanes` clamps the terminal slot in front of the trash column. That is the
/// hysteresis contract (DRAG-REORDER.md § Hysteresis) and it is also the honest reading of "the
/// trash proposes nothing for you": the column declines to be a target, it does not cancel the
/// drag the user is still holding. So a lane drag reorders across the column exactly as it always
@@ -390,9 +390,9 @@ struct BoardDropContext {
/// dispatch) and the live handler for the strip's own surfaces and unlike a card session, those
/// surfaces genuinely clear the proposal rather than holding it. A cursor over a gap, the outer
/// margin, or **the trash column** is over no lane at all (`LaneLayoutMath.laneIndex` answers
/// `nil` there, since the quasi-lane is absent from the live lane list by construction), so the
/// `nil` there, since the trash column is absent from the lane list by construction), so the
/// highlight withdraws and a release refuses: "Finder file drops (attachment import) on
/// tombstoned cards are inert" and the trash column is never a target (04-interactions.md The
/// trash cards are inert" and the trash column is never a file-drop target (04-interactions.md The
/// trash).
func retargetFileFromStrip(_ info: DropInfo) {
guard acceptsFileDrop(info), let cursor = stripCursor() else {
@@ -490,7 +490,7 @@ struct BoardDropContext {
/// against whatever the last render believed:
///
/// 1. the geometry was re-derived on every sample and the proposal is what it produced;
/// 2. a proposal naming a vanished or tombstoned lane is invalidated, and **release with no valid
/// 2. a proposal naming a vanished lane is invalidated, and **release with no valid
/// proposal cancels** items return, nothing is written;
/// 3. an emptied drag cancels itself, and a partly emptied one drops the survivors.
///
@@ -531,7 +531,7 @@ struct BoardDropContext {
case .lanes:
// **A lane drag never targets the trash**, and never a masonry either a lane session
// proposes only lane slots (04-interactions.md The trash). True by construction, since
// `retargetLanes` is the only thing that proposes for one and the quasi-lane is absent
// `retargetLanes` is the only thing that proposes for one and the trash column is absent
// from its slot list; written down because a commit that trusted the container implicitly
// would be the one place the invariant could break silently.
guard target.container == .strip else {
@@ -758,7 +758,7 @@ struct StripDropDelegate: DropDelegate {
/// runs is the strip's `retargetLanes` lane reordering keeps working across the column exactly as
/// it did when the column was a hole in the strip's target;
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
/// import) on tombstoned cards are inert" ( The trash), and the column has nothing else to offer
/// import) on trash cards are inert" ( The trash), and the column has nothing else to offer
/// them no lane, no card, nothing to attach to.
struct TrashDropDelegate: DropDelegate {
@@ -783,7 +783,7 @@ struct TrashDropDelegate: DropDelegate {
context.session.proposeFile(nil)
}
/// A release on the column commits whatever stands the tombstone when the trash is the
/// A release on the column commits whatever stands the delete when the trash is the
/// proposal, and otherwise the proposal the column declined to displace, which is the same
/// "the drop lands where the shadows show" promise as anywhere else.
func performDrop(info: DropInfo) -> Bool {
+22 -19
View File
@@ -19,14 +19,14 @@ import SwiftUI
///
/// - **Lane resize** the right-edge grab strip (above). Deliberately *not* a drag session
/// (DRAG-REORDER.md § Adjacent interaction).
/// - **Drag & drop** cards, lanes and trash rows travel as **system drag sessions**, which is what
/// - **Drag & drop** cards (in either container) and lanes travel as **system drag sessions**, which is what
/// crosses window boundaries, draws the copy badge and gives the full-size replica
/// (`DragSession`, `BoardDrops.swift`, DRAG-REORDER.md). The strip owns the drop geometry
/// registry and the strip-level drop target; the lanes own theirs.
/// - **The rubber band** a drag from any empty surface sweeps a selection (`MarqueeSession`,
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
/// - **The board's fixed grammar keys** (11-command-nexus.md Fixed grammar keys) the four
/// arrows and their / modes, Return's create/rename dispatch, 's tombstone, Escape's step
/// arrows and their / modes, Return's create/rename dispatch, 's staged delete, Escape's step
/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md
/// Configurable bindings draws between what remaps and what does not.
@@ -35,7 +35,7 @@ import SwiftUI
/// carries those titles, and titles are the remapping mechanism's key, so a second row sharing one
/// is ruled out (04-interactions.md Configurable bindings).
///
/// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as
/// - **The trash column** trailing, one fixed unit, joining and leaving the width division as
/// View Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
///
/// ### The live search filter
@@ -91,7 +91,7 @@ struct BoardView: View {
@State private var marquee = MarqueeSession()
/// Where every sweepable item is drawn, in strip coordinates. Owned here because the band is
/// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`).
/// the card faces on either side only *register* into it (`MarqueeTargetRegistry`).
@State private var marqueeTargets = MarqueeTargetRegistry()
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
@@ -252,7 +252,7 @@ struct BoardView: View {
case let .lane(lane):
laneSlot(lane, standard: standard)
// "Appear/disappear is scale + fade lanes ~0.9" (03-board-ui.md § Motion).
// A create, a delete and a Put Back all reach the strip as a lane arriving in
// A create, a delete and an undo all reach the strip as a lane arriving in
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
// the reload that carried it (`Motion.reloadAnimates`) a transition with no
// animated transaction around it is simply an appearance.
@@ -267,7 +267,7 @@ struct BoardView: View {
}
}
if isTrashVisible {
// Trailing, always the quasi-lane has no position of its own to lose, which is
// Trailing, always the column has no position of its own to lose, which is
// also why it never appears in the drop proposal's inputs (those are built from
// `boardLanes`) and why the terminal slot clamps in front of it.
TrashLaneView(
@@ -436,7 +436,7 @@ struct BoardView: View {
// MARK: - Trash
/// Whether the trash quasi-lane is on screen transient, board-scoped, hidden on every open
/// Whether the trash column is on screen transient, board-scoped, hidden on every open
/// (03-board-ui.md § Trash Visibility). Read in two places (the unit total and the slot), so it
/// gets a name rather than being spelled twice.
private var isTrashVisible: Bool {
@@ -609,23 +609,26 @@ struct BoardView: View {
return .handled
}
/// **Plain tombstones the live selection** "a plain-key synonym of File Delete, kept
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md The
/// map).
/// **Plain deletes the selection** "a plain-key synonym of File Delete, kept grammar so no
/// second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md The map).
///
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain performs the same
/// delete as fixed grammar" (04-interactions.md The map, resettled 2026-07-28), and the delete
/// is staged by place inside the store (`BoardStore.deleteSelection`) rather than by two menu
/// items sharing a chord. Put Back the reason the bare key had to stay off the trash is
/// retired with the tombstone model.
/// delete as fixed grammar" (04-interactions.md The map, resettled 2026-07-28) a board
/// selection moves into `.trash/`, a trash selection deletes permanently.
///
/// **Which means the same confirmation, too.** It goes through `TrashConfirmations.requestDelete`
/// rather than straight to `BoardStore.deleteSelection`, because "the same chord deletes
/// permanently confirmation per 03's recoverability rule" and a bare key that skipped the alert
/// the menu item raises would be the one path in the app where one keystroke destroys a card
/// silently. The staging itself is still the store's the alert is the only thing this adds.
///
/// Inert while an inline editor is open, like every grammar key: the field owns as backspace,
/// and a stray one reaching the board mid-edit would delete the item being renamed.
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
// **Plain , spelled out.** The modified chords belong to the menu (Delete / Put Back),
// (Delete Immediately), (Empty Trash) and AppKit routes a key equivalent to the
// **Plain , spelled out.** The modified chords belong to the menu (Delete),
// (Delete Immediately), (Empty Trash) and AppKit routes a key equivalent to the
// menu before the view sees it. But and are nobody's key equivalent, and a fall-through
// that tombstoned the selection on a mistyped text-editing chord would be exactly the kind of
// that deleted the selection on a mistyped text-editing chord would be exactly the kind of
// accident 04-interactions.md's fixed grammar is careful to avoid.
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
return .ignored
@@ -633,7 +636,7 @@ struct BoardView: View {
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard !selection.isEmpty else { return .ignored }
store.deleteSelection()
confirmations.requestDelete(in: store)
return .handled
}
@@ -860,7 +863,7 @@ struct BoardView: View {
}
/// **/ jump to the current container's first/last card** the lane's, or the trash
/// quasi-lane's when that is where the cursor is.
/// trash column's when that is where the cursor is.
///
/// ** escalates into the lane domain** (04 Grammar, settled "the keyboard's one entry to
/// lane selection"): with the lane's first card already the sole selection, the next selects
+600
View File
@@ -0,0 +1,600 @@
import AppKit
import SwiftUI
// MARK: - The card plate's metrics
/// The card plate's geometry, spelled once because **three views draw it**: the real face
/// (`CardFaceView`), the placeholder standing in for a card on its way (`NewCardStubView`'s
/// `.awaitingArrival` face), and since the face itself is what the trash column renders nothing
/// else at all. 02-architecture.md TransientBoardState overlays makes the create handoff "read as
/// one arrival the placeholder renders at the arriving card's exact geometry/chrome", and exact is
/// only checkable if there is one set of numbers rather than two that happen to agree.
enum CardFaceMetrics {
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
/// of the card's edge rather than a bar laid over it.
static let cornerRadius: CGFloat = 8
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities). Reserved as padding whether
/// or not a stripe paints, so colouring a card never shifts its title.
static let stripeWidth: CGFloat = 4
/// The plate's inset around its content.
static let contentPadding: CGFloat = 10
/// Between the icon, the title and the attachments chip.
static let rowSpacing: CGFloat = 6
}
// MARK: - Which side of the board a face is on
/// **The one axis a card face has** which container it is drawn in, and the collaborator that
/// container's grammar needs.
///
/// 03-board-ui.md § Trash, resettled 2026-07-28: "A trashed card is an ordinary card in a special
/// place search, selection, rendering, styling, and clipboard all treat it exactly like any other
/// card". So there is **one** card face in this app, and this is the whole of what differs between
/// its two homes. Everything the two sides share the plate, the stripe, the icon tint, the
/// attachments chip, the selection treatment, the cut dim, the marquee registration, the drag is
/// shared by construction rather than by two views agreeing.
///
/// The three differences are all *absences on the trash side*, and each is 04-interactions.md The
/// trash's "everything edit-shaped is disabled on trash selections" showing up as a branch that is
/// simply not taken:
///
/// - **no Open** no double-click gesture at all ("trash cards don't open double-click stops at
/// selection"), which is also why `openCard` is the board case's payload rather than the view's;
/// - **no Rename** the inline editor is board-only (`isRenaming`), so a rename that somehow
/// targeted a trashed card would render nothing rather than open a field over it;
/// - **no Style** no popover anchor, and no Style rows in the context menu.
///
/// Plus the two that are not about editing: Finder file drops are inert over the trash ( The trash),
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
/// it needs the window's confirmation host (11-command-nexus.md Context menus' Trash cards row).
enum CardFaceRole {
/// A card in a lane. Carries the board window's card opener 's pointer twin
/// (04-interactions.md Selection).
case board(openCard: (ItemID) -> Void)
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
case trash(confirmations: TrashConfirmations)
/// Which container a click on this face selects in, which container its drag begins in, and which
/// container the rubber band sweeps it as one answer, so the three can never disagree
/// (`SelectionGrammar`: "the container is the surface's, not the item's").
var container: ItemContainer {
switch self {
case .board: .board
case .trash: .trash
}
}
}
// MARK: - Card face
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
/// (03-board-ui.md § Card face, § Styling Capabilities).
///
/// ### One face, two containers
///
/// **This is the trash's row too** (03-board-ui.md § Trash, resettled 2026-07-28 the materialized
/// trash): "a trashed card is an ordinary card in a special place rendering treat it exactly like
/// any other card". The tombstone era's compact dimmed plate is retired with the tombstones it drew;
/// a trashed card wears its style, its stripe, its icon tint and its attachments chip exactly as it
/// did in its lane, because it is the same card and the same view. What the trash takes away is
/// listed on `CardFaceRole` and nowhere else.
///
/// ### Title-only, deliberately
///
/// **No body excerpt** settled, "the face stays title-only the old 'iterate on the card face
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
/// indicator when the card has files the title dominates", which is why the paperclip is a
/// secondary-tinted caption and not a count pill: the eye should land on the title.
///
/// ### Two lenient fields, two different fallbacks
///
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
/// no** the app never offers a picker for it, but honours what an author writes). Both degrade
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
/// way to **no stripe at all** because there is no sensible default colour for "the author
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
///
/// ### One presentation, selection styling only
///
/// The face is a top-aligned title row and its two decorations the accent stripe and the
/// selection stroke are shapes in overlays. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
/// face's styling the selection stroke below and never its geometry, so the masonry never
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
/// attachment's media is the card window's job ( / double-click, 05-card-window.md), not the
/// face's the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
/// face).
struct CardFaceView: View {
let store: BoardStore
let card: Card
/// Which side of the board this face is drawn on, and what that side's grammar needs the view's
/// one axis (`CardFaceRole`).
let role: CardFaceRole
/// The strip's rubber band the registry this face registers its drawn frame into.
let marquee: MarqueeControl
/// The board window's drop machinery: this face registers its measured height into the geometry
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
let drops: BoardDropContext
/// The app-wide quick-style recents see `LaneView`'s own note.
@Environment(AppModel.self) private var appModel
/// The plate's corner radius shared with the accent stripe, which rounds its left corners to
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
/// draw this same plate for the create handoff to read as one arrival.
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities, settled in the pathfinder's
/// treatment shootout).
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
/// gestures that fire and refuse (`CardFaceRole`).
@ViewBuilder
var body: some View {
switch role {
case let .board(openCard):
face
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
// second tap recogniser on the same view makes the single click *wait* to see whether a
// second one arrives, and selection must stay instant. Simultaneous means the first click
// of the pair selects and the second opens Finder's own behaviour.
//
// **Plain only.** and double-clicks are selection gestures that happened twice; opening
// a window out from under a range the user is still building would be a surprise.
.simultaneousGesture(TapGesture(count: 2).onEnded {
guard ClickModifier.current == .plain else { return }
openCard(card.id)
})
.contextMenu { boardMenu(openCard: openCard) }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
case let .trash(confirmations):
face
.contextMenu { trashMenu(confirmations: confirmations) }
}
}
/// Everything the two containers share which, after the pivot, is the face itself.
private var face: some View {
titleRow
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardFaceMetrics.contentPadding)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
// colouring a card never shifts its title relative to its uncoloured neighbours.
.padding(.leading, stripeWidth)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
// highlights while hovered" (04-interactions.md Drag and drop), and the accent stroke is
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
// hovered card that is *also* selected still reads as the target.
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: isFileHovered ? 2.5 : 1.5
)
)
// The deferred cut's dim (04-interactions.md Clipboard: "cut items dim in place until paste
// moves them"). Above `contentShape` so the face stays fully clickable while it waits. It
// genuinely fires on the trash side too: "X works cut in the trash, paste into a lane is
// the keyboard-native restore" (04 The trash, resettled 2026-07-28).
.cutTreatment(of: card.id, in: store)
// The face being dragged out dims the same way while the session is in flight on the trash
// side, where the source stays visible: a restore is not a removal until the write lands.
// (On the board a dragged card is lifted out of the resting layout entirely, so this never
// has anything to act on there `LaneView.renderedCards`.)
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename. The modifier grammar plain replaces, toggles, ranges is
// `SelectionGrammar`'s, reached through the store's one funnel.
//
// **The container travels with the click**, and that is what keeps the one remaining
// homogeneity boundary true: a -click across it replaces rather than mixing
// (04-interactions.md The trash). A double click in the trash is two of these and nothing
// more no editor, no card window, no timer.
.onTapGesture {
store.click(SelectionTarget(id: card.id, kind: .card, container: role.container), modifier: .current)
}
// **The whole face is the drag surface** (04-interactions.md Drag and drop). `.onDrag`
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
// the session off until the pointer really moves, so selecting and opening stay instant.
.onDrag(startDrag, preview: { dragReplica })
// The card's height, for the drop model's analytic resting grid. A height is content-driven
// and does not animate under the reflow only positions do, and those are never measured
// (`LaneDropRegistry`). The trash side registers too: a trash card dragged out is an
// ordinary card session, and the shadow it opens in the destination lane should be its real
// footprint rather than the nominal guess.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
}
// MARK: - The card drag
/// Begins this card's system drag session in **its own container**, which is the whole of what
/// makes a trash card's drag a restore (04-interactions.md The trash; `DragLocality.operation`).
private func startDrag() -> NSItemProvider {
switch role.container {
case .board: startBoardCardDrag()
case .trash: startTrashCardDrag()
}
}
/// A lane card's drag (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order**
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
private func startBoardCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let snapshot = store.snapshot
let ids = draggedIDs
// Flatten order, and the lane each member currently lives in the folder path's middle
// component.
var lanesByCard: [ItemID: ItemID] = [:]
var titles: [ItemID: String] = [:]
for lane in snapshot.lanes {
for member in lane.cards where ids.contains(member.id) {
lanesByCard[member.id] = lane.id
titles[member.id] = member.title.value
}
}
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
guard !ordered.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
container: .board,
items: ordered.compactMap { id in
guard let laneID = lanesByCard[id] else { return nil }
return DragPayload.Item(
id: id.rawValue,
folder: root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true)
.path,
title: titles[id]
)
}
)
drops.session.beginCards(
ordered,
folders: payload.folders,
// The dragged items' sizes, frozen at drag start the pickup transition scales the
// replica, and its lingering "last measured frame" would mis-size the shadow and the
// span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
container: .board,
source: store
)
return payload.itemProvider()
}
/// A trash card's drag out **the restore**, and deliberately not special: an ordinary `.cards`
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same
/// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
/// out there is no restore-specific machinery and no Put Back" (03 § Trash).
///
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
/// every other card drop uses, committed by the same `moveCards`.
///
/// **Multi-drag carries the whole trash selection**, in the column's own order the order the
/// rows are drawn in, which is `order` ascending like any lane's.
private func startTrashCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let ids = draggedIDs
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
container: .trash,
items: rows.map {
DragPayload.Item(
id: $0.id.rawValue,
folder: ItemPath.trashCard($0.id).folder(under: root).path,
title: $0.title.value
)
}
)
drops.session.beginCards(
rows.map(\.id),
folders: payload.folders,
heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
container: .trash,
source: store
)
return payload.itemProvider()
}
/// What travels: the whole selection when this card is in it, else this card alone the drag's
/// half of the context-menu targeting rule, and container-scoped like everything else.
private var draggedIDs: Set<ItemID> {
let selection = store.selection
guard selection.container == role.container,
selection.ids.contains(card.id),
selection.ids.count > 1
else { return [card.id] }
return selection.ids
}
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
private var dragReplica: some View {
let count = store.selection.container == role.container && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
replicaFace
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(12)
}
/// A static rendition of the face a drag image is a snapshot, so it carries no gestures, no
/// editor and no geometry observers, and crucially no marquee registration (one built out of the
/// live face would re-register the card's frame from inside the preview's geometry and then
/// deregister it when the image went away, quietly stealing the card from the rubber band and the
/// arrow keys).
private var replicaFace: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
Text(card.title.value ?? "Untitled")
.font(.body)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
.padding(10)
.padding(.leading, stripeWidth)
.frame(width: 220, alignment: .leading)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
}
// MARK: - Context menus
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context
/// menus' Card row, in its order.
@ViewBuilder
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
// alone "a card window is tied to one card" (11-command-nexus.md), so unlike Style and
// Delete below it, this row never widens to the selection; Open never opens multiple, even
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
// first only the plain open.
Button("Open") {
openCard(card.id)
}
Divider()
// Rename: Board Rename's exact store path (`BoardRenameCommand`) `beginRename(of:
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
// this card to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked card outright.
Button("Rename") {
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
}
.disabled(!store.acceptsBoardMutations)
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
Button("Delete") {
store.delete(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
}
/// Delete and Reveal in Finder the two rows 11-command-nexus.md gives a trash card, and no
/// others ("Trash cards | Delete (permanent 03's recoverability confirm), Reveal in Finder").
///
/// **Put Back is gone** with the tombstone model: restoring is drag-out or X/V (03 § Trash).
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
/// enabled on trash selections", read-only lock included inspecting a folder before a purge is
/// exactly the errand it exists for.
///
/// The Delete row goes through the window's confirmation host rather than straight to the store,
/// because this delete is the **permanent** one and the alert is what stands between it and an
/// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`).
@ViewBuilder
private func trashMenu(confirmations: TrashConfirmations) -> some View {
Button("Delete") {
confirmations.requestTrashDelete(of: targetIDs, in: store)
}
.disabled(!store.acceptsBoardMutations)
Divider()
Button("Reveal in Finder") {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
}
}
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
/// alone standard macOS context-menu targeting, shared by Style (`styleTarget`) and Delete
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
.items(targetIDs)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly. Container-scoped, so a trash row's menu never
/// widens to a board selection and vice versa.
private var targetIDs: Set<ItemID> {
guard store.selection.container == role.container, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
}
/// The folders Reveal in Finder points at resolved in this face's container, so a trash row
/// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
private var targetFolders: [URL] {
ItemPath.resolve(targetIDs, in: role.container, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
// MARK: - Title row
private var titleRow: some View {
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
titleOrEditor
// The title takes the row's width so the indicator sits hard against the trailing
// edge and so the rename field fills the same span the title occupied.
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
}
/// The title, or the rename editor when this card is the rename target. The four exits and their
/// store calls are 04-interactions.md Grammar's, stated once in `InlineTitleField`.
@ViewBuilder
private var titleOrEditor: some View {
if isRenaming {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { store.commitRename() },
onAbandon: { store.transient.discardRename() },
// **Click-away commits** a rename's rule, and the deliberate opposite of the
// placeholder's (04-interactions.md Grammar: "focus loss = commit, matching
// the card window's title field").
onFocusLoss: { store.commitRename() },
onCommitAndOpen: {
let id = card.id
store.commitRename()
if case let .board(openCard) = role { openCard(id) }
}
)
.font(.body)
} else {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
}
}
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary the tint
/// exists to make a *hand-coloured* icon stand out from its neighbours.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: card.iconColor) {
AnyShapeStyle(color)
} else {
AnyShapeStyle(.secondary)
}
}
/// The one face chip in scope shown only when the card actually has files, and quiet enough
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
/// accessibility label rather than onto the face: it is useful to know, not to look at.
@ViewBuilder
private var attachmentsIndicator: some View {
if !card.attachments.isEmpty {
Image(systemName: "paperclip")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("\(card.attachments.count) attachments")
}
}
/// K1 · left edge stripe, painted with the resolved `background` "a card's [colour paints] a
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
/// title text never sits on a coloured fill" (03-board-ui.md § Styling Capabilities).
///
/// A value that resolves to nothing a typo'd palette name, a malformed hex, a sequence where
/// a scalar belongs draws **no stripe**, and the value stays on disk exactly as written.
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
/// content does a title wrapping across its full four lines included.
@ViewBuilder
private var accentStripe: some View {
if let color = Palette.color(for: card.background) {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
.fill(color)
.frame(width: stripeWidth)
// Decoration only: the whole plate is one click target for selection.
.allowsHitTesting(false)
}
}
// MARK: - Selection and rename plumbing
private var isSelected: Bool {
store.selection.container == role.container && store.selection.ids.contains(card.id)
}
/// Whether an external Finder file drag is hovering **this** card the attach highlight
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
/// to one implementation (`BoardDrops`).
///
/// **Never on the trash side**: "Finder file drops on trash cards are inert" (04-interactions.md
/// The trash), and the trash column's own delegate clears the file highlight rather than
/// proposing one so this is a second, structural statement of the same rule.
private var isFileHovered: Bool {
role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
}
/// **Board-only** "everything edit-shaped is disabled on trash selections Rename"
/// (04-interactions.md The trash). No path opens a rename on a trashed card, and this makes a
/// stray one render nothing rather than putting a live field over a card that cannot be edited.
private var isRenaming: Bool {
role.container == .board && store.transient.renameEditor?.targetID == card.id
}
private var draft: Binding<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
+4 -4
View File
@@ -4,8 +4,8 @@ import SwiftUI
// MARK: - The Edit menu's clipboard row
/// Edit Cut / Copy / Paste (X / C / V) on the board 11-command-nexus.md's Edit row, whose
/// scope is "Board window: cards and lanes in the trash, C copy-out only (card and lane entries),
/// X disabled text editors: standard text clipboard".
/// scope is "Board window: cards and lanes in the trash, C copies out and X/V is the keyboard
/// restore path paste never targets the trash; text editors: standard text clipboard".
///
/// ### Why this is a responder answer and not three menu items
///
@@ -57,13 +57,13 @@ extension View {
/// **Cut items dim in place until paste moves them** (04-interactions.md Clipboard).
///
/// The same reduced opacity a trash row wears while it is being dragged, and for the same reason:
/// The same reduced opacity a trash card wears while it is being dragged out, and for the same reason:
/// the item is still there, still selectable, still the user's it is simply spoken for. A cut
/// item is deliberately *not* lifted out of the layout the way a dragged one is; a Finder-style
/// deferred cut promises the board looks unchanged until the paste lands.
///
/// Membership is read straight off `TransientBoardState.pendingCut`, which is where the rules
/// already live: a reload ejects a tombstoned or vanished member (so a deleted cut card undims by
/// already live: a reload ejects a member that crossed into the trash or vanished (so a deleted cut card undims by
/// itself), and `ClipboardStore` clears the set outright when the cut is consumed or voided.
func cutTreatment(of id: ItemID, in store: BoardStore) -> some View {
opacity(store.transient.pendingCut.ids.contains(id) ? ClipboardTreatment.dimmedOpacity : 1)
+4 -4
View File
@@ -48,7 +48,7 @@ struct DropTarget: Equatable, Sendable {
// MARK: - Dropping on the trash
/// **Drop-on-trash deletes** (04-interactions.md The trash, settled 2026-07-28: "the drag becomes
/// the pointer's delete gesture release tombstones the dragged card(s), exactly the tombstone"),
/// the pointer's delete gesture release moves the dragged card(s) into `.trash/`, exactly the delete"),
/// as the two pure facts the gesture is made of (`TrashDropTests`).
///
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
@@ -70,7 +70,7 @@ enum TrashDrop {
/// refusal 04 states in its own words:
///
/// - **Lanes are not deliverable this way** "a lane drag proposes only lane slots". (The strip's
/// slot list has never contained the quasi-lane, so this is belt over braces; it is written down
/// slot list has never contained the trash column, so this is belt over braces; it is written down
/// because a guard that is only true by construction is one refactor from being false.)
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
/// dropping it back where it came from writes nothing.
@@ -78,7 +78,7 @@ enum TrashDrop {
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
/// design gives no name and no undo story. The card stays where it is.
/// - ** is refused.** Copying into the trash is not a thing the copy grammar promises the
/// original stays exactly where it was, and there is nothing to tombstone but the original.
/// original stays exactly where it was, and there is nothing to delete but the original.
/// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is
/// not rendered, so it has no drop region), and stated here so the claim is testable.
/// - **The mutating-gesture rule**, like every other write the pointer can start.
@@ -306,7 +306,7 @@ final class DragSession {
// MARK: Where it would land
/// The current proposal, or `nil` when the drag has none a fresh session before the first
/// sample, or one whose target lane was tombstoned in a reload (rule 2 of the re-grounding
/// sample, or one whose target lane vanished in a reload (rule 2 of the re-grounding
/// trio). **Release with no valid proposal cancels.**
private(set) var proposal: DropTarget?
+5 -5
View File
@@ -66,11 +66,11 @@ enum LaneLayoutMath {
/// The unit total a strip of `lanes` divides across the sum of their display units, never
/// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
///
/// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because
/// a tombstoned lane renders nowhere on the board (03-board-ui.md § Trash collapses it to a
/// single trash entry) and so consumes none of the window's width.
/// The caller decides *which* lanes: the strip passes the snapshot's, in order. There is no
/// liveness question left to ask "Cards only. Lanes are never trashed" (03-board-ui.md §
/// Trash), so every lane the snapshot holds is a lane on screen consuming its units.
///
/// **`trashUnits` is the quasi-lane's fixed one unit, and it is *only* consumed while shown**
/// **`trashUnits` is the trash column's fixed one unit, and it is *only* consumed while shown**
/// (03-board-ui.md § Trash): the trash "spans a fixed one width unit no `width` frontmatter,
/// and neither the stepper nor the edge drag applies consumed only while shown". Passing it
/// here rather than fabricating a `Lane` for the trash is what keeps that true: there is no lane
@@ -89,7 +89,7 @@ enum LaneLayoutMath {
/// included) an index into `unitCounts`, or `nil` when `x` is not over a lane at all.
///
/// **The gaps and the margins answer `nil` deliberately**, and so does everything past the last
/// lane which is where the trash quasi-lane sits. That is the whole of drag-to-restore's
/// lane which is where the trash column sits. That is the whole of drag-to-restore's
/// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land
/// squarely on a live lane writes nothing rather than guessing at the nearest one.
///
+8 -425
View File
@@ -473,9 +473,9 @@ struct LaneView: View {
CardFaceView(
store: store,
card: card,
role: .board(openCard: openCard),
marquee: marquee,
drops: drops,
openCard: openCard
drops: drops
)
case let .placeholder(phase):
NewCardStubView(store: store, phase: phase, openCard: openCard)
@@ -487,7 +487,7 @@ struct LaneView: View {
}
}
// "Appear/disappear is scale + fade (cards scale from ~0.8 )"
// (03-board-ui.md § Motion), which is how a create, a delete, a Put Back and
// (03-board-ui.md § Motion), which is how a create, a delete, a restore and
// (m5) a search filter's leavers all reach the masonry. The placeholder wears it
// too: it is the card, one round trip early. Whether any of it *performs* is
// decided upstream at the reload for the real cards (`Motion.reloadAnimates`),
@@ -636,10 +636,10 @@ struct LaneView: View {
return result
}
/// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane the
/// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
/// tombstoned lane at all.
/// **A deleted card renders nowhere, and needs no rule to**: deletion is a *move* into `.trash/`
/// (03-board-ui.md § Trash, resettled 2026-07-28), so a deleted card has physically left
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
/// effective-liveness predicate are retired with the flag they read.
///
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
/// of the resting layout at pickup and stays out until release *whatever the effective operation
@@ -811,423 +811,6 @@ enum LaneSlot: Identifiable {
}
}
// MARK: - The card plate's metrics
/// The card plate's geometry, spelled once because **two views draw it**: the real face
/// (`CardFaceView`) and the placeholder standing in for a card on its way (`NewCardStubView`'s
/// `.awaitingArrival` face). 02-architecture.md TransientBoardState overlays makes the handoff
/// "read as one arrival the placeholder renders at the arriving card's exact geometry/chrome", and
/// exact is only checkable if there is one set of numbers rather than two that happen to agree.
private enum CardFaceMetrics {
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
/// of the card's edge rather than a bar laid over it.
static let cornerRadius: CGFloat = 8
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities). Reserved as padding whether
/// or not a stripe paints, so colouring a card never shifts its title.
static let stripeWidth: CGFloat = 4
/// The plate's inset around its content.
static let contentPadding: CGFloat = 10
/// Between the icon, the title and the attachments chip.
static let rowSpacing: CGFloat = 6
}
// MARK: - Card face
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
/// (03-board-ui.md § Card face, § Styling Capabilities).
///
/// ### Title-only, deliberately
///
/// **No body excerpt** settled, "the face stays title-only the old 'iterate on the card face
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
/// indicator when the card has files the title dominates", which is why the paperclip is a
/// secondary-tinted caption and not a count pill: the eye should land on the title.
///
/// ### Two lenient fields, two different fallbacks
///
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
/// no** the app never offers a picker for it, but honours what an author writes). Both degrade
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
/// way to **no stripe at all** because there is no sensible default colour for "the author
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
///
/// ### One presentation, selection styling only
///
/// The face is a top-aligned title row and its two decorations the accent stripe and the
/// selection stroke are shapes in overlays. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
/// face's styling the selection stroke below and never its geometry, so the masonry never
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
/// attachment's media is the card window's job ( / double-click, 05-card-window.md), not the
/// face's the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
/// face).
private struct CardFaceView: View {
let store: BoardStore
let card: Card
/// The strip's rubber band the registry this face registers its drawn frame into.
let marquee: MarqueeControl
/// The board window's drop machinery: this face registers its measured height into the geometry
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
let drops: BoardDropContext
let openCard: (ItemID) -> Void
/// The app-wide quick-style recents see `LaneView`'s own note.
@Environment(AppModel.self) private var appModel
/// The plate's corner radius shared with the accent stripe, which rounds its left corners to
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
/// draw this same plate for the create handoff to read as one arrival.
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities, settled in the pathfinder's
/// treatment shootout).
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
var body: some View {
titleRow
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardFaceMetrics.contentPadding)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
// colouring a card never shifts its title relative to its uncoloured neighbours.
.padding(.leading, stripeWidth)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
// highlights while hovered" (04-interactions.md Drag and drop), and the accent stroke is
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
// hovered card that is *also* selected still reads as the target.
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: isFileHovered ? 2.5 : 1.5
)
)
// The deferred cut's dim (04-interactions.md Clipboard: "cut items dim in place until paste
// moves them"). Above `contentShape` so the face stays fully clickable while it waits.
.cutTreatment(of: card.id, in: store)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename. The modifier grammar plain replaces, toggles, ranges is
// `SelectionGrammar`'s, reached through the store's one funnel.
.onTapGesture {
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
}
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
// second tap recogniser on the same view makes the single click *wait* to see whether a
// second one arrives, and selection must stay instant. Simultaneous means the first click
// of the pair selects and the second opens Finder's own behaviour.
//
// **Plain only.** and double-clicks are selection gestures that happened twice; opening
// a window out from under a range the user is still building would be a surprise.
.simultaneousGesture(TapGesture(count: 2).onEnded {
guard ClickModifier.current == .plain else { return }
openCard(card.id)
})
// **The whole face is the drag surface** (04-interactions.md Drag and drop). `.onDrag`
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
// the session off until the pointer really moves, so selecting and opening stay instant.
.onDrag(startCardDrag, preview: { dragReplica })
// The card's height, for the drop model's analytic resting grid. A height is content-driven
// and does not animate under the reflow only positions do, and those are never measured
// (`LaneDropRegistry`).
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
}
// MARK: - The card drag
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order**
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
private func startCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let snapshot = store.snapshot
let selection = store.selection
let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(card.id)
&& selection.ids.count > 1
? selection.ids
: [card.id]
// Flatten order, and the lane each member currently lives in the folder path's middle
// component.
var lanesByCard: [ItemID: ItemID] = [:]
var titles: [ItemID: String] = [:]
for lane in snapshot.lanes {
for member in lane.cards where ids.contains(member.id) {
lanesByCard[member.id] = lane.id
titles[member.id] = member.title.value
}
}
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
guard !ordered.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
container: .board,
items: ordered.compactMap { id in
guard let laneID = lanesByCard[id] else { return nil }
return DragPayload.Item(
id: id.rawValue,
folder: root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true)
.path,
title: titles[id]
)
}
)
drops.session.beginCards(
ordered,
folders: payload.folders,
// The dragged items' sizes, frozen at drag start the pickup transition scales the
// replica, and its lingering "last measured frame" would mis-size the shadow and the
// span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
container: .board,
source: store
)
return payload.itemProvider()
}
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
private var dragReplica: some View {
let count = store.selection.container == .board && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
replicaFace
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(12)
}
/// A static rendition of the face a drag image is a snapshot, so it carries no gestures, no
/// editor and no geometry observers.
private var replicaFace: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
Text(card.title.value ?? "Untitled")
.font(.body)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
.padding(10)
.padding(.leading, stripeWidth)
.frame(width: 220, alignment: .leading)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
}
// MARK: - Context menu
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context
/// menus' Card row, in its order, complete as of m5.
@ViewBuilder
private var cardMenu: some View {
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
// alone "a card window is tied to one card" (11-command-nexus.md), so unlike Style and
// Delete below it, this row never widens to the selection; Open never opens multiple, even
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
// first only the plain open.
Button("Open") {
openCard(card.id)
}
Divider()
// Rename: Board Rename's exact store path (`BoardRenameCommand`) `beginRename(of:
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
// this card to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked card outright.
Button("Rename") {
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
}
.disabled(!store.acceptsBoardMutations)
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
Button("Delete") {
store.delete(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
}
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
/// alone standard macOS context-menu targeting, shared by Style (`styleTarget`) and Delete
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
return .items([card.id])
}
return .items(store.selection.ids)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
/// on the live side).
private var targetIDs: Set<ItemID> {
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
}
// MARK: - Title row
private var titleRow: some View {
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
titleOrEditor
// The title takes the row's width so the indicator sits hard against the trailing
// edge and so the rename field fills the same span the title occupied.
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
}
/// The title, or the rename editor when this card is the rename target. Unchanged from the
/// stub this face replaces: the four exits and their store calls are 04-interactions.md
/// Grammar's, stated once in `InlineTitleField`.
@ViewBuilder
private var titleOrEditor: some View {
if isRenaming {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { store.commitRename() },
onAbandon: { store.transient.discardRename() },
// **Click-away commits** a rename's rule, and the deliberate opposite of the
// placeholder's (04-interactions.md Grammar: "focus loss = commit, matching
// the card window's title field").
onFocusLoss: { store.commitRename() },
onCommitAndOpen: {
let id = card.id
store.commitRename()
openCard(id)
}
)
.font(.body)
} else {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
}
}
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary the tint
/// exists to make a *hand-coloured* icon stand out from its neighbours.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: card.iconColor) {
AnyShapeStyle(color)
} else {
AnyShapeStyle(.secondary)
}
}
/// The one face chip in scope shown only when the card actually has files, and quiet enough
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
/// accessibility label rather than onto the face: it is useful to know, not to look at.
@ViewBuilder
private var attachmentsIndicator: some View {
if !card.attachments.isEmpty {
Image(systemName: "paperclip")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("\(card.attachments.count) attachments")
}
}
/// K1 · left edge stripe, painted with the resolved `background` "a card's [colour paints] a
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
/// title text never sits on a coloured fill" (03-board-ui.md § Styling Capabilities).
///
/// A value that resolves to nothing a typo'd palette name, a malformed hex, a sequence where
/// a scalar belongs draws **no stripe**, and the value stays on disk exactly as written.
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
/// content does a title wrapping across its full four lines included.
@ViewBuilder
private var accentStripe: some View {
if let color = Palette.color(for: card.background) {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
.fill(color)
.frame(width: stripeWidth)
// Decoration only: the whole plate is one click target for selection.
.allowsHitTesting(false)
}
}
// MARK: - Selection and rename plumbing
private var isSelected: Bool {
store.selection.container == .board && store.selection.ids.contains(card.id)
}
/// Whether an external Finder file drag is hovering **this** card the attach highlight
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
/// to one implementation (`BoardDrops`).
private var isFileHovered: Bool {
drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
}
private var isRenaming: Bool {
store.transient.renameEditor?.targetID == card.id
}
private var draft: Binding<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
// MARK: - The new-card placeholder
/// The card being created, drawn as a pseudo-card in the masonry flow at standard card width
@@ -1371,7 +954,7 @@ private struct NewCardStubView: View {
/// and then the field disappears, which also fires the focus-loss handler an instant later. The
/// store's `commitRename`/`commitPlaceholder` and the transient state's `discard` all no-op against
/// an editor that is already closed, so the overlap costs nothing.
private struct InlineTitleField: View {
struct InlineTitleField: View {
@Binding var text: String
let prompt: String
+3 -3
View File
@@ -8,7 +8,7 @@
/// > a multi-selection anchors at its last member in flatten order (lane `order`, then card
/// > `order`, the multi-drag order; the same anchor serves paste): creation follows the last
/// > selected card, or appends to the last selected lane; with nothing selected or a
/// > **tombstoned** selection, which never anchors creation the **last-active lane** the lane
/// > **trash** selection, which never anchors creation the **last-active lane** the lane
/// > that most recently held selection or a creation in this window session falling back to the
/// > first lane. **Zero-lane board**: card creation disable[s] via menu validation until a
/// > lane exists.
@@ -59,7 +59,7 @@ enum NewCardTarget {
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
return anchor
}
// Nothing selected, a tombstoned selection, or a stale one the ids name nothing the board
// Nothing selected, a trash selection, or a stale one the ids name nothing the board
// renders, a selection the next reload will drop. Falls through rather than refusing: the
// user pressed N and the board has lanes. The target is then the lane that most recently
// held selection or a creation, and the first lane when there is no such lane (or it has
@@ -81,7 +81,7 @@ enum NewCardTarget {
/// rule 04 says they share.
///
/// `nil` covers the three cases that anchor nothing, which the callers then answer their own way:
/// an empty selection, a **tombstoned** one ("a tombstoned selection never anchors paste",
/// an empty selection, a **trash** one ("a trash selection never anchors paste",
/// settled and "a trashed card's live disk-lane never leaks in as 'the selected card's lane'",
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
/// nothing the board renders.
+2 -2
View File
@@ -4,7 +4,7 @@
/// The two rules verbatim, and each clause's branch below:
///
/// > Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at
/// > its last member in flatten order the N target rule's shared anchor. **A tombstoned
/// > its last member in flatten order the N target rule's shared anchor. **A trash
/// > selection never anchors paste**: V stays enabled and behaves exactly as with nothing selected
/// > a card payload appends to the last-active lane, a lane payload lands at the board's right end.
///
@@ -68,7 +68,7 @@ enum PasteTarget {
guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot),
let position = lanes.firstIndex(where: { $0.id == anchor.laneID })
else {
// Nothing selected, a tombstoned selection, or a stale one: the right end.
// Nothing selected, a trash selection, or a stale one: the right end.
return lanes.count
}
return position + 1
+43 -9
View File
@@ -52,17 +52,36 @@ final class TrashConfirmations {
/// The staging itself lives on the store (`BoardStore.deleteSelection`), so this is the alert and
/// nothing else the two can never disagree about which write a performs.
func requestDelete(in store: BoardStore) {
guard store.selection.container == .trash, store.purgeIsUnrecoverable else {
guard store.selection.container == .trash else {
store.deleteSelection()
return
}
requestTrashDelete(of: store.selection.ids, in: store)
}
/// The **permanent** half of that staging, aimed at an explicit set the trash card's
/// context-menu Delete (11-command-nexus.md Context menus' Trash cards row).
///
/// Its own entry point because a context menu names its target by where it was invoked, not by
/// what is selected: right-clicking a trash card while a *board* selection stands must purge the
/// clicked card, and a path that re-read `selection` would resolve those ids in the wrong
/// container and silently do nothing.
///
/// Same alert, same rule: it "confirms exactly where the loss is real", so
/// `purgeIsUnrecoverable` decides and where it does not, the purge runs straight through, which
/// is the same shrug Delete Immediately gives on a board that keeps history.
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteTrashCards(ids)
return
}
guard let prompt = TrashModel.purgePrompt(
for: store.selection.ids,
for: ids,
in: .trash,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.ids))
pending = Pending(prompt: prompt, action: .deleteTrashCards(ids))
}
/// Raises Delete Immediately's alert **or purges outright** where the loss is not real.
@@ -70,6 +89,10 @@ final class TrashConfirmations {
/// The mode check is the one thing that decides between the two, and it lives on the store as a
/// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one
/// expression rather than three call sites.
///
/// Its one caller is File Delete Immediately, which passes the selection's own ids which is
/// what makes reading `store.selection.container` for the prompt correct here and wrong for a
/// context menu (`requestTrashDelete` above exists for exactly that difference).
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids)
@@ -177,15 +200,26 @@ struct TrashCommands: View {
return TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot)
}
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) where
/// "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: "menu
/// validation's non-empty reads `.trash/`, not the filtered view").
private var canEmptyTrash: Bool {
store?.canEmptyTrash == true
}
}
extension BoardStore {
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash row)
/// where "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash:
/// "menu validation's non-empty reads `.trash/`, not the filtered view", so a search that hides
/// every trash card leaves the command enabled and its confirmation still names the true count).
///
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
/// invisible to every gesture".
private var canEmptyTrash: Bool {
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
return !store.snapshot.trash.isEmpty
///
/// On the store rather than private to the menu row so the validation can be pinned without a
/// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
var canEmptyTrash: Bool {
guard acceptsBoardMutations, transient.isTrashVisible else { return false }
return !snapshot.trash.isEmpty
}
}
+114 -289
View File
@@ -7,14 +7,25 @@ import SwiftUI
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
/// (03-board-ui.md § Trash, resettled 2026-07-28 the materialized trash).
///
/// ### A rendering of `snapshot.trash`, and a quasi-lane
/// ### Ordinary cards, in a column that says where they are
///
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive: the column
/// renders `store.snapshot.trash`, which the loader parsed with the same card parse the lanes use
/// and sorted by `order` like any lane's children. Newest-first falls out of the ranks (every
/// arrival mints one above the current top), so there is no timestamp sort and no entry type here at
/// all. It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's
/// machinery:
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive and nothing
/// to draw differently: the column renders `store.snapshot.trash` which the loader parsed with the
/// same card parse the lanes use and sorted by `order` like any lane's children through the very
/// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place
/// search, selection, rendering, styling, and clipboard all treat it exactly like any other card"
/// (03 § Trash), and one view is the only way to make *rendering* literally true rather than
/// approximately so: a trashed card keeps its icon, its icon tint, its left-edge accent stripe, its
/// attachments chip and its four-line title, because it is the same card and the same face.
///
/// Newest-first falls out of the ranks (every arrival mints one above the current top), so there is
/// no timestamp sort and no entry type here at all.
///
/// ### What makes it a column and not a lane
///
/// The chrome 03 asks for, and nothing beyond it "trailing (rightmost) position when shown,
/// visually distinct dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not
/// draggable, not resizable, excluded from lane reordering":
///
/// - it spans a **fixed one width unit** no `width` frontmatter, no stepper, no resize handle, and
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
@@ -22,7 +33,9 @@ import SwiftUI
/// - it is **not draggable and not reorderable** the header carries no gesture, and it is absent
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
/// snapshot's lanes;
/// - it has **no new-card button**: nothing is created in the trash.
/// - it has **no new-card button**: nothing is created in the trash. There is deliberately no Empty
/// Trash button either that command's home is File Empty Trash (), and 11-command-nexus.md
/// gives the column no pointer affordance of its own.
///
/// ### The drop it takes, and the drag it starts
///
@@ -34,33 +47,34 @@ import SwiftUI
/// row**, because every arrival mints a rank above the current top.
///
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
/// ordinary `.cards` session in the `.trash` container, and `BoardDropContext.commitDrop` hands it
/// to the same `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an
/// ordinary move out there is no restore-specific machinery and no Put Back" (03 § Trash).
/// ordinary `.cards` session in the `.trash` container (`CardFaceView.startTrashCardDrag`), and
/// `BoardDropContext.commitDrop` hands it to the same `moveCards`/`copyCards`/`receiveCards` every
/// board card uses. "Restoring is an ordinary move out there is no restore-specific machinery and
/// no Put Back" (03 § Trash).
///
/// **Finder file drops stay inert** "Finder file drops on trash cards are inert" ( The trash)
/// and say so directly: the delegate clears the file highlight over the column.
/// and say so twice: the delegate clears the file highlight over the column, and the face's hover
/// treatment is board-only (`CardFaceRole`).
///
/// ### No editing in the trash
///
/// "No editing in the trash: trash cards don't open double-click stops at selection; move it out
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style row.
/// first." That is the face's `.trash` role: no double-tap recogniser, no rename editor, no Style
/// rows absences rather than a pile of `disabled` modifiers.
///
/// ### What is still phase 3's
/// ### Accessibility
///
/// The column renders the materialized trash correctly and its selection, drag, drop, filter and
/// context menu all speak the new container vocabulary but its *visual* treatment is still the
/// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is
/// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the
/// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card.
/// "When shown, it is the last container, labeled as Trash with its count. Its cards are ordinary
/// card elements" (10-accessibility.md, resettled 2026-07-28). The container name and value are set
/// here; the elements inside are ordinary card faces because they *are* ordinary card faces. The full
/// element tree labels, values, traits, actions is the accessibility milestone's.
struct TrashLaneView: View {
let store: BoardStore
/// The window's purge-alert host, threaded down rather than read from the focus system: a
/// context menu's content is built in its own host, where a `@FocusedValue` is not reliably the
/// board window's, and the row's Delete Immediately must raise the *same* alert the menu bar's
/// does.
/// board window's, and the row's Delete must raise the *same* alert the menu bar's does.
let confirmations: TrashConfirmations
/// The board window's drop machinery a card's drag is an ordinary card session in the
@@ -69,10 +83,10 @@ struct TrashLaneView: View {
/// The strip's rubber band. The column's empty space is its third surface, in the **trash**
/// container "the rubber band stays on the side it started on" (04-interactions.md The
/// trash) and every row registers its frame into the same registry.
/// trash) and every card face registers its frame into the same registry.
let marquee: MarqueeControl
/// Reduce Motion, for the row transition below 10-accessibility.md names the trash
/// Reduce Motion, for the card transition below 10-accessibility.md names the trash
/// specifically ("and trash animations all get reduced variants").
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -80,18 +94,13 @@ struct TrashLaneView: View {
/// the lanes rather than as a different kind of object.
private let cornerRadius: CGFloat = 10
private let rowSpacing: CGFloat = 6
/// The height a shadow row holds open. A trash row's height is content-driven (one or two title
/// lines) and the cards being proposed have no row yet to be measured, so the shadow is drawn at
/// the nominal single-line plate `LaneDropRegistry`'s own answer to the same question, in this
/// column's smaller idiom.
private let nominalRowHeight: CGFloat = 32
/// Between the cards `LaneView.cardSpacing`, because these are the same cards.
private let cardSpacing: CGFloat = 8
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
rows
cards
}
.background(
RoundedRectangle(cornerRadius: cornerRadius)
@@ -103,6 +112,13 @@ struct TrashLaneView: View {
// deepest region whatever session is in flight and a narrower target would strand the rest
// (`TrashDropDelegate`).
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
// "The last container, labeled as Trash with its count" (10-accessibility.md Trash lane).
// `.contain` rather than `.combine`: the cards inside are ordinary card elements and must
// stay individually reachable combining them would collapse the container the design asks
// VoiceOver to enter.
.accessibilityElement(children: .contain)
.accessibilityLabel("Trash")
.accessibilityValue(TrashModel.phrase(renderedCards.count))
}
/// The cards the column shows.
@@ -112,9 +128,24 @@ struct TrashLaneView: View {
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
/// free because it reads this same value. Hidden, the column renders nothing and registers
/// nothing, so "hidden trash is invisible to search" needs no code at all.
private var cards: [Card] {
let filter = store.searchFilter
return store.snapshot.trash.filter { filter.matches($0) }
///
/// **A card being dragged out renders here anyway**, unlike a lane's: the source stays visible in
/// the trash while the session is in flight, dimmed by the face's own treatment, because a
/// restore is not a removal until the write lands.
private var renderedCards: [Card] {
Self.rendered(store.snapshot.trash, filter: store.searchFilter)
}
/// `renderedCards` as a pure function of its two inputs `LaneView.rendered`'s trash-side twin,
/// split out for the same reason: so the rule can be pinned without a view
/// (`SearchFilterTests`). The column, its count badge and its marquee registration all read the
/// property, which reads this.
///
/// Two of the lane's four inputs are absent, and each absence is a ruling: **no rename
/// exemption**, because nothing renames in the trash (04 The trash), and **no drag hiding**,
/// because a card dragged *out* of the trash stays visible in it until the write lands.
nonisolated static func rendered(_ trash: [Card], filter: SearchFilter) -> [Card] {
trash.filter { filter.matches($0) }
}
// MARK: - The delete gesture's landing
@@ -125,14 +156,14 @@ struct TrashLaneView: View {
drops.session.trashProposal(onBoardRooted: store.rootURL)
}
/// The rows the `VStack` lays out: the entries, with the delete gesture's shadow run opened at
/// the top.
/// The slots the column lays out: the cards, with the delete gesture's shadow run opened at the
/// top.
///
/// The run stands until the echo reload brings the real tombstones the committed-overlay hold
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
/// other container's does (`CommittedHold`).
/// The run stands until the echo reload brings the real cards the committed-overlay hold keeps
/// the arrangement the release proposed on screen for that round trip, exactly as every other
/// container's does (`CommittedHold`).
private var slots: [TrashSlot] {
var result = cards.map(TrashSlot.card)
var result = renderedCards.map(TrashSlot.card)
guard let proposal else { return result }
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
@@ -145,8 +176,12 @@ struct TrashLaneView: View {
/// (03-board-ui.md § Trash Rendering).
///
/// The hatching is what makes the column read as *not a lane* at a glance the design asks for
/// "visually distinct", and a lane's header is the surface this must not be mistaken for. It
/// carries no gesture at all: no selection (the quasi-lane "is never selectable as a lane"), no
/// "visually distinct", and a lane's header is the surface this must not be mistaken for. Now
/// that the cards inside wear their ordinary faces, this header is the *whole* of "you are
/// looking at the trash", which is why it keeps its full treatment rather than softening.
/// **State is never colour-alone** (10-accessibility.md): the header is hatched *plus* labeled.
///
/// It carries no gesture at all: no selection (the column "is never selectable as a lane"), no
/// reorder drag, no context menu.
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
@@ -175,13 +210,15 @@ struct TrashLaneView: View {
))
}
}
.accessibilityElement(children: .combine)
// The container carries the label and the count (see `body`), so the header itself is
// decoration for VoiceOver rather than a second element saying the same thing.
.accessibilityHidden(true)
}
/// The card count the same collection the body renders, so the badge cannot disagree with
/// what is on screen (`LaneView.countBadge`'s rule).
private var countBadge: some View {
Text("\(cards.count)")
Text("\(renderedCards.count)")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
@@ -190,51 +227,57 @@ struct TrashLaneView: View {
.background(Capsule().fill(.quaternary))
}
// MARK: - Rows
// MARK: - Cards
/// The rows, scrollable, with the navigation head kept in view.
/// The cards, scrollable, with the navigation head kept in view.
///
/// **"Selection scrolls into view"** (04-interactions.md Grammar), watching the head rather
/// than the whole selection so exactly one column responds to any one arrow `LaneView`'s rule,
/// on the trash side.
private var rows: some View {
private var cards: some View {
ScrollViewReader { proxy in
scrollableRows
scrollableCards
.onChange(of: store.transient.selectionHead) { _, head in
guard let head, cards.contains(where: { $0.id == head }) else { return }
guard let head, renderedCards.contains(where: { $0.id == head }) else { return }
proxy.scrollTo(TrashSlot.identity(of: head))
}
}
}
private var scrollableRows: some View {
private var scrollableCards: some View {
ScrollView(.vertical) {
// **A plain `VStack`, deliberately not lazy.** Every row must keep its drawn frame
// **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is
// one width unit, so its masonry is a single column but it is the *same* layout the
// lanes use, which is what makes the drag's make-room reflow read as positional slides
// here exactly as it does there (DRAG-REORDER.md § The card masonry).
//
// Not lazy, for `LaneView`'s reason squared: every face must keep its drawn frame
// registered in `MarqueeTargetRegistry` the rubber band sweeps those frames and the
// arrows navigate by them (`NavigationMath`) and a lazy stack only builds the rows it
// has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
// because a trash is small: it holds one board's tombstones, and Empty Trash exists.
VStack(alignment: .leading, spacing: rowSpacing) {
// because a trash is small: it holds one board's deletions, and Empty Trash exists.
MasonryLayout(columns: 1, spacing: cardSpacing) {
ForEach(slots) { slot in
Group {
switch slot {
case let .card(card):
TrashCardRow(
CardFaceView(
store: store,
card: card,
confirmations: confirmations,
drops: drops,
registry: marquee.registry
role: .trash(confirmations: confirmations),
marquee: marquee,
drops: drops
)
case .shadow:
// The delete gesture's shadow, holding the topmost row open
// (04-interactions.md The trash).
DragShadow(cornerRadius: 6)
// (04-interactions.md The trash). At the nominal card height: the cards
// being proposed have no face here yet to be measured.
DragShadow(cornerRadius: CardFaceMetrics.cornerRadius)
.frame(maxWidth: .infinity)
.frame(height: nominalRowHeight)
.frame(height: LaneDropRegistry.nominalCardHeight)
}
}
// A row is a card, so it arrives and leaves in the card's dialect a delete
// A slot is a card, so it arrives and leaves in the card's dialect a delete
// files one in, a restore or a purge takes one out, and both halves of that pair
// should read alike from either side of the strip. The transaction is the
// reload's, like the lanes' (`Motion.reloadAnimates`).
@@ -248,73 +291,32 @@ struct TrashLaneView: View {
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
// below reach the column's full height rather than stopping where the last row ends
// below reach the column's full height rather than stopping where the last card ends
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
// reason: a `ScrollView` proposes its content only the height that content asks for, so a
// view sized to fit its rows leaves the blank space beneath them un-hit-testable. "The
// view sized to fit its cards leaves the blank space beneath them un-hit-testable. "The
// column's gesture surface is full height" (04-interactions.md The trash, settled)
// needs that blank space to actually belong to the view the gesture below is on.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(6)
.contentShape(Rectangle())
// The band's trash-side surface. It arms from the column's empty space, full height
// (above) included, so a drag can start from the blank area below the last row exactly
// as the board background allows on the live side a drag begun on a row instead is that
// row's drag-out, and the begin guard makes that geometric rather than a matter of
// (above) included, so a drag can start from the blank area below the last card exactly
// as the board background allows on the live side a drag begun on a face instead is that
// card's drag-out, and the begin guard makes that geometric rather than a matter of
// gesture priority (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(in: .trash))
}
}
}
// MARK: - The plate
/// The trash row's *appearance*, with none of anybody's behaviour the compact, dimmed plate a
/// tombstone wears.
///
/// **Two views draw it and neither may drift**: the row itself, and its own drag replica (a drag
/// image is a snapshot, and one built out of the live plate would re-register the row's frame from
/// inside the preview's geometry and then deregister it when the image went away quietly stealing
/// the row from the rubber band and the arrow keys).
private struct TrashRowPlate: View {
let symbol: String
/// The title as written, or `nil` for an untitled card "Untitled" is a rendering, never a value
/// (03-board-ui.md § Card face).
let title: String?
var isSelected: Bool = false
private let cornerRadius: CGFloat = 6
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: symbol)
.foregroundStyle(.secondary)
.imageScale(.small)
Text(title ?? "Untitled")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6)))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
)
}
}
// MARK: - What the column lays out
/// One row of the trash column an entry, or one slot of the delete gesture's run.
/// One slot of the trash column a card, or one slot of the delete gesture's run.
///
/// `LaneSlot`'s smaller sibling, and keyed on the same principle: a slot's id decides whether the
/// echo reload reads as a *swap* or as a removal and an insertion.
/// `LaneSlot`'s smaller sibling smaller by exactly one case, because nothing is ever created in the
/// trash so there is no placeholder to stand in for it and keyed on the same principle: a slot's id
/// decides whether the echo reload reads as a *swap* or as a removal and an insertion.
private enum TrashSlot: Identifiable {
/// A card the snapshot's trash already holds.
@@ -334,7 +336,7 @@ private enum TrashSlot: Identifiable {
/// A card slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" }
static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
}
// MARK: - The hatch
@@ -359,180 +361,3 @@ private struct DiagonalHatch: Shape {
return path
}
}
// MARK: - Rows
/// One trash row: a compact, dimmed plate carrying the card's symbol and title.
///
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it
/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no
/// Style. That is 03-board-ui.md's no-editing-in-the-trash rule expressed as an absence rather
/// than as a pile of `disabled` modifiers. (Making it the ordinary card face is phase 3's see
/// `TrashLaneView`.)
private struct TrashCardRow: View {
let store: BoardStore
let card: Card
let confirmations: TrashConfirmations
let drops: BoardDropContext
/// Where the rubber band looks up what it is sweeping the card face's rule, in the trash
/// container (`View.marqueeTarget`).
let registry: MarqueeTargetRegistry
var body: some View {
plate.onDrag(startRowDrag, preview: { dragReplica })
}
private var plate: some View {
rowFace
// The row being dragged out dims in place the source stays visible in the trash,
// because a restore is not a removal until the write lands.
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
// The deferred cut wears the same dim wherever it lands. It genuinely fires here now:
// "X works cut in the trash, paste into a lane is the keyboard-native restore"
// (04-interactions.md The trash, resettled 2026-07-28).
.cutTreatment(of: card.id, in: store)
.contentShape(Rectangle())
.onTapGesture { select() }
.marqueeTarget(card.id, kind: .card, container: .trash, in: registry)
.contextMenu { menu }
}
/// This card as the shared plate draws it appearance only, no gesture, no context menu and
/// crucially no marquee registration, which is what makes it safe for the drag replica to render
/// (see `TrashRowPlate`).
private var rowFace: some View {
TrashRowPlate(
symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card),
title: card.title.value,
isSelected: isSelected
)
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.container == .trash && store.selection.ids.contains(card.id)
}
/// A click selects this card in the **trash** container, through the same grammar the board's
/// surfaces use plain replaces, toggles, ranges (`SelectionGrammar`).
///
/// The container travels with the click, and that is what keeps the one remaining homogeneity
/// boundary true: a -click across it replaces rather than mixing (04-interactions.md The
/// trash). There is no kind axis inside the trash any more lanes are never trashed. No
/// `togglesOnRepeat` click-again-to-unselect is the lane's behaviour, not a card's.
///
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
private func select() {
store.click(
SelectionTarget(id: card.id, kind: .card, container: .trash),
modifier: .current
)
}
// MARK: - Drag out
/// Begins the card's drag out of the trash an ordinary **card session in the trash container**,
/// which is the whole of what makes it a restore (04-interactions.md The trash;
/// `DragLocality.operation`).
///
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
/// every other card drop uses, committed by the same `moveCards`.
///
/// **Multi-drag carries the whole trash selection**, in the column's own order the order the
/// rows are drawn in, which is `order` ascending like any lane's.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture.
private func startRowDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let selection = store.selection
let ids: Set<ItemID> = selection.container == .trash
&& selection.ids.contains(card.id)
&& selection.ids.count > 1
? selection.ids
: [card.id]
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
container: .trash,
items: rows.map {
DragPayload.Item(
id: $0.id.rawValue,
folder: ItemPath.trashCard($0.id).folder(under: root).path,
title: $0.title.value
)
}
)
drops.session.beginCards(
rows.map(\.id),
folders: payload.folders,
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
container: .trash,
source: store
)
return payload.itemProvider()
}
/// The image under the cursor: the row as it is drawn, fanned with a count badge for a
/// multi-drag the card replica's treatment, at a trash row's size.
private var dragReplica: some View {
let count = store.selection.container == .trash && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
if count > 2 { rowFace.offset(x: 10, y: 10).opacity(0.45) }
if count > 1 { rowFace.offset(x: 5, y: 5).opacity(0.7) }
rowFace
}
.frame(width: 200)
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(12)
}
// MARK: - The trash card's context menu
/// Delete and Reveal in Finder the two rows 11-command-nexus.md gives a trash card, and no
/// others ("Trash cards | Delete (permanent 03's recoverability confirm), Reveal in Finder").
///
/// **Put Back is gone** with the tombstone model: restoring is drag-out or X/V (03 § Trash).
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
/// enabled on trash selections", read-only lock included inspecting a folder before a purge is
/// exactly the errand it exists for.
@ViewBuilder
private var menu: some View {
Button("Delete") {
confirmations.requestPurge(of: targetIDs, in: store)
}
.disabled(!store.acceptsBoardMutations)
Divider()
Button("Reveal in Finder") {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
}
}
/// What this row's menu acts on: the whole selection when this row is part of it, else this row
/// alone standard macOS context-menu targeting, and the same rule the card face and the lane
/// header apply to Style.
private var targetIDs: Set<ItemID> {
guard store.selection.container == .trash, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
}
private var targetFolders: [URL] {
ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
}
+7 -7
View File
@@ -65,20 +65,20 @@ struct CardStyleSection: View {
// MARK: - Actions
/// The sidebar's **Actions** section, at the bottom of the stack (05-card-window.md Actions):
/// **Delete** tombstones the card and **Reveal in Finder** the card's folder.
/// **Delete** moves the card to the trash and **Reveal in Finder** the card's folder.
///
/// ### Delete writes; the window's dismissal is not its business
///
/// The button calls `BoardStore.deleteCard`, which is the tombstone exactly (same write op, same
/// bracket, same stamps). It does not close this window: the card's tombstone rounds back through
/// The button calls `BoardStore.deleteCard`, which is the delete exactly (same write op, same
/// bracket, same stamps). It does not close this window: the card's move into `.trash/` rounds back through
/// the watcher and `CardWindowHost.cardWindowFate` takes the window down, which is the same path a
/// delete from the board or from an agent already takes. Dismissing from here as well would be a
/// second rule able to disagree with the first, and 05's own wording is a sequence rather than a
/// pair ("tombstones the card; the window then dismisses itself").
/// pair ("moves the card to the trash; the window then dismisses itself").
///
/// Recovery is the board's trash quasi-lane, which is why this needs no confirmation: the row is
/// still there to Put Back, and 03-board-ui.md reserves the alert for the purge that isn't
/// recoverable.
/// Recovery is the board's trash column, which is why this needs no confirmation: the card is still
/// there to drag out or cut and paste back (03-board-ui.md § Trash there is no Put Back), and 03
/// reserves the alert for the purge that isn't recoverable.
///
/// ### Reveal is not edit-shaped
///
+3 -3
View File
@@ -46,9 +46,9 @@ struct CardWindowView: View {
/// the sidebar is why: the Style section hosts the **shared** style editor, whose API is
/// store-shaped by design (it reads the target set's current values and writes through the one
/// `applyStyle` bracket every anchor shares), and the Actions section's Delete is the store's own
/// tombstone. Routing either through a closure of this window's own would be a second card-styling
/// or card-deleting path to keep in step with the first exactly what "one component, one
/// behavior" and "exactly the tombstone" forbid.
/// move-to-trash. Routing either through a closure of this window's own would be a second
/// card-styling or card-deleting path to keep in step with the first exactly what "one
/// component, one behavior" and "exactly the delete" forbid.
let store: BoardStore
/// The app-wide quick-style recents the embedded editor feeds (03-board-ui.md Styling
/// Controls) app state, not board state, which is why it arrives beside the store rather than
+2 -2
View File
@@ -154,7 +154,7 @@ enum Motion {
reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane)
}
/// A card arriving or leaving a create, a delete, a Put Back, and the search filter's leavers
/// A card arriving or leaving a create, a delete, a restore, and the search filter's leavers
/// and arrivers ("Search-hiding rides the same structural transition hiding is removal, not a
/// special fade"). The trash's rows wear it too: they are cards, and 10 requires the trash
/// animations to have a reduced variant like everything else.
@@ -162,7 +162,7 @@ enum Motion {
cardAppearance(reduced: reduced).transition
}
/// A lane arriving or leaving and the trash quasi-lane joining or leaving the width division,
/// A lane arriving or leaving and the trash column joining or leaving the width division,
/// which is lane-shaped and reads as one.
static func laneTransition(reduced: Bool) -> AnyTransition {
laneAppearance(reduced: reduced).transition
+1 -5
View File
@@ -47,7 +47,6 @@ private let everyOperation: [WriteOperation] = [
.reorder(title: "Fix login"),
.copy(title: "Fix login"),
.delete(title: "Fix login"),
.restore(title: "Fix login"),
.purge(title: "Fix login"),
.style(title: "Fix login"),
.resize(title: "Fix login"),
@@ -63,7 +62,6 @@ private let titledOperations: [(with: WriteOperation, without: WriteOperation)]
(.reorder(title: "Fix login"), .reorder(title: nil)),
(.copy(title: "Fix login"), .copy(title: nil)),
(.delete(title: "Fix login"), .delete(title: nil)),
(.restore(title: "Fix login"), .restore(title: nil)),
(.purge(title: "Fix login"), .purge(title: nil)),
(.style(title: "Fix login"), .style(title: nil)),
(.resize(title: "Fix login"), .resize(title: nil)),
@@ -488,15 +486,13 @@ struct BannerCenterPhrasingTests {
!= BannerCenter.headline(for: error(.style(title: "Fix login"))))
}
@Test("The trash trio speaks the board's vocabulary, never the system Trash's")
@Test("The trash verbs speak the board's vocabulary, never the system Trash's")
func trashVerbsFollowTheNamingConstraint() {
// 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: two "Trash"
// concepts coexist, and "Finder's 'Move to Trash' phrasing is reserved for the system Trash;
// board deletion says 'Delete'". A banner is UI copy like any other.
#expect(BannerCenter.headline(for: error(.delete(title: "Fix login"), .io(message: "the disk is full")))
== "Couldn't delete 'Fix login' — the disk is full")
#expect(BannerCenter.headline(for: error(.restore(title: "Fix login"), .io(message: "the disk is full")))
== "Couldn't put 'Fix login' back — the disk is full")
#expect(BannerCenter.headline(for: error(.purge(title: "Fix login"), .io(message: "the disk is full")))
== "Couldn't permanently delete 'Fix login' — the disk is full")
+4 -324
View File
@@ -1571,332 +1571,12 @@ struct BoardWriterCopyTests {
}
}
// MARK: - Stripping a copied lane's tombstones
/// `BoardWriter.stripTombstonedChildren` the tail of a lane copy (04-interactions.md Drag and
/// drop: "A lane copy **strips tombstoned cards**"). `copyItem` copies the tree verbatim by
/// design, so the strip is the line after it rather than a filter inside it.
struct BoardWriterStripTombstonesTests {
/// A tombstoned card, as an agent or a delete leaves it.
private static func tombstone(order: String, title: String) -> String {
"---\nschema: 1\ntitle: \(title)\norder: \(order)\ndeleted: 2026-03-03T09:00:00Z\n---\n\(title) body.\n"
}
@Test func onlyTheTombstonedChildrenAreRemoved() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)",
Self.tombstone(order: "2048", title: "Trashed"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Also live"))
let live = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(removed == [ItemID(rawValue: Ident.card2)])
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card2)"))
// Removed, never tombstoned, and the survivors are not rewritten on the way past.
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card3)"))
#expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") == live)
#expect(try BoardLoader.load(boardRoot: fixture.url("A.kanban")).warnings.isEmpty)
}
@Test func aWholeTombstonedFolderGoesWithItsAttachments() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)",
Self.tombstone(order: "1024", title: "Trashed"))
try fixture.file("A.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x89, 0x50]))
_ = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
}
@Test func nonUUIDStraysAndUnreadableChildrenAreLeftAlone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
// A stray is not a level at all; a UUID-shaped folder with no `index.md` cannot be asked
// the liveness question, and the conservative direction is to keep it.
try fixture.file("A.kanban/\(Ident.lane1)/notes/scratch.txt", Data("hand-written\n".utf8))
try FileManager.default.createDirectory(at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.indexless)"),
withIntermediateDirectories: true)
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(removed.isEmpty)
#expect(fixture.exists("A.kanban/\(Ident.lane1)/notes/scratch.txt"))
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.indexless)"))
}
@Test func aLaneWithNothingTombstonedIsUntouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
#expect(try BoardWriter.stripTombstonedChildren(of: lane).isEmpty)
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").sorted() == [Ident.card1, "index.md"].sorted())
}
@Test func aMissingFolderIsALoudErrorNamingTheCopy() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let error = writeFailure { _ = try BoardWriter.stripTombstonedChildren(of: fixture.url("A.kanban/\(Ident.lane1)")) }
// The user pressed nothing called "delete": a failure here must say the copy failed.
#expect(error?.operation == .copy(title: nil))
}
}
// MARK: - Delete / Restore
/// `BoardWriter.deleteItem`/`restoreItem` the tombstone half of 01-storage-format.md §
/// Deletion: `deleted: <now>` written into the item's own `index.md` in place, and Put Back
/// (`remove(deleted)`) undoing exactly that. The folder never moves; hiding a tombstoned
/// subtree is the renderer's ancestor walk, not anything either call does a deleted lane's
/// cards are never touched.
struct BoardWriterDeleteRestoreTests {
@Test func deletingALaneWritesATombstoneInPlaceAndPreservesEverythingElse() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
let before = try fixture.indexText("A.kanban/\(Ident.lane1)")
try BoardWriter.deleteItem(at: laneFolder)
// Same path, same name a tombstone never moves or renames the folder.
#expect(fixture.exists("A.kanban/\(Ident.lane1)"))
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
let after = try fixture.indexText("A.kanban/\(Ident.lane1)")
let stamped = [FrontmatterKeys.modified, FrontmatterKeys.modifiedBy, FrontmatterKeys.deleted]
#expect(lines(of: after, excludingKeys: stamped) == lines(of: before, excludingKeys: stamped))
let document = try FrontmatterDocument.parse(after)
#expect(document.unknownFields.map(\.key) == ["project", "labels"])
#expect(document.body.hasSuffix("body — with *markdown*.\n"))
#expect(document.modifiedBy == .missing)
#expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60)
let deleted = try #require(document.deleted.value)
#expect(abs(deleted.timeIntervalSinceNow) < 60)
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
#expect(result.model.lanes.first?.isDeleted == true)
}
/// Hiding beneath is the renderer's walk, not a stored flag: deleting a lane writes only
/// the lane's own file.
@Test func deletingALaneLeavesItsNestedCardUntouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One"))
let cardBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)")
try BoardWriter.deleteItem(at: laneFolder)
#expect(try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") == cardBefore)
}
@Test func loaderRoundTripDeletingACardShowsItDeletedStillInTheSnapshotAtItsRecordedOrder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
let cardFolder = try fixture.item(
"A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")
)
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
try BoardWriter.deleteItem(at: cardFolder)
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
#expect(result.warnings.isEmpty)
let cards = try #require(result.model.lanes.first?.cards)
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
#expect(cards[0].isDeleted == true)
#expect(cards[0].order == 1024)
#expect(cards[1].isDeleted == false)
}
/// After restore the file carries no residue of `deleted` at all, and the item reappears
/// among its current siblings at the `order` it had all along.
@Test func deleteThenRestoreLeavesNoResidueAndReappearsAtItsRecordedOrder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
let cardFolder = try fixture.item(
"A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1536", title: "Card One")
)
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
try BoardWriter.deleteItem(at: cardFolder)
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
.deleted.value != nil)
try BoardWriter.restoreItem(at: cardFolder)
let after = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
#expect(!after.contains("deleted"))
let document = try FrontmatterDocument.parse(after)
#expect(document.deleted == .missing)
#expect(document.order == .valid(1536))
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
let cards = try #require(result.model.lanes.first?.cards)
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
#expect(cards[0].isDeleted == false)
}
/// `remove` takes every occurrence, so a hand-duplicated `deleted` line cannot resurrect
/// the tombstone the instant the winning one is gone.
@Test func restoreRemovesAHandDuplicatedDeletedKeyEntirely() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let text = """
---
schema: 1
order: 1024
title: Twice Gone
deleted: 2026-01-01T00:00:00Z
deleted: 2026-06-01T00:00:00Z
---
body
"""
let folder = try fixture.item(Ident.lane1, text)
try BoardWriter.restoreItem(at: folder)
let after = try fixture.indexText(Ident.lane1)
#expect(after.components(separatedBy: "\n").filter { $0.hasPrefix("deleted:") }.isEmpty)
#expect(try FrontmatterDocument.parse(after).deleted == .missing)
}
/// Tombstones are inert to ordering: a sibling deleted via `deleteItem` must not factor into
/// a subsequent create's append target.
@Test func aTombstonedSiblingIsExcludedFromTheAppendRankAfterDeleteItem() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
let highOrder = try fixture.item(Child.b, "---\nschema: 1\norder: 9999\ntitle: B\n---\nbody\n")
try BoardWriter.deleteItem(at: highOrder)
let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue))
#expect(document.order == .valid(2048))
}
@Test func renumberLeavesATombstonedSiblingByteIdentical() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("lane/\(Child.a)", "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
let toDelete = try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: 3000\ntitle: B\n---\nbody\n")
try BoardWriter.deleteItem(at: toDelete)
let tombstoneAfterDelete = try fixture.indexData("lane/\(Child.b)")
try BoardWriter.renumberVisibleChildren(of: fixture.url("lane"))
#expect(try fixture.indexData("lane/\(Child.b)") == tombstoneAfterDelete)
}
/// Board-root deletion is structurally unreachable at the writer level: a board root's
/// folder name is never UUID-shaped.
@Test func deleteRefusesANonUUIDShapedFolder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = try fixture.item("A.kanban", Item.board)
let error = writeFailure { try BoardWriter.deleteItem(at: root) }
guard case let .unreadable(message) = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
#expect(message.contains("UUID-shaped"))
#expect(try fixture.indexText("A.kanban") == Item.board)
}
@Test func restoreRefusesANonUUIDShapedFolder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = try fixture.item("A.kanban", Item.board)
let error = writeFailure { try BoardWriter.restoreItem(at: root) }
guard case let .unreadable(message) = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
#expect(message.contains("UUID-shaped"))
#expect(try fixture.indexText("A.kanban") == Item.board)
}
/// Comes free via `updateIndex`'s pre-flight: a readable-but-uneditable shape refuses every
/// app-mediated write, delete included.
@Test func deleteOnAnUneditableItemRefuses() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let folder = try fixture.item(Ident.lane1, Fixture.flowMapping)
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
#expect(try fixture.indexText(Ident.lane1) == Fixture.flowMapping)
}
// MARK: Title enrichment (02-architecture.md § Write-failure surfacing)
/// `updateIndex`'s pre-flight read succeeds the shape is readable, only uneditable so by
/// the time the refusal fires, `WriteOperation.withTitle` has already run: the title survives
/// into the thrown error. `Fixture.flowMapping` above has no `title` key at all, which is why
/// this test reaches for `Item.uneditable` instead the fixture that actually carries one.
@Test func deleteOnAnUneditableItemWithAKnownTitleCarriesItInTheOperation() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let folder = try fixture.item(Ident.lane1, Item.uneditable)
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
#expect(error?.operation == .delete(title: "Odd"))
}
/// The negative case: a file that cannot even be read (invalid UTF-8) never gets far enough
/// for `readDocument` to hand back a document, so there is no title to learn the operation
/// stays exactly as its call site constructed it, title `nil`.
@Test func deleteOnAnUnreadableIndexLeavesTheOperationsTitleNil() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let garbage = try #require("---\nschema: 1\ntitle: café\n---\nbody\n".data(using: .isoLatin1))
let folder = try fixture.item(Ident.lane1, bytes: garbage)
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
guard case .unreadable = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
#expect(error?.operation == .delete(title: nil))
}
}
// MARK: - Purge
/// `BoardWriter.purgeItem`: physical removal Delete Immediately / Empty Trash
/// (01-storage-format.md § Deletion) irreversible, and distinct from tombstoning. Does not
/// require the item to be tombstoned first: Delete Immediately skips that stage by design.
/// (01-storage-format.md § Deletion) irreversible, and distinct from the ordinary delete, which
/// is a *move* into `.trash/`. Does not require the item to be in the trash first: Delete
/// Immediately "skips the trash from anywhere" by design.
struct BoardWriterPurgeTests {
@Test func purgeRemovesTheFolderTreeIncludingNestedContentFromDisk() throws {
let fixture = try WriterFixture()
@@ -2129,7 +1809,7 @@ struct BoardWriterImportAttachmentsTests {
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot.png"])
}
/// Attachments belong to cards: the shape guard `deleteItem`/`restoreItem`/`purgeItem` share
/// Attachments belong to cards: the shape guard `moveItem`/`copyItem`/`purgeItem` share
/// refuses a board root (or any non-UUID-shaped folder) before `attachments/` is even
/// considered.
@Test func importIntoANonUUIDShapedFolderIsRefused() throws {
+13 -9
View File
@@ -263,22 +263,26 @@ struct StoreWriteCardBodyTests {
#expect(try fixture.indexData(cardPath) == before)
}
@Test("A tombstoned card is still written to, and stays tombstoned")
func aTombstonedCardStillTakesTheFlush() throws {
@Test("A trashed card is still written to, at its new .trash/ location")
func aTrashedCardStillTakesTheFlush() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteItem(at: fixture.url(cardPath))
try BoardWriter.deleteCardToTrash(at: fixture.url(cardPath), inBoard: fixture.root, order: 1024)
let store = try BoardStore(rootURL: fixture.root)
let trashedPath = ".trash/\(Ident.card1)"
// 05 Deletion & lifecycle: "a dirty Edit buffer flushes into the tombstoned card's folder
// before the window dismisses ... so the keystrokes survive Put Back".
// 05 Deletion & lifecycle, resettled 2026-07-28: "a dirty Edit buffer flushes into the
// card's folder at its new `.trash/` location before the window dismisses ... so the
// keystrokes survive a later restore". `BoardStore.cardBodyTarget` spans both containers for
// exactly this, which is why the store finds the card by id with no hint of where it went.
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "Typed as it went.\n")
#expect(outcome == .written)
#expect(try body(of: fixture, cardPath) == "Typed as it went.\n")
// Surgical: the write replaced the body span, so the tombstone is still standing and Put
// Back still has something to put back.
#expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)
#expect(try body(of: fixture, trashedPath) == "Typed as it went.\n")
// Surgical: the write replaced the body span and nothing else, so the card is otherwise
// exactly as the delete left it a later restore brings the keystrokes back with it.
#expect(try FrontmatterDocument.parse(fixture.indexText(trashedPath)).title == .valid("Notes"))
#expect(try fixture.indexText(trashedPath).contains("project: lanework # agent overlay"))
}
@Test("A card that is not in the board at all reports vanished, and writes nowhere")
+13 -2
View File
@@ -176,8 +176,19 @@ struct BaseInertGitTests {
order: nil,
stamps: .fork
)
try BoardWriter.deleteItem(at: board.url("\(board.laneA)/\(board.card1)"))
try BoardWriter.restoreItem(at: board.url("\(board.laneA)/\(board.card1)"))
// The delete and its restore are both folder moves now (03-board-ui.md § Trash), which is
// the pair most likely to notice a `.git` at the root: the first walks into `<root>/.trash/`
// and the second walks back out of it.
try BoardWriter.deleteCardToTrash(
at: board.url("\(board.laneA)/\(board.card1)"), inBoard: board.root, order: 1024
)
_ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: board.root).appendingPathComponent(board.card1),
toParent: board.url(board.laneA),
sourceBoardRoot: board.root,
destinationBoardRoot: board.root,
order: nil
)
// The renumbers are the pointed ones: both walk a parent's whole directory listing, which
// is where a `.git` entry actually gets looked at.
try BoardWriter.renumberVisibleChildren(of: board.root)
+22
View File
@@ -336,6 +336,28 @@ struct SearchFilterOrderTests {
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2])
}
/// The trash *column* narrows through the same predicate, which is the whole of "shown, its cards
/// participate in the filter exactly like any other card the point of the pivot"
/// (03-board-ui.md § Trash). `LaneView.rendered`'s trash-side twin, pinned the same way: the
/// column, its count badge and its marquee registration all read this list, so one answer keeps
/// them in step.
@Test("The trash column renders exactly what navigation and Select All walk")
func trashColumnRendersTheFilteredCards() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(TrashLaneView.rendered(model.trash, filter: .inactive).map(\.id) == [card1, card2])
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id) == [card1])
// The column and the arrow grammar cannot disagree about what is on screen.
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id)
== SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")))
// A query nothing matches empties the column without emptying the container which is why
// Empty Trash's validation reads `.trash/` and not this list.
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "zzzz")).isEmpty)
#expect(!model.trash.isEmpty)
}
@Test("The delete successor is drawn from what the lane is showing")
func successorSkipsHiddenSiblings() throws {
let fixture = try makeBoard()
+216
View File
@@ -829,3 +829,219 @@ struct TrashConfirmationsTests {
#expect(confirmations.pending == nil)
}
}
// MARK: - The menu-validation seams
/// The trash's three File-menu rows, validated as predicates rather than as menu items 11-command
/// -nexus.md's inventory, and 03-board-ui.md § Trash's rulings about scope.
///
/// The rows themselves are `TrashCommands`, whose whole body is one `disabled()` per row over these
/// answers; what is worth pinning is the answers. `TrashModel.canDelete`/`canDeleteImmediately` are
/// pinned as pure functions in `TrashModelTests`; this suite covers the two seams that need a live
/// store Empty Trash's scope, and the staging a actually performs.
@MainActor
@Suite("The trash's menu validation")
struct TrashMenuValidationTests {
/// 11-command-nexus.md: "Board window, trash shown and non-empty (whole-trash scope,
/// search-independent)"; 03 § Trash: "menu validation's 'non-empty' reads `.trash/`, not the
/// filtered view".
@Test("Empty Trash needs the column shown and the container non-empty — never the filtered view")
func emptyTrashValidation() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Hidden, the trash "is invisible to every gesture" the command included.
#expect(!store.canEmptyTrash)
store.transient.isTrashVisible = true
#expect(store.canEmptyTrash)
// A query that hides every trash card leaves it enabled: the scope is the container, not
// what is on screen.
store.searchQuery = "zzzz-nothing-matches"
#expect(store.searchFilter.visibleIDs(in: store.snapshot, container: .trash).isEmpty)
#expect(store.canEmptyTrash)
// And the confirmation still names the true count, for the same reason.
let prompt = try #require(TrashModel.emptyTrashPrompt(in: store.snapshot, unrecoverable: true))
#expect(prompt.title == "Permanently delete 2 cards?")
}
@Test("An empty container disables it however visible the column is")
func emptyTrashNeedsCards() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
#expect(!store.canEmptyTrash)
}
/// **One Delete, staged by place** 04-interactions.md The map: "File Delete is the chord's
/// only owner no twin menu items, no shared-equivalent routing". Put Back's twin is retired,
/// so exactly one predicate enables the row and the selection's *container* decides which write
/// it performs.
@Test("Delete is one enabled row on both sides, and the container picks the write")
func deleteIsOneRowStagedByPlace() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([card1], in: .board)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
confirmations.requestDelete(in: store)
// The board staging: a move, no alert, the card now in the trash.
#expect(confirmations.pending == nil)
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
store.select([trashed], in: .trash)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
confirmations.requestDelete(in: store)
// The trash staging: permanent, and behind the alert.
let pending = try #require(confirmations.pending)
#expect(pending.action == .deleteTrashCards([trashed]))
}
/// A context menu names its target by where it was invoked, so the trash row's Delete must purge
/// the clicked card even while a *board* selection stands the case a selection-reading path
/// would silently no-op on (`TrashConfirmations.requestTrashDelete`).
@Test("The trash card's context-menu Delete acts on its own target, not on the selection")
func contextMenuDeleteIgnoresTheSelection() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([card1], in: .board)
confirmations.requestTrashDelete(of: [trashed], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .deleteTrashCards([trashed]))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
// The board selection was never the subject and is untouched on disk.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
}
}
// MARK: - Everything edit-shaped refuses a trash selection
/// 04-interactions.md The trash: "Everything edit-shaped is disabled on trash selections Open
/// Card, Rename, Style". Each of the three answers with one expression used for both its `disabled`
/// state and its action, which is what this suite drives (`BoardStore.openCardTarget`,
/// `.renameTarget`, `.boardStyleTarget`).
@MainActor
@Suite("Edit-shaped commands on a trash selection")
struct TrashGrammarExclusionTests {
@Test("Open Card takes a sole board card and nothing else")
func openRefusesTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board)
#expect(store.openCardTarget == card1)
// "Trash cards don't open double-click stops at selection; move it out first" (03 § Trash).
store.select([trashed], in: .trash)
#expect(store.openCardTarget == nil)
store.select([trashed, newer], in: .trash)
#expect(store.openCardTarget == nil)
// A lane and a multi-selection refuse too a card window is tied to one card.
store.select([lane1], in: .board)
#expect(store.openCardTarget == nil)
store.select([card1, card2], in: .board)
#expect(store.openCardTarget == nil)
}
@Test("Rename takes a sole board item, card or lane, and never a trash card")
func renameRefusesTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board)
#expect(store.renameTarget?.id == card1)
#expect(store.renameTarget?.title == "First")
store.select([lane1], in: .board)
#expect(store.renameTarget?.id == lane1)
store.select([trashed], in: .trash)
#expect(store.renameTarget == nil)
}
/// The one with a fall-through worth guarding: an empty selection styles *the board*, so a trash
/// selection has to disable rather than land there "quietly restyling the board because the
/// user had a trashed card selected would be the silent retarget 03 forbids".
@Test("Style… falls through to the board on an empty selection, but a trash selection disables it")
func styleRefusesTheTrashWithoutFallingThrough() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.clearSelection()
#expect(store.boardStyleTarget == .board)
store.select([card1], in: .board)
#expect(store.boardStyleTarget == .items([card1]))
store.select([trashed], in: .trash)
#expect(store.boardStyleTarget == nil)
}
/// The trash's Delete is not edit-shaped and neither is Reveal, so both stay available the two
/// rows 11-command-nexus.md gives a trash card, and no others.
@Test("Delete and Reveal are what a trash selection keeps")
func whatTheTrashKeeps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([trashed], in: .trash)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
#expect(TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot))
#expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL).lastPathComponent } == [Ident.indexless])
}
}
// MARK: - Put Back is gone
/// 03-board-ui.md § Trash: "**No Put Back** (settled) Restoring is an ordinary move out". The
/// retirement is mostly a compile-time fact there is no `putBack` on the store, no restore write on
/// the Writer, and no second -titled menu row so what is left to state at runtime is that a
/// restore registers, phrases and writes as the ordinary move it now is.
@MainActor
@Suite("Put Back is retired")
struct PutBackRetirementTests {
@Test("The undo vocabulary has no restore verb — a restore is a Move")
func noRestoreVerb() {
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Restore"))
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Put Back"))
// What a drag-out or a X/V restore actually reads as in the Edit menu.
#expect(HistoryPhrase.name(.move, kind: .card) == "Move Card")
}
/// One predicate for both stagings, because there is only one item: the mirror-image pair that
/// existed to make two twins enable exactly one of themselves retired with Put Back.
@Test("One Delete predicate covers both containers")
func oneDeletePredicate() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try loaded(fixture)
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [trashed], container: .trash), in: snapshot))
}
}
+43 -71
View File
@@ -26,9 +26,7 @@ import Testing
/// What follows fills the remaining gaps: minimal-touch stated with **mtimes**, not just bytes
/// (existing sibling-byte assertions never look at the filesystem's own "did this file move"
/// signal); the renumber fallback stated as the *positive* exception across a whole board rather
/// than within one lane; deleterestore at full byte precision against the pre-delete original
/// (existing coverage checks the *result* is undeleted and reordered correctly, not that the
/// bytes differ from the original by exactly one line); unknown-key order through a writer op
/// than within one lane; unknown-key order through a writer op
/// with keys deliberately interleaved among schema-owned ones (existing coverage groups the
/// unknown keys together); and one end-to-end composite scenario tying every guarantee together.
@@ -110,8 +108,19 @@ struct WriteFidelityMinimalTouchTests {
snapshots[folder] = try snapshot(fixture, folder)
}
func step(_ label: String, targeting targets: Set<String>, _ operation: () throws -> Void) throws {
/// `departed` names folders the step moved *out of the enumerated tree* which, since
/// `allIndexFolders` skips hidden folders, is exactly what a delete now is: the card's folder
/// travels into `<root>/.trash/` (03-board-ui.md § Trash, resettled 2026-07-28) and stops
/// being visible here. They leave the tracked set rather than being asserted about, because
/// "untouched" is a claim about the files that stayed.
func step(
_ label: String,
targeting targets: Set<String>,
departed: Set<String> = [],
_ operation: () throws -> Void
) throws {
try operation()
for folder in departed { snapshots[folder] = nil }
for (folder, before) in snapshots where !targets.contains(folder) {
let after = try snapshot(fixture, folder)
@@ -144,11 +153,24 @@ struct WriteFidelityMinimalTouchTests {
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: .style(title: nil)
) { $0.set(FrontmatterKeys.title, to: .string("Renamed Three")) }
}
try step("delete", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
try BoardWriter.deleteItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
// The delete is a **move** into `.trash/` (03-board-ui.md § Trash): the card's folder leaves
// the visible tree entirely, and every index that stayed behind must be untouched mtime
// included, which is the point of this harness.
try step("delete", targeting: [], departed: ["\(Ident.lane2)/\(Ident.card4)"]) {
try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane2)/\(Ident.card4)"), inBoard: fixture.root, order: 1024
)
}
try step("restore", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
try BoardWriter.restoreItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
// The restore is an ordinary move out "there is no restore-specific machinery and no Put
// Back" (03 § Trash) so the folder simply comes back, and again nothing else moves.
try step("restore", targeting: []) {
_ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: fixture.root).appendingPathComponent(Ident.card4),
toParent: fixture.url(Ident.lane2),
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 2048
)
}
var createdCard = ""
@@ -219,65 +241,6 @@ struct WriteFidelityRenumberTests {
}
}
// MARK: - Delete restore, byte precision
/// 01-storage-format.md § Deletion: Put Back "undoes exactly what `deleteItem` wrote". Existing
/// coverage (`BoardWriterDeleteRestoreTests`) checks the *result* undeleted, reordered
/// correctly this test checks the *bytes*: after a full deleterestore round trip, the file
/// differs from the pre-delete original in exactly one place, the `modified:` line, with every
/// comment, inline comment, unknown key, and per-line ending untouched, and zero occurrences of
/// `deleted` anywhere in the text.
struct WriteFidelityTombstoneTests {
@Test func deleteThenRestoreDiffersFromTheOriginalOnlyInTheModifiedTimestamp() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
let original = "---\n"
+ "schema: 1\n"
+ "# a hand-written note\n"
+ "title: Original\n"
+ "order: 1536\n"
+ "project: lanework # agent overlay\n"
+ "sphere: work\r\n"
+ "labels: [a, b, c]\n"
+ "modified: 2026-01-01T00:00:00Z\n"
+ "---\n"
+ "Body text.\n\nMore body — with *markdown*.\n"
let cardPath = "\(Ident.lane1)/\(Ident.card1)"
let folder = try fixture.item(cardPath, original)
try fixture.item("\(Ident.lane1)/\(Ident.card2)", "---\nschema: 1\norder: 2048\ntitle: Sibling\n---\n")
try BoardWriter.deleteItem(at: folder)
#expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)
try BoardWriter.restoreItem(at: folder)
let after = try fixture.indexText(cardPath)
#expect(lines(of: after, excludingKeys: [FrontmatterKeys.modified])
== lines(of: original, excludingKeys: [FrontmatterKeys.modified]))
// The untouched CRLF unknown-key line and the inline comment both survived verbatim.
#expect(after.contains("sphere: work\r\n"))
#expect(after.contains("project: lanework # agent overlay\n"))
// No residue of the key that made this item a tombstone, anywhere in the text.
#expect(!after.contains("deleted"))
let document = try FrontmatterDocument.parse(after)
#expect(document.deleted == .missing)
#expect(document.order == .valid(1536))
#expect(document.title == .valid("Original"))
// Position among siblings unchanged: the loader sees the card back at its recorded
// order, ahead of the sibling that was never touched.
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.warnings.isEmpty)
let cards = try #require(result.model.lanes.first?.cards)
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
#expect(cards[0].isDeleted == false)
#expect(cards[0].order == 1536)
}
}
// MARK: - Unknown-key order through a writer op
/// 01-storage-format.md § Fractal layout Rules, "unknown frontmatter keys and their order are
@@ -380,10 +343,19 @@ struct WriteFidelityCompositeTests {
)
#expect(copyID != card3)
// Delete, then restore, the hand-edited card its unknown keys and comment must come
// back with no residue of `deleted`.
try BoardWriter.deleteItem(at: lane1Folder.appendingPathComponent(card2.rawValue))
try BoardWriter.restoreItem(at: lane1Folder.appendingPathComponent(card2.rawValue))
// Delete, then restore, the hand-edited card two folder moves now (03-board-ui.md § Trash,
// resettled 2026-07-28), so its unknown keys, its comment and its body must ride along
// untouched through both legs.
try BoardWriter.deleteCardToTrash(
at: lane1Folder.appendingPathComponent(card2.rawValue), inBoard: root, order: 1024
)
_ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: root).appendingPathComponent(card2.rawValue),
toParent: lane1Folder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: nil
)
// Renumber both lanes the fallback that touches every visible sibling's `order`.
try BoardWriter.renumberVisibleChildren(of: lane1Folder)
+12 -12
View File
@@ -8,31 +8,31 @@ The defining consequence: anything that can read and write files is a first-clas
Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/).
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards.
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy.
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — pinned by a golden fixture suite of 18 on-disk boards.
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy.
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
- **Drag & drop** — cards, lanes and trash rows all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane copy strips tombstoned cards while a lane move carries them whole. Dragging a trash row onto a lane restores it at the drop position, ⌥ copies it out live instead, and dropping it on another board follows the same copy-out grammar. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the newest-first sort makes honest rather than arbitrary. Lanes aren't deliverable that way, a foreign board's card isn't, and ⌥ isn't, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. Tombstoned surfaces are inert to them, and a read-only board or an open inline editor refuses them outright.
- **Drag & drop** — cards, in either container, and lanes all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane carries exactly its cards either way, since the trash is board-level and there is nothing lane-nested to strip. Dragging a trash card onto a lane restores it at the drop position — an ordinary move — while dropping it on another board follows the same copy default every cross-board drag does, ⌘ forcing the true restore-move. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the rank minting makes honest rather than arbitrary: every arrival really does land above the current top. Lanes aren't deliverable that way, a foreign board's card isn't, and ⌥ isn't, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. The trash column and its cards are inert to them, and a read-only board or an open inline editor refuses them outright.
- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the liveness and card/lane-entry boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.
- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the board/trash and card/lane boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a pasted lane strips tombstoned cards while a cut lane carries them whole; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The trash is copy-out only — ⌘C on a trash row (card or lane entry) yields a live copy with the tombstone stripped, ⌘X is disabled there — and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V into a lane is the keyboard-native restore — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
- **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there, and drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical tombstone. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live.
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. Lanes are never trashed: deleting a lane deletes it, folder and contents, with undo as the net. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, ⌥⌘⌫ Delete Immediately skips the trash from anywhere, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
- **Live search** — the board window's toolbar carries one item, a search field (Edit ▸ Find, ⌘F), and typing in it filters the board as you type: a card stays when its title *or* its body contains the query, case- and diacritic-insensitively (so `resume` finds "Résumé"), and everything else animates out under one gentle spring while the survivors reflow. Scope is title and body only — attachment filenames are deliberately not searched. The filter is the single source of truth for what's on the board rather than a highlight over it: the masonry, each lane's count badge, drop zones, the rubber band, ⇧-ranges, Select All, arrow navigation and the ⌥-jumps all read it, and the shown trash filters like any other lane, its card rows and lane rows each by their own text. Nothing invisible stays selected — a card the query hides leaves the selection the moment it goes, and so does one an agent edits out of the match while you search. The field is a control, not an editor: board commands stay live and act on the selection while you type (⌘N included, which clears the search first so a new card is never born invisible), only ⌘←/⌘→ and ⌥⌘←/⌥⌘→ stand down so they stay caret chords, plain ⌫ edits the query while ⌘⌫ still deletes the selection, and Return is swallowed because a live filter has nothing to submit. Tab hands the keyboard to the board with the query intact. Escape steps out one layer per press — a non-empty field clears, an empty one returns focus to the board, and a board-focused Escape under an active search clears it before it means deselect. A rename is deliberately not a carve-out: rename a card out of the match during a search and it animates away exactly as an agent's edit would.
- **Live search** — the board window's toolbar carries one item, a search field (Edit ▸ Find, ⌘F), and typing in it filters the board as you type: a card stays when its title *or* its body contains the query, case- and diacritic-insensitively (so `resume` finds "Résumé"), and everything else animates out under one gentle spring while the survivors reflow. Scope is title and body only — attachment filenames are deliberately not searched. The filter is the single source of truth for what's on the board rather than a highlight over it: the masonry, each lane's count badge, drop zones, the rubber band, ⇧-ranges, Select All, arrow navigation and the ⌥-jumps all read it, and and the shown trash filters like any other lane. Nothing invisible stays selected — a card the query hides leaves the selection the moment it goes, and so does one an agent edits out of the match while you search. The field is a control, not an editor: board commands stay live and act on the selection while you type (⌘N included, which clears the search first so a new card is never born invisible), only ⌘←/⌘→ and ⌥⌘←/⌥⌘→ stand down so they stay caret chords, plain ⌫ edits the query while ⌘⌫ still deletes the selection, and Return is swallowed because a live filter has nothing to submit. Tab hands the keyboard to the board with the query intact. Escape steps out one layer per press — a non-empty field clears, an empty one returns focus to the board, and a board-focused Escape under an active search clears it before it means deselect. A rename is deliberately not a carve-out: rename a card out of the match during a search and it animates away exactly as an agent's edit would.
- **The welcome screen** — branding and two actions on the left, recents on the right: board icon, name, containing folder, and the lane/card counts stamped at last close, newest first. The list never opens a board to build itself, so a huge board or an offline volume costs nothing. Single click selects, double click or Return opens, and a context menu carries Open, Reveal in Finder, and Forget. A board that failed to open or restore says so **on its own row**, in the warning tint, carrying the loader's specifics rather than a modal at launch; a board whose bookmark no longer resolves dims to Unavailable with Open and Reveal off and Forget still live; and a failure naming no known board keeps a list of its own rather than vanishing. File ▸ Open Recent lists the same boards — unavailable ones disabled — with Clear Menu at the bottom, which forgets every record because here the registry *is* the menu.
- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — tombstoned items carried, strays and timestamps untouched.
- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — `.trash/` carried along so the copy matches its own copied history, strays and timestamps untouched.
- **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar leads with the Attachments section (below) and its remaining sections are stacked headers awaiting their content. Reopening a live card focuses the window it already has, and the window closes itself the moment its card stops being live — deleted, tombstoned, buried under a tombstoned lane, or moved to another board.
- **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar leads with the Attachments section (below) and its remaining sections are stacked headers awaiting their content. Reopening a card focuses the window it already has, and the window closes itself the moment its card stops being on the board — moved to the trash (entering the trash counts as deleted), gone with its deleted lane, purged, or moved to another board; a dirty Edit buffer flushes into the card's new location first, so the keystrokes survive a later restore.
- **Attachments** — the sidebar's first section is the card's complete file inventory: every top-level file of its `attachments/` in Finder order, body-embedded ones included, as compact rows carrying a small QuickLook thumbnail (the file's Finder icon until one is generated, and for anything QuickLook won't preview) beside a middle-truncated filename. The whole window is the drop surface — drag files anywhere in it, Edit mode and raw source included, and they import as attachments with Finder-style renames on collision, because the text editor deliberately declines file drags while dragged *text* still lands at the caret exactly as it always did. Folders refuse at the cursor and a mixed drag imports its files and says how many folders it skipped. File ▸ Add Attachment… (⇧⌘A) and a quiet plus in the section header are the same act from the menu bar and the pointer, both opening a multi-select panel into the same import path the board's own file drops use. The section is keyboard-native: it takes focus, arrows walk the rows, Space QuickLooks the selected one in the system's own panel, Return opens it in its default app, and ⌫ moves it to the **system** Trash — never a hard delete, and deliberately distinct from the board's own trash, which is why a failure there says "Couldn't move 'shot.png' to the Trash". Rows drag out their file URL, so a file goes to Finder or another app with no export path of its own; a right-click offers Open, Reveal in Finder and Remove; and File ▸ Reveal in Finder points at the selected attachment while the section holds focus, the card's folder otherwise. Every write is an ordinary bracketed one — one reload, one commit, one banner on failure — and the read-only lock disables adding and removing in place.
@@ -44,7 +44,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, Put Back, and an Edit session's whole run of saves — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Both editions ship it: base runs the native stack, and Lanework Pro binds git behind the same seam without changing a keystroke.
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Both editions ship it: base runs the native stack, and Lanework Pro binds git behind the same seam without changing a keystroke.
## Development