diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index e27273d..dafece9 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -66,7 +66,18 @@ struct BoardWindowHost: View { // The window is handed to the board as a closure, not a value: `WindowAccessor` // attaches after this body first runs, and the lane-resize drag needs the *live* // window to grow at its right edge (03-board-ui.md § Lane). - BoardView(store: store, window: { windowController.window }) + // + // `openCard` is the host's too, for a different reason: a card window's identity is + // `(board, card)` and only this view holds the board half. `openWindow(value:)` with + // a ref that already has a window focuses it, so "at most one card window per card + // (reopen focuses)" needs no bookkeeping here (02-architecture.md § Windows). + BoardView( + store: store, + window: { windowController.window }, + openCard: { cardID in + openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID)) + } + ) } // "The board in front", for the menu items that act on it (`LaneWidthCommands`). .focusedSceneValue(\.boardStore, store) diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 6602bb7..86ebbf9 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -107,17 +107,29 @@ struct KanbanApp: App { /// changing one silently breaks every remap of it. @CommandsBuilder private var menuCommands: some Commands { + // The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board…, + // still owed), Open…. The creation pair validates against the frontmost board through the + // focus system, so both are simply absent-of-effect when no board is in front. CommandGroup(after: .newItem) { + BoardCreationCommands() + + Divider() + Button("Open…") { appModel.presentOpenPanel() } .keyboardShortcut("o", modifiers: .command) } - // The Board menu (11-command-nexus.md). Its items act on the frontmost board window, which - // they reach through the focus system rather than through the app model — see - // `LaneWidthCommands`, which also owns their validation. + // The Board menu (11-command-nexus.md), in its inventoried order — Rename precedes the + // width pair, with Open Card, Style… and the Move items still owed. Its items act on the + // frontmost board window, which they reach through the focus system rather than through the + // app model — see `BoardCommands.swift`, which also owns their validation. CommandMenu("Board") { + BoardRenameCommand() + + Divider() + LaneWidthCommands() } diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index b5e0695..c55975d 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -449,6 +449,11 @@ public final class BannerCenter { if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" } case let .resize(title): if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" } + case let .rename(title): + // The title here is the item's name *before* the edit — the one the user is still + // looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example + // sentence) identify the right row rather than a name that never landed. + if let title { "Couldn't rename '\(title)'" } else { "Couldn't rename the item" } case let .importAttachment(filename): "Couldn't import '\(filename)'" case .listAttachments: diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index cb92026..2f93885 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -645,32 +645,300 @@ public final class BoardStore { } } + // MARK: - Creation + + /// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md). + /// + /// **Untitled, and deliberately with no inline editor.** 03-board-ui.md gives lane titles one + /// editing surface — "Inline rename on the header" — and 04-interactions.md gives that surface + /// one entry point, Board ▸ Rename, "since Return on a lane creates a card". Nothing in either + /// doc opens an editor *at creation*, so a new lane appears with the untitled placeholder and + /// the user renames it if they want a name. Titles are optional at every level; a lane with no + /// `title` key is a legitimate resting state, not a half-finished one. + /// + /// Like `setLaneWidth`, the rethrow is swallowed: `performWrite` has already posted the banner, + /// and a menu item has no second thing to do about a failure. + public func createLane() { + let root = rootURL + try? performWrite { () throws(BoardWriteError) -> Void in + _ = try BoardWriter.createLane(inBoard: root, title: nil) + } + } + + // MARK: - The new-card placeholder's commit + + /// Turns the open placeholder into a real card — the write half of 02-architecture.md § + /// Layering's one named exception to the one-way flow. + /// + /// The five outcomes, all settled: + /// + /// - **No placeholder, or one already committed** — nothing to do. (Idempotence matters: Return + /// commits, and the field's focus-loss handler fires immediately afterwards.) + /// - **An empty title discards it** — "creating-then-abandoning never leaves an empty card + /// behind" (04-interactions.md ▸ Grammar). Whitespace counts as empty: a title of three + /// spaces is a slip, not a deliberate untitled card. + /// - **A vanished lane discards it** — the anchor is gone, so there is nowhere to file the + /// card; the reload that removed the lane is the authority. + /// - **A failed create discards it too** (settled, 02 § Layering): "the overlay never waits for + /// a card that cannot arrive". The failure is already the banner's. + /// - **A successful create hands off**: the overlay flips to `.awaitingArrival` and stands until + /// the watcher round-trips the real card, so the user never sees a hole where they just typed. + /// + /// - Returns: the created card's id, or `nil` on any of the discard paths — which is what the + /// ⌘↩ call site needs to know whether it has a card window to open. + @discardableResult + public func commitPlaceholder() -> ItemID? { + guard let placeholder = transient.newCardPlaceholder, placeholder.phase == .editing else { return nil } + + let title = placeholder.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty, + let lane = snapshot.lanes.first(where: { $0.id == placeholder.laneID && !$0.isDeleted }) + else { + transient.discardPlaceholder() + return nil + } + + let laneFolder = rootURL.appendingPathComponent(placeholder.laneID.rawValue) + let visible = lane.cards.filter { !$0.isDeleted } + // `nil` means "append", which is `createCard`'s own default — so the anchored case is the + // only one that needs a rank at all. + let position = Self.insertionIndex(after: placeholder.anchorCardID, among: visible) + + let created = try? performWrite { () throws(BoardWriteError) -> ItemID in + let id = try BoardWriter.createCard(inLane: laneFolder, title: title) + guard let position else { return id } + + // The rank is computed here rather than passed to `createCard` because the create's + // contract is "append after the visible siblings" and widening it would give every + // caller a position to think about. The reposition rides the Writer's own same-parent + // degenerate reorder — "a move whose destination is the item's current parent degrades + // to a plain reorder" — inside the *same* `performWrite`, so the pair rounds back as + // one app-mediated reload rather than showing the card at the bottom for a frame. + var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position) + if rank == nil { + // Midpoint precision exhausted between the anchor and its neighbour + // (01-storage-format.md § Ordering). Compact, then place against the fresh ranks: + // the new card is not among the renumbered siblings — it was appended past them — + // so the compacted ladder lines up one-for-one with `visible`. + try BoardWriter.renumberVisibleChildren(of: laneFolder) + rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position) + } + guard let rank else { return id } + + _ = try BoardWriter.moveItem( + at: laneFolder.appendingPathComponent(id.rawValue), + toParent: laneFolder, + sourceBoardRoot: rootURL, + destinationBoardRoot: rootURL, + order: rank + ) + return id + } + + guard let created else { + transient.discardPlaceholder() + return nil + } + transient.commitPlaceholder(expecting: created) + return created + } + + /// The display position a new card takes, or `nil` for "append at the bottom". + /// + /// An anchor that is not among `visible` degrades to `nil` rather than failing: the card the + /// ⌘N target rule named was deleted or moved away mid-typing, and the lane — the anchor that + /// actually matters — is still there. Appending is the honest fallback; refusing to create + /// would punish the user for someone else's edit. + nonisolated static func insertionIndex(after anchor: ItemID?, among visible: [Card]) -> Int? { + guard let anchor, let index = visible.firstIndex(where: { $0.id == anchor }) else { return nil } + // Already last: "immediately after it" and "at the bottom" are the same position, and + // append needs no rank of its own. + return index + 1 < visible.count ? index + 1 : nil + } + + // MARK: - Inline rename + + /// Writes the open rename editor's draft — the third inline editor's commit + /// (04-interactions.md ▸ Grammar), reached by Return **and** by focus loss ("a rename commits + /// … the deliberate exception being the placeholder, because nothing exists on disk yet"). + /// + /// Four rules, all from 04 and 03: + /// + /// - **The editor closes first, unconditionally.** Every path below ends with it gone, and + /// retiring it up front is what makes this idempotent — Return commits and the field's + /// focus-loss handler fires an instant later against no editor at all. + /// - **A vanished target writes nothing, silently.** "A target that is tombstoned, deleted, or + /// gone at commit time discards the editor and its keystrokes silently … nothing is ever + /// written into a vanished folder, and no partial `index.md` can resurrect deleted data." + /// Liveness is effective — a card under a tombstoned lane is vanished too. + /// - **An empty commit removes the `title` key** (03-board-ui.md § Card face; 04 ▸ Selection: + /// "Committing an empty rename on an existing item removes its `title` key"), rather than + /// writing `title: ""` — titles are optional, and the face shows the untitled placeholder. + /// - **An unchanged title writes nothing.** `setLaneWidth`'s rule, for the same reason: an + /// editor opened and dismissed with Return must not stamp `modified` or mint a commit. + /// + /// The folder is re-derived from the *current* snapshot, which is what makes a foreign move + /// mid-rename invisible: the editor follows the UUID, and the write lands wherever the item is + /// now. + public func commitRename() { + guard let editor = transient.renameEditor else { return } + transient.discardRename() + + guard let target = Self.liveItem(editor.targetID, in: snapshot) else { return } + + let typed = editor.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let newTitle: String? = typed.isEmpty ? nil : typed + guard newTitle != target.title else { return } + + var folder = rootURL.appendingPathComponent(target.laneID.rawValue) + if let cardID = target.cardID { + folder.append(component: cardID.rawValue) + } + + try? performWrite { () throws(BoardWriteError) -> Void in + // `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so the + // banner names the item by the title it still has rather than the one that failed to + // land (see `WriteOperation.rename`). + try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in + if let newTitle { + document.set(FrontmatterKeys.title, to: .string(newTitle)) + } else { + document.remove(FrontmatterKeys.title) + } + } + } + } + + /// Where a live item lives and what it is currently called, or `nil` when the id names nothing + /// the board renders. + /// + /// **Effective liveness, ancestor-walked** — the same rule `CardWindowHost.cardWindowFate` + /// applies to a card window and `ItemReferenceSet` applies to the selection: a card under a + /// tombstoned lane renders nowhere, so it is as gone as a deleted one. The path is returned as + /// its two identity components rather than as a URL so the caller builds it off the store's + /// *current* `rootURL`, which a mid-session folder rename may have moved. + nonisolated static func liveItem( + _ id: ItemID, + in snapshot: BoardModel + ) -> (laneID: ItemID, cardID: ItemID?, title: String?)? { + for lane in snapshot.lanes where !lane.isDeleted { + if lane.id == id { + return (laneID: lane.id, cardID: nil, title: lane.title.value) + } + if let card = lane.cards.first(where: { $0.id == id && !$0.isDeleted }) { + return (laneID: lane.id, cardID: card.id, title: card.title.value) + } + } + return nil + } + + // MARK: - Lane reorder + + /// Commits a lane drag: `id` lands at display position `index` among the board's live lanes, + /// counted **with the dragged lane itself removed** — which is the index + /// `LaneReorderMath.proposedIndex` produces. + /// + /// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md ▸ + /// Drag and drop) and belongs to m5's drag card; here source and destination board roots are + /// the same URL, so `moveItem` takes its same-parent degenerate-reorder path and rewrites + /// exactly one file — the moved lane's `order`. + /// + /// **A drag that ends where it started writes nothing**: `index == from` re-inserts the lane in + /// its own slot, and a no-op must not stamp `modified` or mint a commit — the resize drag's + /// rule, and for the same reason. + public func moveLane(_ id: ItemID, toIndex index: Int) { + let lanes = snapshot.lanes.filter { !$0.isDeleted } + guard let from = lanes.firstIndex(where: { $0.id == id }) else { return } + + var remaining = lanes + remaining.remove(at: from) + let target = min(max(0, index), remaining.count) + guard target != from else { return } + + let root = rootURL + let folder = root.appendingPathComponent(id.rawValue) + try? performWrite { () throws(BoardWriteError) -> Void in + var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target) + if rank == nil { + // Compact and place again. Unlike the card case the dragged lane *is* among the + // renumbered children — it is a real folder on disk — so its fresh rank is dropped + // from the ladder before the neighbours are consulted. + try BoardWriter.renumberVisibleChildren(of: root) + var compacted = Ranks.renumbered(count: lanes.count) + compacted.remove(at: from) + rank = Ranks.insertionRank(amongVisible: compacted, at: target) + } + guard let rank else { return } + + _ = try BoardWriter.moveItem( + at: folder, + toParent: root, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: rank + ) + } + } + // MARK: - Selection (delegated) - // The three thin pass-throughs to `transient`, and the only ones. + // The thin pass-throughs to `transient`, and the only ones. // // **Conveniences, not a second home.** The selection is the transient state every command site // touches — menu validation, ⌫, paste anchoring, Select All — and `store.selection` reads better // at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing // is stored here: `selection` is computed and the two mutators forward, so there is no second - // copy to go stale. Drag membership, the pending cut, the query and the placeholder get no such + // copy to go stale. Drag membership, the pending cut, the query and the editors get no such // shortcuts — they have one or two call sites each, and a delegate per field would be the // grab-bag reassembling itself on this class. + // + // `isEditingInline` earns one for the selection's reason and no other: **every** board-mutating + // menu item validates against it (04-interactions.md's focused-editor rule), and a rule read + // that often should read as one word. /// The board's selection, re-resolved against every snapshot this store applies — /// `TransientBoardState.selection` under a shorter name. public var selection: ItemReferenceSet { transient.selection } - /// Replaces the selection — `TransientBoardState.select(_:liveness:)`, which owns the semantics. + /// Whether an inline title editor is open — `TransientBoardState.isEditingInline`, which owns + /// what it means and why every mutating command reads it. + public var isEditingInline: Bool { transient.isEditingInline } + + /// Replaces the selection, and **records the lane it lands in** as the last-active one. + /// + /// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it + /// takes a snapshot to answer "which lane is that". 04-interactions.md's ⌘N target rule calls + /// for "the lane that most recently held selection or a creation", and a *card* selection is + /// its lane holding selection just as much as the lane's own header click is — so both are + /// noted here, and creation notes itself in `beginPlaceholder`. public func select(_ ids: Set, liveness: Liveness) { transient.select(ids, liveness: liveness) + transient.noteActiveLane(Self.lane(holding: ids, in: snapshot)) } /// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). + /// + /// The last-active lane deliberately survives: it is a high-water mark of where the user has + /// been working, and ⌘N after a deselect is exactly the case it exists to answer. public func clearSelection() { transient.clearSelection() } + /// The lane a selection sits in, or `nil` when it names no single one — a live lane selects + /// itself; live cards select their lane, but only when they all share one (a cross-lane + /// selection has no single home to remember). + nonisolated static func lane(holding ids: Set, in snapshot: BoardModel) -> ItemID? { + guard !ids.isEmpty else { return nil } + var found: ItemID? + for lane in snapshot.lanes where !lane.isDeleted { + let names = ids.contains(lane.id) || lane.cards.contains { !$0.isDeleted && ids.contains($0.id) } + guard names else { continue } + guard found == nil else { return nil } + found = lane.id + } + return found + } + // MARK: - Quiescence /// Suspends until no reload is running and none is owed. diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index 5ad9b9c..3b7a1d3 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -155,18 +155,81 @@ public struct NewCardPlaceholder: Sendable, Equatable { /// has before its title commits. public var laneID: ItemID + /// The card the new one is being born **immediately after**, or `nil` for the lane's bottom. + /// + /// It exists because 04-interactions.md's ⌘N target rule is a *position*, not just a lane: + /// "with a card selected, the new card is created in that card's lane, immediately after it + /// (paste-anchor consistency)". Every other entry point — Return on a lane, the header button, + /// a double-click on empty space, ⌘N with nothing selected — appends at the bottom and passes + /// `nil`. + /// + /// **A position, deliberately not a rank.** The `order` is computed at *commit* time against + /// the snapshot as it is then (`BoardStore.commitPlaceholder`), never frozen when the editor + /// opened: an agent filing a card into the gap mid-typing must move the new card, not be + /// overwritten by a stale midpoint. An anchor that has vanished by commit time degrades to the + /// lane's bottom rather than failing — the lane is the anchor that matters, and the card being + /// created is the user's, not the vanished neighbour's. + public var anchorCardID: ItemID? + /// What the user has typed so far. Lives here and nowhere else: there is no file to hold it. public var draftTitle: String public var phase: Phase - public init(laneID: ItemID, draftTitle: String = "", phase: Phase = .editing) { + public init( + laneID: ItemID, + anchorCardID: ItemID? = nil, + draftTitle: String = "", + phase: Phase = .editing + ) { self.laneID = laneID + self.anchorCardID = anchorCardID self.draftTitle = draftTitle self.phase = phase } } +// MARK: - RenameEditor + +/// The **third inline editor** (04-interactions.md ▸ Grammar): a card's or a lane's title being +/// edited in place, on the card face or in the lane header. +/// +/// It is `NewCardPlaceholder`'s sibling in shape and its opposite in almost every rule, and the +/// contrast is worth stating once: +/// +/// | | `NewCardPlaceholder` | `RenameEditor` | +/// |---|---|---| +/// | References | a *lane* (no identity yet) | an **item**, by UUID | +/// | Click-away | discards — nothing is on disk | **commits** — the item is real | +/// | Empty commit | discards the whole draft | removes the `title` key | +/// | A reload can | drop it, or hand it off | only drop it | +/// +/// **Tracked by UUID, which makes a foreign move invisible.** "A foreign *move* mid-rename is +/// invisible — the editor follows the UUID and the commit writes the title wherever the card now +/// lives"; the commit re-derives the folder from the current snapshot, so a card an agent filed +/// into another lane mid-typing is still renamed correctly. +/// +/// There is no phase enum. The placeholder needs one because it outlives its own commit (the +/// overlay stands in for a card that has not arrived yet); a rename has nothing to stand in for — +/// the item is already on screen, and the commit's round trip simply updates it. +public struct RenameEditor: Sendable, Equatable { + + /// The card or lane being renamed. Deliberately untyped as to *kind*: the commit derives the + /// folder by finding the id in the snapshot, and every rule here — the vanish discard, the + /// UUID tracking, the empty-commit removal — reads identically for both levels. + public var targetID: ItemID + + /// What the user has typed. **Seeded from the current title** when the editor opens (an + /// untitled item seeds empty, since "Untitled" is a rendering, not a value — + /// 03-board-ui.md § Card face), and thereafter the only place the draft exists. + public var draftTitle: String + + public init(targetID: ItemID, draftTitle: String = "") { + self.targetID = targetID + self.draftTitle = draftTitle + } +} + // MARK: - TransientBoardState /// Everything a board's windows share that is **not on disk** — one per `BoardStore`, created with @@ -186,12 +249,18 @@ public struct NewCardPlaceholder: Sendable, Equatable { /// reload lands and a card edited to no longer match animates out. A stored result set would be /// a second, staler answer to a question the snapshot can always answer, and would need a /// re-resolution rule of its own — which is exactly the accretion this type exists to stop. -/// 3. **The overlay** — `newCardPlaceholder`, anchored to a lane rather than to items, discarded -/// when the lane it is anchored to goes away and handed off when the real card arrives. +/// 3. **The inline editors** — `newCardPlaceholder`, anchored to a lane rather than to items, +/// discarded when the lane it is anchored to goes away and handed off when the real card +/// arrives; and `renameEditor`, anchored to an *item* and discarded when that item vanishes. +/// Two editors, one at a time: they share the app's single keyboard focus, so beginning either +/// ends the other. /// /// The remainder is plain per-open values: `isTrashVisible` is hidden on every open and **never /// persisted** — visiting the trash is an errand, not a layout choice. It needs no reset logic /// because this object is built fresh with its store; closing the board is the reset. +/// `lastActiveLaneID` is per-open in the same sense — "in this window session" is exactly the +/// scope of a container built with its store — but it *does* reference an item, so `resolve` has +/// something to say about it. /// /// `@MainActor` because it is read by SwiftUI on the main actor and mutated by gestures there; /// `@Observable` so the board window and its card windows re-render off the same truth. @@ -238,7 +307,7 @@ public final class TransientBoardState { /// of its own. public var searchQuery: String = "" - // MARK: The overlay + // MARK: The inline editors /// The new-card placeholder, or `nil` when no card is being created. See `NewCardPlaceholder` /// for what it is and `resolve(against:)` for what a reload does to it. @@ -247,8 +316,40 @@ public final class TransientBoardState { /// card is exactly the kind of state that rots if anyone may assign it. public private(set) var newCardPlaceholder: NewCardPlaceholder? + /// The inline rename in flight, or `nil` when no title is being edited. See `RenameEditor`. + /// + /// `private(set)` for the placeholder's reason, plus one of its own: the *commit* is a write + /// that only `BoardStore` can perform, so an editor assignable from anywhere could be cleared + /// out from under a commit that was about to read its draft. + public private(set) var renameEditor: RenameEditor? + + /// Whether a title editor holds focus — 04-interactions.md's **focused-editor rule** as one + /// boolean: "while an inline title editor — rename or the new-card placeholder — is focused, + /// board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu + /// validation". + /// + /// Every board-mutating menu item validates against this, so the rule is stated once rather + /// than re-derived per item. The one carve-out the design names — Open Card ⌘↩, which stays + /// enabled to commit the edit and open the window — is the item's business, not this flag's. + public var isEditingInline: Bool { + newCardPlaceholder != nil || renameEditor != nil + } + // MARK: Per-open values + /// The lane that most recently held selection or a creation **in this window session** — + /// 04-interactions.md's ⌘N target rule's fallback when nothing (or a tombstoned something) is + /// selected, before the last resort of the first lane. + /// + /// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing + /// in the snapshot that could reconstruct which lane the user was last working in, which is + /// precisely why the rule exists — a ⌘N after an Escape should file the card where the user + /// has been, not at the far left of the board. + /// + /// `resolve(against:)` clears it when the lane vanishes, because a target that renders nowhere + /// is no target at all; `NewCardTarget` then falls through to the first lane. + public private(set) var lastActiveLaneID: ItemID? + /// Whether the trash quasi-lane is showing (03-board-ui.md ▸ Trash). /// /// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout @@ -276,15 +377,37 @@ public final class TransientBoardState { selection = .empty } + /// Records that `laneID` is where the user is working — a lane selected, or created into. + /// + /// **`nil` is a no-op, not a clear.** "Last-active" is a high-water mark: clearing the + /// selection does not un-happen the lane the user was just in, and 04-interactions.md's rule + /// leans on exactly that (⌘N *with nothing selected* is the case the memory serves). The only + /// thing that clears it is the lane going away, which `resolve(against:)` owns. + public func noteActiveLane(_ laneID: ItemID?) { + guard let laneID else { return } + lastActiveLaneID = laneID + } + // MARK: - The placeholder's lifecycle - /// Opens the inline editor for a new card in `laneID`, replacing any placeholder already open. + /// Opens the inline editor for a new card in `laneID`, replacing any editor already open and + /// marking the lane active. /// - /// Replacing rather than refusing: two placeholders can never be open at once (one inline editor, - /// one focus), so a second begin is the first one being abandoned — 04-interactions.md's - /// click-away discard, arriving as a new creation instead of a click. - public func beginPlaceholder(inLane laneID: ItemID) { - newCardPlaceholder = NewCardPlaceholder(laneID: laneID) + /// Replacing rather than refusing: two inline editors can never be open at once (one focus), + /// so a second begin is the first one being abandoned — 04-interactions.md's click-away + /// discard, arriving as a new creation instead of a click (02-architecture.md § Layering + /// states it outright: "Starting a new creation while a placeholder is open is a click-away + /// for the draft"). A rename in flight is dropped for the same reason; a rename's click-away + /// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the + /// focus is being taken by another editor before the user has said they are done. + /// + /// - Parameter anchorCardID: the card the new one is born immediately after (04's ⌘N target + /// rule), or `nil` for the lane's bottom — which is what Return, the header button, and a + /// double-click on empty space all pass. + public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) { + renameEditor = nil + newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID) + noteActiveLane(laneID) } /// Records what the user has typed. A no-op with no placeholder open — the draft has nowhere to @@ -312,6 +435,37 @@ public final class TransientBoardState { newCardPlaceholder = nil } + // MARK: - The rename editor's lifecycle + + /// Opens the inline rename of `targetID`, seeded with `currentTitle`. + /// + /// The seed is the caller's because this type holds no snapshot: the two entry points (Return + /// on a sole selected card, Board ▸ Rename) both have the item in hand already. `nil` seeds an + /// empty field — an untitled item has no title to edit, and "Untitled" is a rendering that + /// must never be typed into the file (03-board-ui.md § Card face). + /// + /// Replaces whatever editor was open, for `beginPlaceholder`'s reason: one focus, one editor. + public func beginRename(of targetID: ItemID, currentTitle: String?) { + newCardPlaceholder = nil + renameEditor = RenameEditor(targetID: targetID, draftTitle: currentTitle ?? "") + } + + /// Records what the user has typed. A no-op with no editor open, like `updateDraft`. + public func updateRenameDraft(_ title: String) { + renameEditor?.draftTitle = title + } + + /// Closes the rename editor without writing — **Escape's abandon**, and also how + /// `BoardStore.commitRename` retires the editor once its write has been issued (or refused). + /// + /// There is no `commitRename` here for the same reason there is no create here: this type + /// stores no URLs and performs no I/O. The keystrokes are simply dropped; on the abandon path + /// disk was never touched, and on the commit path disk has already been touched by the time + /// this runs. + public func discardRename() { + renameEditor = nil + } + // MARK: - Reload /// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot. @@ -341,6 +495,19 @@ public final class TransientBoardState { /// Otherwise the placeholder survives untouched: reloads swap the snapshot *underneath* the /// overlay, exactly as they do underneath the selection. /// + /// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md ▸ + /// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target + /// that is tombstoned, deleted, or gone discards the editor and its keystrokes silently. + /// A foreign *move* is deliberately not a vanish — the editor follows the UUID and the commit + /// writes wherever the item now lives — which falls out for free from matching on identity + /// rather than on position. Liveness is **effective**, so a card under a lane an agent just + /// tombstoned vanishes with it. + /// + /// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md + /// gives every item-referencing piece of transient state: nothing may reference an item the + /// current universe does not have. It is not an `ItemReferenceSet` only because it is one + /// optional rather than a set on a side — the rule it obeys is the same one. + /// /// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references /// an item, so no snapshot can invalidate either — the query's *results* change with every /// snapshot, which is precisely why the results are not stored here. @@ -349,6 +516,16 @@ public final class TransientBoardState { dragMembers = dragMembers.resolved(against: snapshot) pendingCut = pendingCut.resolved(against: snapshot) newCardPlaceholder = resolvedPlaceholder(against: snapshot) + + // One universe computed once and asked three questions — the rename target's liveness, the + // last-active lane's, and (via the placeholder above, which asks its own way) the anchor's. + let live = ItemReferenceSet.idUniverse(of: snapshot, on: .live) + if let editor = renameEditor, !live.contains(editor.targetID) { + renameEditor = nil + } + if let lane = lastActiveLaneID, !live.contains(lane) { + lastActiveLaneID = nil + } } /// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot. diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 738c49c..444bf0f 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -1193,6 +1193,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case purge(title: String?) case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md) case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane) + /// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸ + /// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the + /// surfaces" is settled (02-architecture.md § Write-failure surfacing, which names + /// "Couldn't rename 'Fix login'…" verbatim), and a rename that failed must not tell the user + /// the app could not *restyle* something. `title` is the item's title as it stood **before** + /// the edit — `updateIndex` enriches it off the document it just read — which is the name the + /// user is still looking at when the banner appears. + case rename(title: String?) case importAttachment(filename: String) case listAttachments case renumberChildren // order-maintenance sweep (compaction) @@ -1218,6 +1226,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case .purge: .purge(title: title) case .style: .style(title: title) case .resize: .resize(title: title) + case .rename: .rename(title: title) } } @@ -1240,6 +1249,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case let .purge(title): Self.phrase("purge", title) case let .style(title): Self.phrase("style", title) case let .resize(title): Self.phrase("resize", title) + case let .rename(title): Self.phrase("rename", title) case let .importAttachment(filename): "import attachment '\(filename)'" case .listAttachments: "list attachments" case .renumberChildren: "renumber children" diff --git a/Kanban/Storage/Ranks.swift b/Kanban/Storage/Ranks.swift index 7a2d6a0..e7f5ae8 100644 --- a/Kanban/Storage/Ranks.swift +++ b/Kanban/Storage/Ranks.swift @@ -38,6 +38,29 @@ enum Ranks: Sendable { return mid } + /// The rank an item takes when it lands at display position `index` among `orders` — the + /// visible siblings it is joining, **in display order and with the item itself already + /// excluded**. + /// + /// One function for the three cases every insertion gesture has (a lane drag's release, a + /// drop between two cards, ⌘N's after-the-anchor position), so no call site re-derives which + /// of `insertAtHead`/`midpoint`/`append` its position calls for: + /// + /// - at or before the head → `insertAtHead(ofVisible:)`; + /// - at or past the end (an empty `orders` included) → `append(toVisible:)`; + /// - between two siblings → their `midpoint`. + /// + /// **`nil` means the gap is exhausted, not that the insertion is illegal**: `midpoint` returns + /// no value when two neighbours are adjacent `Double`s or share an order (the duplicate-order + /// tie). That is the renumber trigger (01-storage-format.md § Ordering) and the caller's cue to + /// compact and ask again — never something to paper over with an arbitrary rank, which would + /// silently reorder the board. + static func insertionRank(amongVisible orders: [Double], at index: Int) -> Double? { + if orders.isEmpty || index >= orders.count { return append(toVisible: orders) } + if index <= 0 { return insertAtHead(ofVisible: orders) } + return midpoint(between: orders[index - 1], and: orders[index]) + } + /// `count` fresh ranks, whole multiples of 1024 in ascending order /// (1024, 2048, …) — the renumber target when midpoint precision is /// exhausted. Deterministic by construction; the writer applies these, diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index 3172da7..b7ccc6d 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -20,6 +20,107 @@ extension FocusedValues { } } +// MARK: - Shared validation + +/// The two conditions **every** board-mutating menu item disables on, in one place. +/// +/// - **The read-only lock** (02-architecture.md § The lock's scope): "every mutating command +/// disables via menu validation" across every window sharing the store. An item that is going to +/// be refused should not look available. +/// - **The focused-editor rule** (04-interactions.md ▸ Grammar, settled): "while an inline title +/// editor — rename or the new-card placeholder — is focused, board-scoped menu commands (Delete, +/// New Card, Paste, Move, Style, …) disable via menu validation" and the keyboard belongs to the +/// text domain. The one carve-out the design names is Open Card ⌘↩, which stays enabled to commit +/// the edit and open the window — it is not a menu item yet (m5), and when it is, it is the one +/// item that must *not* read this property. +/// +/// Stated once rather than repeated per item, because the interesting failure mode is an item that +/// quietly forgets half of it. +extension BoardStore { + var acceptsBoardMutations: Bool { + !isReadOnly && !isEditingInline + } +} + +// MARK: - Creation items + +/// File ▸ New Card (⌘N) and File ▸ New Lane (⇧⌘N) — 11-command-nexus.md's two creation rows. +/// +/// **New Card resolves its target through `NewCardTarget`**, the ⌘N target rule as a pure function, +/// and uses the *same* answer for its `disabled` state as for its action: a `nil` resolution is the +/// zero-lane board, where "card creation and card paste have no target — New Card, Return-creation, +/// and Paste with a card payload disable via menu validation until a lane exists" +/// (04-interactions.md ▸ The map). Two derivations of that condition would be two chances to +/// disagree. +/// +/// **New Lane is enabled whenever the board accepts writes.** It is the way *out* of a zero-lane +/// board — "New Lane (⇧⌘N) is one way in" — so it can have no selection precondition at all. The +/// lane it creates is untitled and no editor opens on it; see `BoardStore.createLane`. +struct BoardCreationCommands: View { + + @FocusedValue(\.boardStore) private var store + + var body: some View { + Button("New Card") { + guard let store, let target = newCardTarget else { return } + store.transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID) + } + .keyboardShortcut("n", modifiers: .command) + .disabled(newCardTarget == nil) + + Button("New Lane") { + store?.createLane() + } + .keyboardShortcut("n", modifiers: [.shift, .command]) + .disabled(store?.acceptsBoardMutations != true) + } + + /// Where ⌘N would file a card, or `nil` when it cannot — no focused board, a board that refuses + /// writes, an inline editor holding the keyboard, or a board with no lanes. + private var newCardTarget: NewCardTarget.Resolution? { + guard let store, store.acceptsBoardMutations else { return nil } + return NewCardTarget.resolve( + selection: store.selection, + lastActiveLaneID: store.transient.lastActiveLaneID, + snapshot: store.snapshot + ) + } +} + +// MARK: - Rename + +/// Board ▸ Rename — no default chord, deliberately (11-command-nexus.md: "— (cards: Return in +/// place)"), and remappable like any other item. +/// +/// It "exists for completeness and remapping" for cards, whose real path is Return, and it is a +/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could +/// never be renamed at all (04-interactions.md ▸ Selection). +/// +/// Validation is the sole-selected-live-item rule — card or lane, either kind, exactly one. A +/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned +/// selections" (04 ▸ The trash), which `ItemReferenceSet`'s liveness side answers directly. +struct BoardRenameCommand: View { + + @FocusedValue(\.boardStore) private var store + + var body: some View { + Button("Rename") { + guard let store, let target = 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.liveness == .live, selection.ids.count == 1, let id = selection.ids.first, + let item = BoardStore.liveItem(id, in: store.snapshot) + else { return nil } + return (id: id, title: item.title) + } +} + // MARK: - Lane width items /// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane, @@ -31,9 +132,7 @@ extension FocusedValues { /// **Validation is the sole-selected-lane rule.** Both items are enabled only when the focused /// board's selection resolves to exactly one live lane; a card selection, a multi-selection, a /// trash-side selection and an empty one all disable them. Decrease additionally disables at one -/// unit, which is the floor. Nothing selects a lane yet — the lane-chrome card wires the header -/// click (04-interactions.md ▸ Selection) — so these validate-disable in today's build, which is -/// expected rather than broken. +/// unit, which is the floor. struct LaneWidthCommands: View { @FocusedValue(\.boardStore) private var store @@ -54,11 +153,10 @@ struct LaneWidthCommands: View { /// The sole selected live lane, or `nil` — the whole of these items' validation. /// - /// A read-only board disables every mutating command (02-architecture.md § The lock's scope), so - /// the lock is folded in here rather than left for the write to refuse: an item that is going to - /// fail should not look available. + /// The lock and the open-editor rule are folded in through `acceptsBoardMutations` rather than + /// left for the write to refuse: an item that is going to fail should not look available. private var selectedLane: Lane? { - guard let store, !store.isReadOnly else { return nil } + guard let store, store.acceptsBoardMutations else { return nil } let selection = store.selection guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil } return store.snapshot.lanes.first { $0.id == id && !$0.isDeleted } diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index b4af1be..59b4cf4 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -15,11 +15,22 @@ import SwiftUI /// keyboard face — re-divides the existing window width across the new unit total, compressing the /// siblings and never touching the window (`BoardStore.setLaneWidth`). /// +/// ### The three interactions it hosts +/// +/// - **Lane resize** — the right-edge grab strip (above). +/// - **Lane reorder** — the whole title bar is the drag surface (`LaneReorderSession`, +/// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be +/// order. +/// - **The keyboard's narrow slice** — Return's create/rename dispatch and Escape's step outward. +/// /// ### What is deliberately not here yet /// -/// Selection, drag and drop, the trash quasi-lane, the toolbar, search, styling and the lane context -/// menu all belong to later milestone cards. This view is the layout and the resize interaction, and -/// the chrome inside `LaneView` is a placeholder those cards replace. +/// The trash quasi-lane, the toolbar, search, styling, the lane context menu, and drag & drop's real +/// machinery (multi-drag, cross-board locality, the shadow's hold rule) all belong to later +/// milestone cards, and the card face inside `LaneView` is still a stub those cards replace. The +/// **selection grammar** here is likewise minimal — a click replaces the selection, and that is all: +/// ⌘-click toggling, ⇧-click ranges, the rubber band and the cards-XOR-lanes homogeneity rule are +/// m5's selection-model card. struct BoardView: View { let store: BoardStore @@ -29,10 +40,23 @@ struct BoardView: View { /// after the first body evaluation. let window: @MainActor () -> NSWindow? + /// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from + /// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef` + /// needs the board's own window ref, which is the host's identity and not the board's. + let openCard: (ItemID) -> Void + /// One resize at a time, per window. `@State` so it lives exactly as long as this board window's /// view does, which is the interaction's whole lifetime. @State private var resize = LaneResizeSession() + /// One reorder at a time, per window — same lifetime, same reasoning. + @State private var reorder = LaneReorderSession() + + /// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored + /// deliberately whenever an inline editor closes: the field that had focus is gone, and Return + /// must go back to meaning create/rename rather than nothing at all. + @FocusState private var isBoardFocused: Bool + /// The inter-lane gap, and the strip's outer margin — one number, because the standard-width /// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`). private let spacing: CGFloat = 12 @@ -51,16 +75,38 @@ struct BoardView: View { stripWidth: viewport.size.width, totalUnits: LaneLayoutMath.totalUnits(of: lanes), gap: spacing) + // The lanes in the order the strip should *show* them: their snapshot order at rest, and + // the drag's would-be order while a reorder is in flight — which is how the siblings + // reflow to make room (04-interactions.md ▸ Drag and drop). The proposal is recomputed + // here on every render, so a foreign reload mid-drag simply moves the zones (rule 1 of + // that section's re-grounding trio). + let shown = shownLanes(lanes, standard: standard) HStack(alignment: .top, spacing: spacing) { - ForEach(lanes) { lane in - laneSlot(lane, standard: standard) + ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in + laneSlot(lane, at: position, among: shown, standard: standard) } } .padding(spacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + // The board is a focus target so the grammar keys reach it at all. The focus *ring* is off: + // the strip is the window's content, not a control, and a rectangle around the whole board + // would read as an error state. + .focusable() + .focusEffectDisabled() + .focused($isBoardFocused) + .onAppear { isBoardFocused = true } + .onChange(of: store.isEditingInline) { _, editing in + // An editor took focus and has now given it back. Without this the strip stays unfocused + // after every rename and Return silently stops working. + if !editing { isBoardFocused = true } + } + .onKeyPress(.return) { handleReturn() } + .onKeyPress(.escape) { handleEscape() } } + // MARK: - Lanes + /// One lane's strip slot, plus its trailing grab strip. /// /// Normally a plain `LaneView` sized to its unit count's slot width (a wide lane swallows the @@ -76,9 +122,15 @@ struct BoardView: View { /// /// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out /// off the tidy snapped layout regardless of the live overflow. + /// + /// While this lane is being **reordered** the slot instead keeps its resting size and travels: + /// the offset is the gap between where the pointer has carried it and where it would rest under + /// the current proposal, so it tracks the cursor 1:1 while its siblings sit in the would-be + /// order beneath it (`zIndex(2)`, above even a resize). @ViewBuilder - private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View { + private func laneSlot(_ lane: Lane, at position: Int, among shown: [Lane], standard: CGFloat) -> some View { let resizing = resize.isResizing(lane.id) + let dragging = reorder.isDragging(lane.id) let units = resizing ? resize.units : LaneLayoutMath.displayUnits(of: lane) let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing) ZStack(alignment: .topLeading) { @@ -93,11 +145,20 @@ struct BoardView: View { // continuous width and not the not-yet-committed `lane.width`. The live width still // narrows and widens the columns continuously, so the cards reflow under the cursor // between ticks (free via `MasonryLayout`). - LaneView(lane: lane, columns: units) - .frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading) + LaneView( + store: store, + lane: lane, + columns: units, + reorder: reorder, + headerDrag: headerDrag(at: position, among: shown, standard: standard), + openCard: openCard + ) + .frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading) } .frame(width: slotWidth, alignment: .topLeading) - .zIndex(resizing ? 1 : 0) + .offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0) + .opacity(dragging ? 0.9 : 1) + .zIndex(dragging ? 2 : (resizing ? 1 : 0)) .overlay(alignment: .trailing) { LaneResizeHandle( store: store, @@ -112,8 +173,10 @@ struct BoardView: View { // (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag // resizes the *window* on the way, so a refused commit would leave the window grown // around a lane that snapped back — and the lock's row is already saying why nothing - // can be written. - .disabled(store.isReadOnly) + // can be written. The focused-editor rule closes it too, like every board command, and + // so does a reorder in flight: two drags mutating one strip layout is not a state this + // view has a meaning for. + .disabled(store.isReadOnly || store.isEditingInline || reorder.isActive) } } @@ -123,6 +186,128 @@ struct BoardView: View { private var liveLanes: [Lane] { store.snapshot.lanes.filter { !$0.isDeleted } } + + // MARK: - Reorder + + /// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in + /// flight. A drag whose lane has vanished from the snapshot shows the plain order and proposes + /// nothing — its release then cancels (04 ▸ Drag and drop, "an emptied drag cancels itself"). + private func shownLanes(_ lanes: [Lane], standard: CGFloat) -> [Lane] { + guard let (from, to) = proposal(among: lanes, standard: standard) else { return lanes } + return LaneReorderMath.reordered(lanes, from: from, to: to) + } + + /// Where the dragged lane sits in `lanes` and where it would land — `nil` when no reorder is in + /// flight, or when the lane it is carrying is no longer on the board. + private func proposal(among lanes: [Lane], standard: CGFloat) -> (from: Int, to: Int)? { + guard let id = reorder.laneID, let from = lanes.firstIndex(where: { $0.id == id }) else { return nil } + let to = LaneReorderMath.proposedIndex( + unitCounts: unitCounts(of: lanes), + draggedIndex: from, + dragCentreX: reorder.centre, + standard: standard, + gap: spacing + ) + return (from, to) + } + + /// How far the travelling lane is drawn from the slot it would rest in — the pointer's position + /// minus the proposal's. Zero at the instant a tick lands, growing again as the pointer moves + /// on, which is what makes the replica read as *held* rather than as snapping. + private func travelOffset(at position: Int, among shown: [Lane], standard: CGFloat) -> CGFloat { + reorder.centre - LaneReorderMath.centre( + ofLaneAt: position, + unitCounts: unitCounts(of: shown), + standard: standard, + gap: spacing + ) + } + + /// The strip's half of a lane header's drag: where the lane rests now, and what a release means. + private func headerDrag(at position: Int, among shown: [Lane], standard: CGFloat) -> LaneHeaderDrag { + LaneHeaderDrag( + startCentre: { + LaneReorderMath.centre( + ofLaneAt: position, + unitCounts: unitCounts(of: shown), + standard: standard, + gap: spacing + ) + }, + commit: { commitReorder(standard: standard) } + ) + } + + /// Releases the drag: re-derive the proposal against the snapshot **as it is now** and write it. + /// + /// Re-deriving rather than trusting the last rendered proposal is 04-interactions.md ▸ Drag and + /// drop's re-grounding rule at its most consequential moment: a reload that landed between the + /// last render and the release must not be written over. A lane that vanished in that window + /// yields no proposal and the release simply cancels — "release with no valid proposal cancels; + /// items return, nothing is written". + /// + /// `BoardStore.moveLane` owns the rest, the unchanged-index no-op included. + private func commitReorder(standard: CGFloat) { + defer { reorder.end() } + guard let id = reorder.laneID, + let (_, to) = proposal(among: liveLanes, standard: standard) + else { return } + store.moveLane(id, toIndex: to) + } + + private func unitCounts(of lanes: [Lane]) -> [Int] { + lanes.map { LaneLayoutMath.displayUnits(of: $0) } + } + + // MARK: - Grammar keys + + /// **Return**, narrowly (04-interactions.md ▸ Grammar): a sole selected live card begins an + /// inline rename, a sole selected live lane begins a new-card placeholder at its bottom, and + /// everything else is ignored — a multi-card selection is explicitly inert, and a lane's rename + /// path is Board ▸ Rename precisely because Return on a lane creates. + /// + /// The full keyboard map — arrows, ⌥-jumps, the ⌥↑ escalation, ⌫, the ⌥⌘ moves — is **m5's + /// keyboard-grammar card**. This is the creation/rename pair and nothing else. + /// + /// Inert while an inline editor is open: "all grammar keys inert while a title editor is + /// focused". The field consumes Return itself, so this guard is belt over braces — but the belt + /// matters, because a stray Return reaching here mid-edit would open a *second* editor. + private func handleReturn() -> KeyPress.Result { + guard !store.isEditingInline, !store.isReadOnly else { return .ignored } + let selection = store.selection + guard selection.liveness == .live, + selection.ids.count == 1, + let id = selection.ids.first, + let target = BoardStore.liveItem(id, in: store.snapshot) + else { return .ignored } + + if target.cardID == nil { + store.transient.beginPlaceholder(inLane: target.laneID) + } else { + store.transient.beginRename(of: id, currentTitle: target.title) + } + return .handled + } + + /// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else + /// clear the selection. + /// + /// The middle step — clearing an active search and returning focus to the board — is m5's, and + /// it slots between these two once the search field exists. + /// + /// The editors handle Escape themselves while they hold focus; this branch is the outer net for + /// the case where focus has drifted off the field with an editor still open, and it abandons + /// both kinds because at most one can be open at a time. + private func handleEscape() -> KeyPress.Result { + if store.isEditingInline { + store.transient.discardPlaceholder() + store.transient.discardRename() + return .handled + } + guard !store.selection.isEmpty else { return .ignored } + store.clearSelection() + return .handled + } } // MARK: - Resize shadow diff --git a/Kanban/UI/Board/LaneReorderMath.swift b/Kanban/UI/Board/LaneReorderMath.swift new file mode 100644 index 0000000..ef4797e --- /dev/null +++ b/Kanban/UI/Board/LaneReorderMath.swift @@ -0,0 +1,101 @@ +import CoreGraphics + +/// The lane-reorder drag's geometry, as pure arithmetic — no view, no session, no snapshot +/// (`LaneReorderMathTests`). `LaneLayoutMath`'s sibling: that one owns the resting layout and the +/// right-edge resize, this one owns "where would the lane land if I let go now". +/// +/// **Geometry-based, so the proposal is stable rather than jittery** (04-interactions.md ▸ Drag and +/// drop): the answer is a function of analytically computed resting positions and one pointer +/// coordinate — never of measured mid-flight frames, which are garbage precisely during the reflow +/// they trigger (03-board-ui.md § Motion, "Motion never feeds back into logic"). +/// +/// **Width-aware by construction.** The design asks for "no reflow until the cursor reaches where +/// the dragged lane would actually land"; comparing against each remaining lane's *centre* is +/// exactly that — a 3× lane's centre is three units along, so the drag has to travel most of that +/// lane's width before the board proposes stepping past it, and a 1× lane yields quickly. +/// +/// ### What this deliberately is not +/// +/// The full drag model — the shadow's hold-until-a-new-candidate rule, multi-drag's N contiguous +/// shadows, cross-board locality with its copy/move badge, and the mid-drag re-grounding rules — is +/// **m5's drag card**, which replaces this file's callers with the real `DropSlot` +/// (02-architecture.md § Layering ▸ Components). What is here is the within-board single-lane case +/// and nothing else, deliberately small enough to be obviously correct. +enum LaneReorderMath { + + /// Where the dragged lane would land: an index into the ordered live lanes **with the dragged + /// lane removed**, so the result is in `0...(unitCounts.count - 1)` and `draggedIndex` itself + /// means "back where it started". + /// + /// - Parameters: + /// - unitCounts: the ordered live lanes' display units (`LaneLayoutMath.displayUnits`), the + /// board as it currently is — recomputed against each snapshot rather than frozen at drag + /// start, so a foreign lane add or tombstone mid-drag just moves the zones and the next + /// proposal targets the board as it now is (04 ▸ Drag and drop, rule 1). + /// - draggedIndex: the dragged lane's position in `unitCounts`. + /// - dragCentreX: the dragged lane's centre under the cursor, in strip coordinates (0 at the + /// strip's leading edge, outer margin included). + /// - standard: the 1× lane width (`LaneLayoutMath.standardWidth`). + /// - gap: the inter-lane gap, which is also the strip's outer margin. + /// + /// Out-of-range `draggedIndex` yields `0` rather than trapping: the lane vanished under the + /// drag, and the caller's release-with-no-valid-proposal rule cancels anyway. + static func proposedIndex( + unitCounts: [Int], + draggedIndex: Int, + dragCentreX: CGFloat, + standard: CGFloat, + gap: CGFloat + ) -> Int { + guard unitCounts.indices.contains(draggedIndex) else { return 0 } + + var remaining = unitCounts + remaining.remove(at: draggedIndex) + + // The remaining lanes' resting centres, left to right, in the layout they would have with + // the dragged lane gone — which is the layout the siblings are already showing. + // Monotonically increasing, so "how many centres has the cursor passed" is both the answer + // and the reason it never oscillates: one threshold per slot, crossed once. + var index = 0 + var x = gap + for units in remaining { + let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap) + guard dragCentreX > x + width / 2 else { break } + index += 1 + x += width + gap + } + return index + } + + /// The resting centre of the lane at `index` in a strip of `unitCounts`, in the same strip + /// coordinates `proposedIndex` reads. + /// + /// Two callers, and they are the two halves of the drag: the gesture freezes this at drag start + /// as the origin its translation is measured from (the *physical pointer* being the only live + /// input — 03 § Motion), and the view offsets the travelling lane from the centre it would rest + /// at under the current proposal, which is what makes the replica track the cursor while the + /// siblings sit in their would-be order. + static func centre(ofLaneAt index: Int, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat { + var x = gap + for (position, units) in unitCounts.enumerated() { + let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap) + if position == index { return x + width / 2 } + x += width + gap + } + return x + } + + /// `unitCounts` (or any per-lane values) with the item at `from` moved to `to`, where `to` is + /// counted **with the item already removed** — the ordering `proposedIndex` returns, applied. + /// + /// Shared by the view (which reorders the lanes it lays out, so the siblings show the would-be + /// order) and by the drag's own centre arithmetic, so the two can never disagree about what the + /// proposal means. + static func reordered(_ items: [T], from: Int, to: Int) -> [T] { + guard items.indices.contains(from) else { return items } + var result = items + let item = result.remove(at: from) + result.insert(item, at: min(max(0, to), result.count)) + return result + } +} diff --git a/Kanban/UI/Board/LaneReorderSession.swift b/Kanban/UI/Board/LaneReorderSession.swift new file mode 100644 index 0000000..4d9d060 --- /dev/null +++ b/Kanban/UI/Board/LaneReorderSession.swift @@ -0,0 +1,69 @@ +import CoreGraphics +import Observation + +/// Window-local state for an in-flight lane reorder — the drag surface being the whole title bar +/// (03-board-ui.md § Lane, "no separate grip"). At most one runs per board window; `BoardView` owns +/// it as `@State` and hands it to the lanes. +/// +/// `LaneResizeSession`'s sibling, and deliberately much smaller. It holds only what the *pointer* +/// contributes — which lane, how far it has travelled, and the centre it started from — because +/// everything else the proposal needs is read fresh from the snapshot at render time +/// (`LaneReorderMath.proposedIndex`). That split is 04-interactions.md ▸ Drag and drop's +/// re-grounding rule made structural: "the frozen-at-drag-start inputs are the *dragged items'* +/// sizes and the physical pointer only … the analytic resting zones recompute against each new +/// snapshot", so a foreign lane add mid-drag cannot leave this session holding a stale board. +/// +/// ### The click-versus-drag split +/// +/// A plain click on the title bar selects the lane; only movement past `threshold` begins a +/// reorder (04 ▸ Selection: "the drag surface engages only on movement — the click-vs-drag split +/// cards already have"). One gesture recognises both, so a hesitant click can never start a drag +/// and a drag can never also select. +@MainActor +@Observable +final class LaneReorderSession { + + /// The lane being dragged; `nil` when idle. Observed — flipping it drives the travelling lane's + /// z-order and offset, and the siblings' reflow into the proposed order. + private(set) var laneID: ItemID? + + /// How far the pointer has travelled horizontally since the drag began. The *only* live input: + /// vertical movement is ignored outright, since lanes reorder along one axis. + private(set) var translation: CGFloat = 0 + + /// The dragged lane's resting centre at drag start, in strip coordinates — the origin + /// `translation` is measured from, frozen exactly as 03-board-ui.md § Motion requires. + @ObservationIgnored private(set) var startCentre: CGFloat = 0 + + /// How far the pointer must move before a click becomes a drag. Small enough that a deliberate + /// drag feels immediate, large enough that the tremor in a click never reorders the board. + static let threshold: CGFloat = 4 + + var isActive: Bool { laneID != nil } + + func isDragging(_ id: ItemID) -> Bool { laneID == id } + + /// The dragged lane's centre under the cursor: the frozen start plus the physical translation, + /// and nothing measured. + var centre: CGFloat { startCentre + translation } + + /// Begins a reorder of `laneID`, freezing the centre its travel is measured from. + func begin(laneID: ItemID, startCentre: CGFloat) { + self.laneID = laneID + self.startCentre = startCentre + self.translation = 0 + } + + func update(translation: CGFloat) { + guard isActive else { return } + self.translation = translation + } + + /// Ends the drag, handing the caller nothing: the *commit* needs the current snapshot's lane + /// order, which `BoardView` has and this session deliberately does not. Idempotent, because a + /// gesture can end after the lane it was carrying has already vanished. + func end() { + laneID = nil + translation = 0 + } +} diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 713cf3a..9f8f55d 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -1,15 +1,47 @@ import SwiftUI -/// One lane: a header and a vertically scrolling masonry of cards (03-board-ui.md § Lane). +// MARK: - The strip's half of the header drag + +/// What the strip lends a lane so its **whole title bar** can be the drag surface (03-board-ui.md § +/// Lane, "no separate grip"). /// -/// **The chrome here is deliberately minimal**, and the design's real lane is a later card: the -/// leading SF Symbol, the new-card button, the drag surface, the context menu (Rename, Style…, the -/// quick-style recents row, the Width stepper, Delete), the colour accent band, inline rename and -/// the search-aware count all arrive with the lane-chrome milestone. What this view owes the -/// full-visibility layout card is the shape — a header, a body that scrolls vertically only, and a -/// masonry whose interior column count is the lane's width — and that is all it does. +/// The gesture lives on the header — that is where the design puts it — but the two things it needs +/// are the strip's: where this lane currently rests (the origin the translation is measured from) +/// and what a release means (a proposal computed against the live lane order, then a write). Both +/// arrive as closures rather than as values because both must be read at *gesture* time, not at +/// body-evaluation time. +@MainActor +struct LaneHeaderDrag { + /// This lane's resting centre in strip coordinates, read the instant the drag begins and frozen + /// for its duration (`LaneReorderSession.startCentre`). + let startCentre: () -> CGFloat + /// Commit the reorder at whatever the current proposal is, and end the session. + let commit: () -> Void +} + +// MARK: - LaneView + +/// One lane: a title bar and a vertically scrolling masonry of cards (03-board-ui.md § Lane). +/// +/// ### The title bar (this milestone's subject) +/// +/// Leading SF Symbol from `icon` — lenient, an unknown name renders the `square.stack` default +/// (`ItemSymbol`) — then the title or its quiet "Untitled" placeholder, a quiet secondary +/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**: +/// a plain click selects the lane, movement past a small threshold begins a reorder +/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in +/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag. +/// +/// ### What is still a later card's +/// +/// The lane context menu (Rename, Style…, the quick-style recents row, the Width stepper, Delete), +/// the colour accent band, and the search-aware filtering behind the count all belong to later +/// milestones. The **card face** is likewise still a stub — `CardStubView` gains the leading icon, +/// the attachment chip, the cut treatment and the attachment carousel with the card-face card; what +/// it grows here is only what inline rename and click selection require. struct LaneView: View { + let store: BoardStore let lane: Lane /// Interior masonry columns — the lane's width units, or the resize session's snapped count @@ -17,77 +49,501 @@ struct LaneView: View { /// override it (see `BoardView.laneSlot`). let columns: Int + /// The strip's reorder session, so the header knows whether *it* is the lane in flight. + let reorder: LaneReorderSession + + let headerDrag: LaneHeaderDrag + + /// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens + /// the card window"). Supplied by the strip, which is supplied by the host: a lane has no + /// business knowing about `WindowGroup` keys. + let openCard: (ItemID) -> Void + /// Spacing between cards, and between the interior columns. private let cardSpacing: CGFloat = 8 var body: some View { VStack(alignment: .leading, spacing: 8) { header - ScrollView(.vertical) { - // Cards stay standard width whatever the lane spans: at a slot width of - // `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly - // `units` columns of `standard` (03-board-ui.md § Layout — full visibility). - MasonryLayout(columns: columns, spacing: cardSpacing) { - ForEach(liveCards) { card in - CardStubView(card: card) - } - } - .frame(maxWidth: .infinity, alignment: .topLeading) - } + cardStack } + .padding(6) + .background(selectionBackground) + .overlay(selectionStroke) } - /// The header — title, or a quiet "Untitled" where there is none, plus the live card count. - /// - /// Titles are optional at every level (03-board-ui.md § Card face): a missing `title` renders as - /// a secondary-styled placeholder rather than as an empty row. **Replaced wholesale by the - /// lane-chrome card**, which brings the icon, the count badge's real styling, the new-card - /// button, the drag surface and the context menu. + // MARK: - Header + private var header: some View { + headerContent + // The bar is the drag surface, so it must be hit-testable across its whole width — + // including the empty stretch between the badge and the button. + .contentShape(Rectangle()) + .gesture(headerGesture) + .overlay(alignment: .trailing) { newCardButton } + } + + private var headerContent: some View { HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane)) + .foregroundStyle(.secondary) + .imageScale(.medium) + headerTitle + countBadge + Spacer(minLength: 0) + } + // Reserves the button's width so a long title truncates before it collides, and keeps the + // button out of the gestured region. + .padding(.trailing, 22) + .padding(.horizontal, 4) + } + + /// The title, or the rename editor when this lane is the one being renamed. + /// + /// A lane's **only** rename path is Board ▸ Rename (04-interactions.md ▸ Selection: "the menu + /// item is a lane's only rename path, since Return on a lane creates a card"), so nothing in + /// this view opens the editor — it only renders one that is already open. + @ViewBuilder + private var headerTitle: some View { + if isRenaming { + InlineTitleField( + text: renameDraft, + prompt: "Lane name", + onCommit: { store.commitRename() }, + onAbandon: { store.transient.discardRename() }, + onFocusLoss: { store.commitRename() }, + // A lane has no card window; ⌘↩ still commits, which is the half of the rule that + // applies (04 ▸ Grammar's carve-out is "commits the edit — placeholder or rename — + // and open[s] the card window", and only a card has one to open). + onCommitAndOpen: { store.commitRename() } + ) + .font(.headline) + } else { Text(lane.title.value ?? "Untitled") .font(.headline) .foregroundStyle(lane.title.value == nil ? .secondary : .primary) .lineLimit(1) .truncationMode(.tail) - Text("\(liveCards.count)") - .font(.callout) - .foregroundStyle(.secondary) - .monospacedDigit() - Spacer(minLength: 0) } - .padding(.horizontal, 4) + } + + /// The card-count badge — quiet, secondary (03-board-ui.md § Lane). + /// + /// **It counts exactly what the body renders**, because it reads the same `renderedCards` the + /// masonry iterates. That is deliberate rather than incidental: "The count reads the search + /// filter like every other surface — during a search it shows the visible count, not the + /// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge + /// follows by construction, with no second rule to keep in step. + private var countBadge: some View { + Text("\(renderedCards.count)") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Capsule().fill(.quaternary)) + } + + /// The new-card button — a **pointer twin** of File ▸ New Card whose click *names its target*: + /// "the lane header's new-card button overrides [the ⌘N target] rule — the click names its + /// target lane, selection notwithstanding" (11-command-nexus.md ▸ Pointer grammar, settled), so + /// it passes this lane and no anchor rather than consulting `NewCardTarget`. + private var newCardButton: some View { + Button { + store.transient.beginPlaceholder(inLane: lane.id) + } label: { + Image(systemName: "plus") + .imageScale(.small) + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("New card in \(lane.title.value ?? "Untitled")") + // Mutating, so the read-only lock disables it like every other write path + // (02-architecture.md § The lock's scope), and the focused-editor rule closes it while an + // inline editor is open (04 ▸ Grammar) — the pointer twin of a disabled menu item. + .disabled(store.isReadOnly || store.isEditingInline) + } + + /// One gesture recognising both halves of 04-interactions.md ▸ Selection's click-vs-drag split: + /// "a plain click on the title bar selects the lane; the drag surface engages only on movement". + /// + /// `minimumDistance: 0` so the release is seen even when nothing moved — that release *is* the + /// click. `.global` coordinates because the strip's own space shifts as siblings reflow under + /// the proposal, and a translation measured against a moving frame is not a pointer delta. + private var headerGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + // Selection stays live under the lock; reordering does not (02 § The lock's scope). + // The focused-editor rule holds a drag off too: a reorder is a board command. + guard !store.isReadOnly, !store.isEditingInline else { return } + if !reorder.isDragging(lane.id) { + guard abs(value.translation.width) > LaneReorderSession.threshold else { return } + reorder.begin(laneID: lane.id, startCentre: headerDrag.startCentre()) + } + reorder.update(translation: value.translation.width) + } + .onEnded { _ in + if reorder.isDragging(lane.id) { + headerDrag.commit() + } else { + // A plain click on the header always selects — unlike lane empty space, it does + // not toggle off. 04 gives the click-again-to-unselect behaviour to empty space + // only, and a full lane has no empty space to reach for. + store.select([lane.id], liveness: .live) + } + } + } + + // MARK: - Body + + /// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one + /// click selects the lane or, when it is already the selection, clears it; a double click + /// creates a card at the bottom with its title editor focused. + private var cardStack: some View { + ScrollView(.vertical) { + // Cards stay standard width whatever the lane spans: at a slot width of + // `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly + // `units` columns of `standard` (03-board-ui.md § Layout — full visibility). + MasonryLayout(columns: columns, spacing: cardSpacing) { + ForEach(slots) { slot in + switch slot { + case let .card(card): + CardStubView(store: store, card: card, openCard: openCard) + case .placeholder: + NewCardStubView(store: store, openCard: openCard) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + // Order matters: the two-tap recogniser must be attached first so a double click is not + // consumed as two singles. + .onTapGesture(count: 2) { + guard !store.isReadOnly, !store.isEditingInline else { return } + store.transient.beginPlaceholder(inLane: lane.id) + } + .onTapGesture { toggleLaneSelection() } + } + } + + /// What the masonry lays out: the rendered cards, plus the new-card placeholder when this lane + /// is the one being created into. + /// + /// The overlay is inserted **at the position the card will actually take** — after its anchor + /// for ⌘N's "immediately after it", at the bottom otherwise — by asking the very function the + /// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card + /// cannot appear anywhere but where the real card lands. + private var slots: [LaneSlot] { + var result = renderedCards.map(LaneSlot.card) + guard let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id else { + return result + } + let position = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards) + result.insert(.placeholder, at: position ?? result.count) + 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. - private var liveCards: [Card] { + /// + /// This is also the collection m5's search filter narrows, which is what keeps the count badge + /// honest for free — see `countBadge`. + private var renderedCards: [Card] { lane.cards.filter { !$0.isDeleted } } + + // MARK: - Selection + + private var isSelected: Bool { + store.selection.liveness == .live && store.selection.ids.contains(lane.id) + } + + /// Click on empty space: select, or clear when this lane is already *the* selection. + /// + /// "Single click selects the lane (click again to unselect)". The toggle-off tests for a + /// sole-membership selection rather than mere containment, so a future ⌘-click multi-selection + /// of lanes is narrowed by a click rather than wiped by it — the modifier grammar itself + /// (⌘-click toggles, ⇧-click range-extends, rubber band, homogeneity enforcement) is **m5's + /// selection-model card**, and nothing here should pre-empt it. + private func toggleLaneSelection() { + if store.selection.liveness == .live, store.selection.ids == [lane.id] { + store.clearSelection() + } else { + store.select([lane.id], liveness: .live) + } + } + + /// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet — + /// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as + /// a fill that would compete with it once that lands. + private var selectionBackground: some View { + RoundedRectangle(cornerRadius: 10) + .fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear)) + } + + private var selectionStroke: some View { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5) + } + + // MARK: - Rename plumbing + + private var isRenaming: Bool { + store.transient.renameEditor?.targetID == lane.id + } + + /// The draft, as a binding onto transient state rather than as `@State`: the editor's text lives + /// in `TransientBoardState` because a reload has rules about it (the vanish discard), and a + /// second copy in the view would be the one the commit did not read. + private var renameDraft: Binding { + Binding( + get: { store.transient.renameEditor?.draftTitle ?? "" }, + set: { store.transient.updateRenameDraft($0) } + ) + } +} + +// MARK: - Lane slots + +/// What a lane's masonry lays out — its cards, plus at most one pseudo-card. +/// +/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its +/// title commits (02-architecture.md § Layering, the one named exception to the one-way flow). +/// Modelling it as a sibling case rather than as a fake `Card` is what keeps that true — nothing can +/// accidentally hand it to code expecting an item that exists. +private enum LaneSlot: Identifiable { + case card(Card) + case placeholder + + var id: String { + switch self { + case let .card(card): "card:\(card.id.rawValue)" + // Constant, because there is only ever one placeholder in one lane at a time and it must + // keep its identity — and therefore its keyboard focus — while the user types. + case .placeholder: "placeholder" + } + } } // MARK: - Card stub -/// A card, as a rounded plate with its title — **a stand-in, replaced by the card-face card**, which -/// brings the leading icon, the attachment chip, the selection and cut treatments, inline rename and -/// the sole-selected card's attachment carousel (03-board-ui.md § Card face). +/// A card, as a rounded plate with its title — **still a stand-in**, replaced by the card-face card, +/// which brings the leading icon, the attachment chip, the cut treatment and the sole-selected +/// card's attachment carousel (03-board-ui.md § Card face). /// -/// It takes whatever width `MasonryLayout` proposes (one interior column = one standard width) and -/// sizes its own height to its content, which is what makes the masonry masonry: a taller card only -/// pushes the cards below it in its own column. +/// What it has grown here is only what this milestone owes: click-to-select with a selection +/// treatment, and the inline rename editor swapping in for the title when this card is the rename +/// target. private struct CardStubView: View { + let store: BoardStore let card: Card + let openCard: (ItemID) -> Void var body: some View { - Text(card.title.value ?? "Untitled") - .font(.body) - .foregroundStyle(card.title.value == nil ? .secondary : .primary) - .lineLimit(4) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary)) + Group { + 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) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5) + ) + .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. + .onTapGesture { store.select([card.id], liveness: .live) } + } + + private var isSelected: Bool { + store.selection.liveness == .live && store.selection.ids.contains(card.id) + } + + private var isRenaming: Bool { + store.transient.renameEditor?.targetID == card.id + } + + private var draft: Binding { + 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 — +/// 02-architecture.md § Layering's one named exception to the one-way flow, finally rendered. +/// +/// Two faces, one per phase: +/// +/// - **`.editing`** — a focused text field. Return commits, Escape abandons, and **click-away +/// discards**: the placeholder's rule, "the deliberate exception because nothing exists on disk +/// yet" (04-interactions.md ▸ Grammar). +/// - **`.awaitingArrival`** — the committed title as plain text, deliberately *not* an editor. The +/// Writer's create has run and the overlay is only covering the gap until the watcher round-trips +/// the real card; leaving a live field there would invite edits that have nowhere to go, and its +/// focus loss would fire the discard rule against a card that is already on its way. +private struct NewCardStubView: View { + + let store: BoardStore + let openCard: (ItemID) -> Void + + var body: some View { + Group { + if isEditing { + InlineTitleField( + text: draft, + prompt: "Card title", + onCommit: { commit() }, + onAbandon: { store.transient.discardPlaceholder() }, + onFocusLoss: { store.transient.discardPlaceholder() }, + onCommitAndOpen: { + // The one board command that stays enabled mid-edit: commit, then open + // (04 ▸ Grammar's carve-out). A commit that discarded — empty title, a + // vanished lane, a failed create — hands back no id and opens nothing. + if let id = commit() { openCard(id) } + } + ) + .font(.body) + } else { + Text(store.transient.newCardPlaceholder?.draftTitle ?? "") + .font(.body) + .foregroundStyle(.secondary) + .lineLimit(4) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5) + ) + } + + private var isEditing: Bool { + store.transient.newCardPlaceholder?.phase == .editing + } + + /// Commits, then **re-selects the lane** — "Return commits and re-selects the lane (next Return + /// = next card)" (04-interactions.md ▸ Grammar). The lane rather than the new card is what makes + /// a run of Return-type-Return file a stack of cards without the user's hands leaving the + /// keyboard. + /// + /// The lane is read before the commit, because every discard path clears the overlay that holds + /// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting + /// something that renders nowhere would break the homogeneous-by-liveness invariant until the + /// next reload swept it away. + @discardableResult + private func commit() -> ItemID? { + let lane = store.transient.newCardPlaceholder?.laneID + let id = store.commitPlaceholder() + if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) { + store.select([lane], liveness: .live) + } + return id + } + + private var draft: Binding { + Binding( + get: { store.transient.newCardPlaceholder?.draftTitle ?? "" }, + set: { store.transient.updateDraft($0) } + ) + } +} + +// MARK: - The inline title field + +/// The one text field all three inline editors wear — the new-card placeholder, a card rename, and +/// a lane rename — so the grammar around it is written once (04-interactions.md ▸ Grammar). +/// +/// The four exits, and who differs on them: +/// +/// | Exit | Placeholder | Rename | +/// |---|---|---| +/// | Return | commits | commits | +/// | Escape | discards | abandons | +/// | Click-away | **discards** | **commits** | +/// | ⌘↩ | commits + opens | commits + opens | +/// +/// Only the click-away row differs, which is why it is a caller-supplied closure rather than a +/// branch in here: this view knows *that* focus left, never what that should mean. +/// +/// **Every handler must be idempotent**, because the exits overlap by construction: Return commits +/// 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 { + + @Binding var text: String + let prompt: String + let onCommit: () -> Void + let onAbandon: () -> Void + let onFocusLoss: () -> Void + let onCommitAndOpen: () -> Void + + @FocusState private var isFocused: Bool + + var body: some View { + TextField(prompt, text: $text) + .textFieldStyle(.plain) + .lineLimit(1) + .focused($isFocused) + // The editor is born focused: every entry point to it is a deliberate "edit this now" + // (Return, ⌘N, the header button, a double click, Board ▸ Rename), and one that landed + // unfocused would need a second click to do anything. + .onAppear { isFocused = true } + .onSubmit(onCommit) + // ⌘↩ before the field sees the Return: the one board command enabled mid-edit + // (04 ▸ Grammar). Anything without the modifier is passed straight through, so plain + // Return still reaches `onSubmit`. + .onKeyPress(keys: [.return], phases: .down) { press in + guard press.modifiers.contains(.command) else { return .ignored } + onCommitAndOpen() + return .handled + } + // Escape reaches a focused text field as AppKit's cancel operation on some paths and as + // a plain key press on others; both are wired to the same idempotent abandon rather than + // guessing which one this control will get. + .onKeyPress(.escape) { + onAbandon() + return .handled + } + .onExitCommand(perform: onAbandon) + .onChange(of: isFocused) { _, focused in + guard !focused else { return } + onFocusLoss() + } } } diff --git a/Kanban/UI/Board/NewCardTarget.swift b/Kanban/UI/Board/NewCardTarget.swift new file mode 100644 index 0000000..13060e7 --- /dev/null +++ b/Kanban/UI/Board/NewCardTarget.swift @@ -0,0 +1,82 @@ +/// 04-interactions.md's **⌘N target rule** (settled), as a pure function of the three things it +/// reads — the selection, the last-active lane, and the snapshot (`NewCardTargetTests`). +/// +/// The rule verbatim, and each clause's branch below: +/// +/// > with a card selected, the new card is created in that card's lane, immediately after it +/// > (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); +/// > … with nothing selected — or a **tombstoned** 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. +/// +/// **A pure function rather than a method on the store** for the reason every rule in this codebase +/// that can be one is: the five branches are five lines of test rather than five UI states to drive, +/// and the menu item's `disabled` and its action then read the *same* answer instead of two +/// hand-kept-in-sync conditions. +/// +/// ### What it deliberately does not decide +/// +/// - **The lane header's new-card button overrides this rule entirely** (11-command-nexus.md ▸ +/// Pointer grammar, settled): "the click names its target lane, selection notwithstanding". That +/// call site passes its own lane and never comes here. +/// - **Return on a selected lane** is the same target as this rule's lane branch, but it is reached +/// by grammar rather than by the menu; it also passes its lane directly. +/// - **Multi-selections.** The rule speaks of "a card"/"a lane", singular, and a multi-selection has +/// no "it" to be immediately after. Anything but a sole selection falls through to the +/// last-active lane, which is the same answer an empty selection gets — the honest reading, and +/// the one m5's selection-model card can refine if the design ever grows a plural case. +enum NewCardTarget { + + /// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the + /// lane's bottom). Exactly `NewCardPlaceholder`'s two anchoring fields, because that is what + /// this resolves *into*. + struct Resolution: Equatable { + let laneID: ItemID + let anchorCardID: ItemID? + } + + /// The target, or `nil` when there is none — **the zero-lane board**, where "New Card, + /// Return-creation, and Paste with a card payload disable via menu validation until a lane + /// exists". `nil` is therefore the menu item's `disabled` condition as well as its refusal, so + /// the two can never disagree. + /// + /// - Parameters: + /// - selection: the board's current selection, liveness side included. A `.trashed` selection + /// "never anchors creation" and is treated exactly as an empty one — the settled precedent + /// 04 ▸ Clipboard cites for paste, applied here to its source rule ("a trashed card's live + /// disk-lane never leaks in as 'the selected card's lane'"). + /// - lastActiveLaneID: `TransientBoardState.lastActiveLaneID`, already cleared by the reload + /// rule if its lane vanished — but re-checked here anyway, because a caller need not have + /// reloaded since the lane went. + static func resolve( + selection: ItemReferenceSet, + lastActiveLaneID: ItemID?, + snapshot: BoardModel + ) -> Resolution? { + let lanes = snapshot.lanes.filter { !$0.isDeleted } + guard !lanes.isEmpty else { return nil } + + if selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first { + for lane in lanes { + // A lane selected: appended at its bottom, Return consistency. + if lane.id == id { return Resolution(laneID: lane.id, anchorCardID: nil) } + // A card selected: its lane, immediately after it — paste-anchor consistency. + if lane.cards.contains(where: { $0.id == id && !$0.isDeleted }) { + return Resolution(laneID: lane.id, anchorCardID: id) + } + } + // The id names nothing the board renders — a selection the next reload will drop. + // Falls through to the last-active lane rather than refusing: the user pressed ⌘N and + // the board has lanes. + } + + // Nothing selected, a tombstoned selection, a multi-selection, or a stale one: the lane that + // most recently held selection or a creation, and the first lane when there is no such lane + // (or it has since gone). + if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) { + return Resolution(laneID: lane.id, anchorCardID: nil) + } + return lanes.first.map { Resolution(laneID: $0.id, anchorCardID: nil) } + } +} diff --git a/Kanban/UI/ItemSymbol.swift b/Kanban/UI/ItemSymbol.swift new file mode 100644 index 0000000..9b73a5a --- /dev/null +++ b/Kanban/UI/ItemSymbol.swift @@ -0,0 +1,44 @@ +import AppKit + +/// The `icon` field's rendering rule: **a name that resolves is drawn, anything else falls back to +/// the level's default** (03-board-ui.md § Styling ▸ Capabilities, "`icon`: SF Symbol per item with +/// per-level defaults"). +/// +/// ### Lenient, never an error +/// +/// `icon` is a lenient field (01-storage-format.md § Frontmatter): a typo in a hand-written name is +/// not a load failure, not a warning, and not an empty box — it renders as the default and the +/// author's bytes are left exactly as written until they change them. That mirrors the read side's +/// treatment of `width`, and it is what makes "any other SF Symbol name works written by hand" +/// (03 § Controls) safe to promise: the app's curated grid is a convenience, the file is the escape +/// hatch, and a name the running OS does not happen to have simply degrades. +/// +/// `NSImage(systemSymbolName:)` is the only honest test — the symbol set is the *OS's*, and it grows +/// between releases, so a hard-coded allow-list would be wrong the day after it was written. +enum ItemSymbol { + + /// The per-level defaults, verbatim from 03-board-ui.md § Styling ▸ Capabilities. + static let board = "rectangle.split.3x1" + static let lane = "square.stack" + static let card = "doc.text" + + /// `field`'s symbol name if it names a symbol this system can draw, `fallback` otherwise. + /// + /// Handles all three `FieldValue` shapes the same way, which is the point: a missing key, a + /// malformed one (a sequence where a scalar belongs), and a valid-but-unknown name are one + /// case to the renderer — *there is no symbol to draw, so draw the default*. + static func name(_ field: FieldValue, fallback: String) -> String { + guard let name = field.value, exists(name) else { return fallback } + return name + } + + /// Whether the running system can draw `name` as an SF Symbol. + /// + /// Uncached deliberately. The lookup is a bundle-backed symbol resolution that AppKit itself + /// caches, it runs once per lane per render pass, and a cache here would be one more piece of + /// main-actor state to keep honest across the OS's own symbol availability. If a profile ever + /// says otherwise, this one function is where the memo goes. + static func exists(_ name: String) -> Bool { + !name.isEmpty && NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil + } +} diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift index 3806e6b..a6b7ef7 100644 --- a/KanbanTests/BannerCenterTests.swift +++ b/KanbanTests/BannerCenterTests.swift @@ -51,6 +51,7 @@ private let everyOperation: [WriteOperation] = [ .purge(title: "Fix login"), .style(title: "Fix login"), .resize(title: "Fix login"), + .rename(title: "Fix login"), .importAttachment(filename: "photo.png"), .listAttachments, .renumberChildren, @@ -66,6 +67,7 @@ private let titledOperations: [(with: WriteOperation, without: WriteOperation)] (.purge(title: "Fix login"), .purge(title: nil)), (.style(title: "Fix login"), .style(title: nil)), (.resize(title: "Fix login"), .resize(title: nil)), + (.rename(title: "Fix login"), .rename(title: nil)), ] // MARK: - Ordering @@ -359,6 +361,20 @@ struct BannerCenterPhrasingTests { } } + @Test("Rename says the design's own sentence, and never borrows styling's") + func renameHasItsOwnVerb() { + // 02-architecture.md § Write-failure surfacing names this line verbatim when it settles + // that "the vocabulary grows with the surfaces": inline rename gets its own case rather + // than folding into the generic frontmatter bucket, so a failed rename must not tell the + // user the app could not *restyle* anything. + #expect(BannerCenter.headline(for: error(.rename(title: "Fix login"), .io(message: "the disk is full"))) + == "Couldn't rename 'Fix login' — the disk is full") + #expect(BannerCenter.headline(for: error(.rename(title: nil), .io(message: "the disk is full"))) + == "Couldn't rename the item — the disk is full") + #expect(BannerCenter.headline(for: error(.rename(title: "Fix login"))) + != BannerCenter.headline(for: error(.style(title: "Fix login")))) + } + @Test("The cause tail comes from the error's reason and nowhere else") func causeTailCarriesTheDiagnosis() { #expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full"))) diff --git a/KanbanTests/InlineEditWriteTests.swift b/KanbanTests/InlineEditWriteTests.swift new file mode 100644 index 0000000..a41154d --- /dev/null +++ b/KanbanTests/InlineEditWriteTests.swift @@ -0,0 +1,658 @@ +import Foundation +import Testing +@testable import Kanban + +/// The two inline editors' **write** paths — `BoardStore.commitRename` and +/// `BoardStore.commitPlaceholder` — plus the lane drag's `moveLane`. +/// +/// Like `LaneWidthWriteTests`, these drive a real store over a real temp board and then read the +/// **raw bytes** back rather than the app's own read path: the interesting claims are about the +/// file — the key that lands or leaves, the stamps that follow, and everything else surviving +/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. + +// MARK: - Fixtures + +private func tombstoned(order: String, title: String) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + deleted: 2026-03-03T09:00:00Z + --- + \(title) body. + + """ +} + +/// An item with no `title` key at all — the untitled state a rename can both start from and return +/// an item to. +private func untitled(order: String) -> String { + """ + --- + schema: 1 + order: \(order) + project: lanework # agent overlay + created: 2026-01-01T09:00:00Z + modified: 2026-02-02T09:00:00Z + --- + Some body. + + """ +} + +/// Two lanes: `lane1` with two cards, `lane2` with one, plus a readable-but-uneditable lane. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third")) + try fixture.item(Ident.lane3, Item.uneditable) + return fixture +} + +private let lane1 = ItemID(rawValue: Ident.lane1) +private let lane2 = ItemID(rawValue: Ident.lane2) +private let lane3 = ItemID(rawValue: Ident.lane3) +private let card1 = ItemID(rawValue: Ident.card1) +private let card2 = ItemID(rawValue: Ident.card2) +private let card3 = ItemID(rawValue: Ident.card3) + +/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come +/// through a rename byte-for-byte, in order. +private func untouchedLines(_ text: String) -> [Substring] { + text.split(separator: "\n", omittingEmptySubsequences: false).filter { + !$0.hasPrefix("modified") && !$0.hasPrefix("title:") + } +} + +private func load(_ fixture: WriterFixture) throws -> BoardModel { + try BoardLoader.load(boardRoot: fixture.root).model +} + +private func card(_ id: ItemID, in model: BoardModel) -> Card? { + model.lanes.flatMap(\.cards).first { $0.id == id } +} + +/// Every card folder under a lane, so a create can be spotted by what is newly there. +private func cardFolders(_ lane: String, in fixture: WriterFixture) throws -> Set { + Set(try fixture.entryNames(lane).filter { BoardLoader.isUUIDShaped($0) }) +} + +// MARK: - Rename + +@MainActor +@Suite("BoardStore ▸ inline rename") +struct InlineRenameWriteTests { + + @Test("A non-empty commit writes the title, stamps modified, and touches nothing else") + func writesTheTitleAndStamps() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("Fix login") + store.commitRename() + + let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") + #expect(after.contains("title: Fix login")) + #expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution") + #expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh") + + // Everything the write does not own survives exactly, in order: the unknown key with its + // inline comment, the reserved `labels`, the original `created`, the `order`, and the body. + #expect(untouchedLines(after) == untouchedLines(before)) + + let renamed = try #require(card(card1, in: load(fixture))) + #expect(renamed.title == .valid("Fix login")) + #expect(renamed.modifiedBy.isMissing) + let modified = try #require(renamed.modified.value) + #expect(abs(modified.timeIntervalSinceNow) < 60) + + #expect(store.transient.renameEditor == nil, "the editor closes on commit") + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A lane renames through the same path") + func renamesALane() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginRename(of: lane2, currentTitle: "Doing") + store.transient.updateRenameDraft("In progress") + store.commitRename() + + #expect(try fixture.indexText(Ident.lane2).contains("title: In progress")) + #expect(try load(fixture).lanes.first { $0.id == lane2 }?.title == .valid("In progress")) + } + + @Test("An empty commit removes the title key, byte-faithfully") + func emptyCommitRemovesTheKey() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("") + store.commitRename() + + let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") + // "Committing an empty rename on an existing item removes its `title` key" — the *key*, not + // an empty string: titles are optional at every level, and `title: ""` would be a real (if + // blank) title where the face should show the untitled placeholder. + #expect(!after.contains("title:")) + #expect(!after.contains("title: \"\"")) + #expect(untouchedLines(after) == untouchedLines(before), "only the title line and the stamps moved") + + let stripped = try #require(card(card1, in: load(fixture))) + #expect(stripped.title.isMissing) + #expect(stripped.order == 1024, "the card keeps its place") + } + + @Test("Whitespace commits as empty — a title of three spaces is a slip, not a name") + func whitespaceIsEmpty() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft(" ") + store.commitRename() + + #expect(try #require(card(card1, in: load(fixture))).title.isMissing) + + // And a title with edges is trimmed rather than quoted into the file with its padding. + store.transient.beginRename(of: card2, currentTitle: "Second") + store.transient.updateRenameDraft(" Fix login ") + store.commitRename() + #expect(try #require(card(card2, in: load(fixture))).title == .valid("Fix login")) + } + + @Test("An unchanged title writes nothing at all") + func unchangedTitleIsANoOp() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane2)/\(Ident.card3)", untitled(order: "1024")) + let store = try BoardStore(rootURL: fixture.root) + let titled = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") + let untouched = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") + + // An editor opened and dismissed with Return must not stamp `modified` or (on a git board) + // mint a commit — the lane-resize rule, for the same reason. + store.transient.beginRename(of: card1, currentTitle: "First") + store.commitRename() + #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == titled) + + // Same for an untitled item committed still untitled: the key must not materialize and then + // vanish, nor the file be rewritten to say nothing new. + store.transient.beginRename(of: card3, currentTitle: nil) + store.commitRename() + #expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == untouched) + #expect(try fixture.entryNames("\(Ident.lane2)/\(Ident.card3)") == ["index.md"], "no temp-file residue either") + } + + @Test("A commit at a target the snapshot does not have writes nothing, silently") + func vanishedTargetWritesNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.entryNames("") + + // "A target that is tombstoned, deleted, or gone at commit time discards the editor and its + // keystrokes silently" — silently being the operative word: nothing written, no banner. + store.transient.beginRename(of: ItemID(rawValue: Ident.indexless), currentTitle: "Ghost") + store.transient.updateRenameDraft("Never lands") + store.commitRename() + + #expect(try fixture.entryNames("") == before) + #expect(!fixture.exists(Ident.indexless)) + #expect(store.transient.renameEditor == nil) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A commit at a card under a tombstoned lane writes nothing — liveness is effective") + func targetUnderATombstonedLaneWritesNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + // The lane carries the tombstone; the card's own flag is untouched, and it renders nowhere + // regardless (03-board-ui.md collapses the lane to one trash entry). + try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("Never lands") + store.commitRename() + + #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A rename follows a foreign move — the write lands wherever the card now lives") + func renameFollowsTheUUID() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("Fix login") + + try FileManager.default.moveItem( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + to: fixture.url("\(Ident.lane2)/\(Ident.card1)") + ) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + store.commitRename() + + #expect(try fixture.indexText("\(Ident.lane2)/\(Ident.card1)").contains("title: Fix login")) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A readable-but-uneditable target refuses the write, banners it, and keeps its bytes") + func uneditableTargetBanners() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData(Ident.lane3) + + store.transient.beginRename(of: lane3, currentTitle: "Odd") + store.transient.updateRenameDraft("Renamed") + store.commitRename() + + #expect(try fixture.indexData(Ident.lane3) == before) + #expect(store.banners.oneShots.count == 1) + let posted = try #require(store.banners.oneShots.first) + // The title is enriched off the document the write refused, so the banner names the item by + // what it is still called rather than by the name that never landed. + #expect(posted.error.operation == .rename(title: "Odd")) + #expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't rename 'Odd' — ")) + } + + @Test("A read-only board refuses the rename without a second banner") + func readOnlyBoardRefusesQuietly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("Fix login") + store.commitRename() + + #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) + #expect(store.banners.oneShots.isEmpty, "the lock row is already standing") + #expect(store.bannerRows.contains { $0.id == "read-only-lock" }) + } +} + +// MARK: - The new-card placeholder's commit + +@MainActor +@Suite("BoardStore ▸ new-card placeholder commit") +struct NewCardCommitWriteTests { + + @Test("A lane-bottom commit creates the card appended after the visible cards") + func createsAtTheLaneBottom() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try cardFolders(Ident.lane1, in: fixture) + + store.transient.beginPlaceholder(inLane: lane1) + store.transient.updateDraft("Fix login") + let created = try #require(store.commitPlaceholder()) + + let after = try cardFolders(Ident.lane1, in: fixture) + #expect(after.subtracting(before) == [created.rawValue], "exactly one new card folder") + + let model = try load(fixture) + let made = try #require(card(created, in: model)) + #expect(made.title == .valid("Fix login")) + // Appended after the visible siblings at 1024 and 2048: `Ranks.append` is max + 1024. + #expect(made.order == 3072) + #expect(model.lanes.first { $0.id == lane1 }?.cards.map(\.id) == [card1, card2, created], + "and it lands last in display order") + + // The overlay stands in for the card until the watcher round-trips it — it does not vanish + // the instant the Writer returns (02-architecture.md § Layering). + #expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(created)) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("An after-anchor commit lands the card between its anchor and the next card") + func createsAfterItsAnchor() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // ⌘N with `card1` selected: "in that card's lane, immediately after it". + store.transient.beginPlaceholder(inLane: lane1, after: card1) + store.transient.updateDraft("Fix login") + let created = try #require(store.commitPlaceholder()) + + let model = try load(fixture) + let made = try #require(card(created, in: model)) + #expect(made.order == 1536, "the midpoint of 1024 and 2048") + #expect(model.lanes.first { $0.id == lane1 }?.cards.map(\.id) == [card1, created, card2]) + #expect(made.title == .valid("Fix login")) + + // The reposition rides the Writer's same-parent degenerate reorder, so exactly one file was + // rewritten past the create: the neighbours keep their ranks. + #expect(card(card1, in: model)?.order == 1024) + #expect(card(card2, in: model)?.order == 2048) + } + + @Test("An anchor that is already last is the lane's bottom — no reposition") + func anchoringTheLastCardIsAnAppend() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginPlaceholder(inLane: lane1, after: card2) + store.transient.updateDraft("Fix login") + let created = try #require(store.commitPlaceholder()) + + #expect(try #require(card(created, in: load(fixture))).order == 3072) + } + + @Test("An anchor that vanished degrades to the lane's bottom rather than refusing") + func aVanishedAnchorAppends() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginPlaceholder(inLane: lane1, after: card1) + store.transient.updateDraft("Fix login") + + // An agent deletes the anchor mid-typing. The lane — the anchor that actually matters — is + // still there, so the card the user is creating is theirs to keep. + try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + let created = try #require(store.commitPlaceholder()) + #expect(try #require(card(created, in: load(fixture))).order == 3072) + } + + @Test("An empty commit discards the draft and writes nothing") + func emptyCommitCreatesNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try cardFolders(Ident.lane1, in: fixture) + + // "Creating-then-abandoning never leaves an empty card behind" — untitled cards exist only + // when made deliberately (04-interactions.md ▸ Grammar). + store.transient.beginPlaceholder(inLane: lane1) + store.transient.updateDraft(" ") + #expect(store.commitPlaceholder() == nil) + + #expect(try cardFolders(Ident.lane1, in: fixture) == before) + #expect(store.transient.newCardPlaceholder == nil) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A commit into a lane the snapshot has lost discards the draft") + func vanishedLaneDiscards() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginPlaceholder(inLane: ItemID(rawValue: Ident.indexless)) + store.transient.updateDraft("Fix login") + #expect(store.commitPlaceholder() == nil) + + #expect(!fixture.exists(Ident.indexless)) + #expect(store.transient.newCardPlaceholder == nil) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A failed create discards the placeholder and banners the failure") + func aFailedCreateDiscards() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // Read-execute only: the lane loads and renders, but no folder can be minted inside it. + let laneFolder = fixture.url(Ident.lane2) + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: laneFolder.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: laneFolder.path) } + + store.transient.beginPlaceholder(inLane: lane2) + store.transient.updateDraft("Fix login") + #expect(store.commitPlaceholder() == nil) + + // Settled in 02-architecture.md § Layering: "if the Writer create throws after the title + // commits, the create flow discards the placeholder … the overlay never waits for a card + // that cannot arrive". The failure is the banner's, not the overlay's. + #expect(store.transient.newCardPlaceholder == nil) + #expect(store.banners.oneShots.count == 1) + #expect(store.banners.oneShots.first?.error.operation == .createCard) + } + + @Test("A read-only board refuses the create without a second banner") + func readOnlyBoardRefusesQuietly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + let before = try cardFolders(Ident.lane1, in: fixture) + + store.transient.beginPlaceholder(inLane: lane1) + store.transient.updateDraft("Fix login") + #expect(store.commitPlaceholder() == nil) + + #expect(try cardFolders(Ident.lane1, in: fixture) == before) + #expect(store.transient.newCardPlaceholder == nil) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A committed placeholder is not committed twice") + func commitIsIdempotent() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginPlaceholder(inLane: lane1) + store.transient.updateDraft("Fix login") + let created = try #require(store.commitPlaceholder()) + let after = try cardFolders(Ident.lane1, in: fixture) + + // Return commits and the field's focus-loss handler fires an instant later; the second call + // must not file a duplicate. + #expect(store.commitPlaceholder() == nil) + #expect(try cardFolders(Ident.lane1, in: fixture) == after) + #expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(created)) + } +} + +// MARK: - Lane reorder + +@MainActor +@Suite("BoardStore ▸ lane reorder") +struct LaneReorderWriteTests { + + @Test("A lane moved to the head takes a rank before every sibling") + func movesToTheHead() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // lane1 @1024, lane2 @2048, lane3 @1024 (`Item.uneditable`'s own order; the folder-name + // tie-break puts it after lane1). Moving the last lane to index 0 puts it at min − 1024. + store.moveLane(lane2, toIndex: 0) + + let model = try load(fixture) + #expect(model.lanes.map(\.id) == [lane2, lane1, lane3]) + #expect(model.lanes.first { $0.id == lane2 }?.order == 0) + } + + @Test("A lane moved between two siblings takes their midpoint") + func movesBetweenSiblings() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Two")) + try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Three")) + let store = try BoardStore(rootURL: fixture.root) + + // With lane1 removed the remaining pair is [lane2 @2048, lane3 @3072]; landing at index 1 + // is between them. + store.moveLane(lane1, toIndex: 1) + + let model = try load(fixture) + #expect(model.lanes.map(\.id) == [lane2, lane1, lane3]) + #expect(model.lanes.first { $0.id == lane1 }?.order == 2560) + // "A reorder rewrites only the moved item's `index.md`" — the neighbours keep their ranks. + #expect(model.lanes.first { $0.id == lane2 }?.order == 2048) + #expect(model.lanes.first { $0.id == lane3 }?.order == 3072) + } + + @Test("A lane moved to the end is appended past every sibling") + func movesToTheEnd() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Two")) + let store = try BoardStore(rootURL: fixture.root) + + store.moveLane(lane1, toIndex: 1) + + let model = try load(fixture) + #expect(model.lanes.map(\.id) == [lane2, lane1]) + #expect(model.lanes.first { $0.id == lane1 }?.order == 3072) + } + + @Test("A drag that ends where it started writes nothing at all") + func unchangedIndexIsANoOp() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData(Ident.lane1) + + // Index 0 is where lane1 already sits, counted with itself removed — the release must not + // stamp `modified` or mint a commit. + store.moveLane(lane1, toIndex: 0) + + #expect(try fixture.indexData(Ident.lane1) == before) + // The writer's temp files are hidden, so only a listing that sees them can prove there is + // no residue from a write that should never have started. + #expect(try !fixture.entryNames(Ident.lane1).contains { $0.hasPrefix(".") }) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("Exhausted precision compacts the board and then places the lane") + func exhaustedPrecisionRenumbers() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + // Two lanes on adjacent Doubles: no rank exists between them (01-storage-format.md § + // Ordering's renumber trigger), which is exactly what the compaction fallback is for. + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Two")) + try fixture.item(Ident.lane3, Item.rich(order: "2048.0000000000005", title: "Three")) + let store = try BoardStore(rootURL: fixture.root) + #expect(Ranks.midpoint(between: 2048, and: 2048.0000000000005) == nil, "the gap really is exhausted") + + // lane1 to the slot between lane2 and lane3. + store.moveLane(lane1, toIndex: 1) + + let model = try load(fixture) + #expect(model.lanes.map(\.id) == [lane2, lane1, lane3]) + // The compaction runs first and is **sequence-preserving** — it rewrites the ladder to + // 1024/2048/3072 in the display order the board already had (lane1, lane2, lane3), so + // nothing visibly moves. Only then is the dragged lane placed, at the midpoint of the fresh + // gap between lane2 and lane3. + #expect(model.lanes.first { $0.id == lane2 }?.order == 2048) + #expect(model.lanes.first { $0.id == lane1 }?.order == 2560) + #expect(model.lanes.first { $0.id == lane3 }?.order == 3072, + "the exhausted rank was compacted away rather than worked around") + } + + @Test("A lane that is not on the board is a no-op, and a read-only board refuses quietly") + func unknownLaneAndLockedBoard() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexData(Ident.lane1) + + store.moveLane(ItemID(rawValue: Ident.indexless), toIndex: 0) + #expect(try fixture.indexData(Ident.lane1) == before) + + store.enterVanishedRootLock() + store.moveLane(lane1, toIndex: 2) + #expect(try fixture.indexData(Ident.lane1) == before) + #expect(store.banners.oneShots.isEmpty) + #expect(store.bannerRows.contains { $0.id == "read-only-lock" }) + } +} + +// MARK: - New lane + +@MainActor +@Suite("BoardStore ▸ new lane") +struct NewLaneWriteTests { + + @Test("New Lane appends an untitled lane at the board's right end") + func createsAnUntitledLane() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One")) + let store = try BoardStore(rootURL: fixture.root) + + store.createLane() + + let model = try load(fixture) + #expect(model.lanes.count == 2) + let made = try #require(model.lanes.last) + #expect(made.id != lane1) + // No `title` key at all — the folder-name fallback is a board-level rule, and a lane with no + // title renders the untitled placeholder until Board ▸ Rename gives it one. + #expect(made.title.isMissing) + #expect(made.order == 2048) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("New Lane is the way out of a zero-lane board") + func createsTheFirstLane() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + let store = try BoardStore(rootURL: fixture.root) + #expect(store.snapshot.lanes.isEmpty) + + store.createLane() + + let model = try load(fixture) + #expect(model.lanes.count == 1) + #expect(model.lanes.first?.order == 1024, "an empty parent's first child lands at the board convention") + } + + @Test("A read-only board refuses the create without a second banner") + func readOnlyBoardRefusesQuietly() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + + store.createLane() + + #expect(try load(fixture).lanes.isEmpty) + #expect(store.banners.oneShots.isEmpty) + } +} diff --git a/KanbanTests/LaneReorderMathTests.swift b/KanbanTests/LaneReorderMathTests.swift new file mode 100644 index 0000000..754d959 --- /dev/null +++ b/KanbanTests/LaneReorderMathTests.swift @@ -0,0 +1,162 @@ +import CoreGraphics +import Testing +@testable import Kanban + +/// `LaneReorderMath` — the lane drag's proposal, as arithmetic. +/// +/// The board these numbers describe: `standard = 100`, `gap = 10`, so a 1× slot is 100pt wide, a 2× +/// slot is 210 (two standards plus the interior gap it swallows) and a 3× is 320. The strip's outer +/// margin is one gap, so the first slot starts at x = 10. + +private let standard: CGFloat = 100 +private let gap: CGFloat = 10 + +private func proposal(_ units: [Int], dragging index: Int, centre: CGFloat) -> Int { + LaneReorderMath.proposedIndex( + unitCounts: units, + draggedIndex: index, + dragCentreX: centre, + standard: standard, + gap: gap + ) +} + +@Suite("LaneReorderMath") +struct LaneReorderMathTests { + + // MARK: Resting geometry + + @Test("A lane's resting centre is its slot's midpoint, gaps and wide lanes counted") + func restingCentres() { + // Four 1× lanes: slots at [10, 110), [120, 220), [230, 330), [340, 440). + let uniform = [1, 1, 1, 1] + #expect(LaneReorderMath.centre(ofLaneAt: 0, unitCounts: uniform, standard: standard, gap: gap) == 60) + #expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: uniform, standard: standard, gap: gap) == 170) + #expect(LaneReorderMath.centre(ofLaneAt: 3, unitCounts: uniform, standard: standard, gap: gap) == 390) + + // A 3× lane in the middle: slots at [10, 110), [120, 440), [450, 550). + let mixed = [1, 3, 1] + #expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: mixed, standard: standard, gap: gap) == 280) + #expect(LaneReorderMath.centre(ofLaneAt: 2, unitCounts: mixed, standard: standard, gap: gap) == 500) + + // An index past the end yields the position the next slot would start at, rather than + // trapping: the drag's lane can vanish between a render and a gesture callback. + #expect(LaneReorderMath.centre(ofLaneAt: 9, unitCounts: mixed, standard: standard, gap: gap) == 560) + } + + // MARK: The proposal + + @Test("A lane that has not moved proposes its own index") + func restingDragProposesNoChange() { + // Dragging lane 1 of four: with it removed the remaining centres are 60, 170, 280. Its own + // resting centre is 170, which has passed exactly one of them. + #expect(proposal([1, 1, 1, 1], dragging: 1, centre: 170) == 1) + #expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0) + #expect(proposal([1, 1, 1, 1], dragging: 3, centre: 390) == 3) + } + + @Test("The proposal steps once the cursor passes a remaining lane's centre, and not before") + func theThresholdIsTheNeighboursCentre() { + // Dragging lane 0 out of four. Remaining slots are the other three, laid out from x = 10: + // centres 60, 170, 280. + #expect(proposal([1, 1, 1, 1], dragging: 0, centre: 59) == 0) + #expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0, "the boundary itself does not step — strictly past") + #expect(proposal([1, 1, 1, 1], dragging: 0, centre: 61) == 1) + #expect(proposal([1, 1, 1, 1], dragging: 0, centre: 171) == 2) + #expect(proposal([1, 1, 1, 1], dragging: 0, centre: 281) == 3) + } + + @Test("A far drag in either direction clamps to the ends") + func farDragsClampToTheEnds() { + #expect(proposal([1, 1, 1, 1], dragging: 2, centre: -5000) == 0) + #expect(proposal([1, 1, 1, 1], dragging: 2, centre: 5000) == 3, "the last index with the lane itself removed") + #expect(proposal([1, 1, 1], dragging: 0, centre: 5000) == 2) + } + + @Test("Width-aware: a wide neighbour has to be crossed, not merely touched") + func wideNeighboursDemandRealTravel() { + // Lanes [1, 3, 1] with the 1× at index 0 dragged. The remaining pair is the 3× then the 1×: + // slots [10, 330) and [340, 440), centres 170 and 390. + // + // "No reflow until the cursor reaches where the dragged lane would actually land": at 200 the + // cursor is well inside the wide lane but has passed its centre, so the step is honest; at + // 150 it has not, and proposing a swap there would reorder the board under a cursor still + // sitting over the lane it started left of. + #expect(proposal([1, 3, 1], dragging: 0, centre: 150) == 0) + #expect(proposal([1, 3, 1], dragging: 0, centre: 200) == 1) + #expect(proposal([1, 3, 1], dragging: 0, centre: 400) == 2) + } + + @Test("The proposal is monotone in the cursor — it never oscillates") + func theProposalIsMonotone() { + // One threshold per remaining slot, crossed once, is what makes the shadow stable rather + // than jittery (04-interactions.md ▸ Drag and drop). Sweeping the whole strip must therefore + // produce a non-decreasing sequence. + let units = [2, 1, 3, 1, 2] + var last = 0 + for x in stride(from: CGFloat(-200), through: 1200, by: 1) { + let next = proposal(units, dragging: 2, centre: x) + #expect(next >= last, "the proposal went backwards as the cursor moved right, at x = \(x)") + last = next + } + #expect(last == units.count - 1) + } + + @Test("A single-lane board proposes the only index there is") + func singleLaneBoard() { + #expect(proposal([1], dragging: 0, centre: -900) == 0) + #expect(proposal([1], dragging: 0, centre: 900) == 0) + } + + @Test("An out-of-range dragged index yields zero rather than trapping") + func vanishedLaneDoesNotTrap() { + // The lane vanished under the drag; the caller's release-with-no-valid-proposal rule cancels + // anyway, so the only contract here is totality. + #expect(proposal([1, 1], dragging: 7, centre: 100) == 0) + #expect(proposal([], dragging: 0, centre: 100) == 0) + } + + // MARK: Applying a proposal + + @Test("Reordering applies the proposal's own index convention") + func reorderedAppliesTheConvention() { + let lanes = ["a", "b", "c", "d"] + + // `to` counts positions with the item already removed, which is what `proposedIndex` + // returns — so `to == from` must be the identity. + #expect(LaneReorderMath.reordered(lanes, from: 1, to: 1) == lanes) + #expect(LaneReorderMath.reordered(lanes, from: 0, to: 0) == lanes) + + #expect(LaneReorderMath.reordered(lanes, from: 0, to: 1) == ["b", "a", "c", "d"]) + #expect(LaneReorderMath.reordered(lanes, from: 0, to: 3) == ["b", "c", "d", "a"]) + #expect(LaneReorderMath.reordered(lanes, from: 3, to: 0) == ["d", "a", "b", "c"]) + #expect(LaneReorderMath.reordered(lanes, from: 2, to: 1) == ["a", "c", "b", "d"]) + } + + @Test("Reordering is total: out-of-range indices clamp or pass through") + func reorderedIsTotal() { + let lanes = ["a", "b", "c"] + #expect(LaneReorderMath.reordered(lanes, from: 9, to: 0) == lanes) + #expect(LaneReorderMath.reordered(lanes, from: 0, to: 99) == ["b", "c", "a"]) + #expect(LaneReorderMath.reordered(lanes, from: 2, to: -5) == ["c", "a", "b"]) + } + + @Test("A dragged lane parked over each slot in turn lands exactly there") + func aRoundTripThroughEverySlot() { + // The end-to-end claim the two halves compose into: park the dragged lane on top of a + // sibling's resting centre and the proposal, applied, puts it in that sibling's place. + let units = [1, 2, 1, 3] + let lanes = ["a", "b", "c", "d"] + let from = 0 + var remaining = units + remaining.remove(at: from) + + for slot in remaining.indices { + let centre = LaneReorderMath.centre(ofLaneAt: slot, unitCounts: remaining, standard: standard, gap: gap) + // A hair past the centre is what "passed it" means; sitting exactly on it holds. + let landed = proposal(units, dragging: from, centre: centre + 1) + #expect(landed == slot + 1) + #expect(LaneReorderMath.reordered(lanes, from: from, to: landed).firstIndex(of: "a") == slot + 1) + } + } +} diff --git a/KanbanTests/NewCardTargetTests.swift b/KanbanTests/NewCardTargetTests.swift new file mode 100644 index 0000000..da48989 --- /dev/null +++ b/KanbanTests/NewCardTargetTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing +@testable import Kanban + +/// 04-interactions.md's **⌘N target rule**, branch by branch. +/// +/// The rule is written as a pure function precisely so it can be tested like one: every branch is a +/// selection plus a snapshot in, a lane-and-anchor (or nothing) out — no menu, no window, no +/// gesture. The board underneath is a real load off a real temp tree, because the rule reads +/// `isDeleted` and card ordering and a hand-built `BoardModel` would let those drift from what the +/// loader actually produces. + +// MARK: - Fixtures + +/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`. + +private func tombstoned(order: String, title: String) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + deleted: 2026-03-03T09:00:00Z + --- + \(title) body. + + """ +} + +/// Two live lanes (three cards between them) and one tombstoned lane, so every branch has something +/// to point at and the trash side has a member of its own. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third")) + try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Archive")) + return fixture +} + +private let lane1 = ItemID(rawValue: Ident.lane1) +private let lane2 = ItemID(rawValue: Ident.lane2) +private let lane3 = ItemID(rawValue: Ident.lane3) +private let card1 = ItemID(rawValue: Ident.card1) +private let card2 = ItemID(rawValue: Ident.card2) +private let card3 = ItemID(rawValue: Ident.card3) + +private func resolve( + _ snapshot: BoardModel, + selection: ItemReferenceSet = .empty, + lastActive: ItemID? = nil +) -> NewCardTarget.Resolution? { + NewCardTarget.resolve(selection: selection, lastActiveLaneID: lastActive, snapshot: snapshot) +} + +// MARK: - Tests + +@MainActor +@Suite("NewCardTarget ▸ the ⌘N target rule") +struct NewCardTargetTests { + + @Test("A sole selected card targets its own lane, immediately after it") + func aSelectedCardAnchorsInItsLane() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // "With a card selected, the new card is created in that card's lane, immediately after it + // (paste-anchor consistency)." + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], liveness: .live)) + == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1)) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], liveness: .live)) + == NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3)) + + // The last card in a lane is still an anchor here — "after the last card" and "at the + // bottom" coincide, and it is the commit that notices (`BoardStore.insertionIndex`). + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], liveness: .live)) + == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2)) + } + + @Test("A sole selected lane targets its bottom, with no anchor") + func aSelectedLaneTargetsItsBottom() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // "With a lane selected, appended at its bottom (Return consistency)." + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], liveness: .live)) + == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) + } + + @Test("Nothing selected falls to the last-active lane") + func nothingSelectedUsesTheLastActiveLane() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + #expect(resolve(snapshot, lastActive: lane2) + == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) + + // And the last resort when there is no memory to consult, or the lane it names is gone: + // "falling back to the first lane". + #expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) + #expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless)) + == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) + // A *tombstoned* lane is not a target either — it renders nowhere, and the trash is never a + // creation destination. + #expect(resolve(snapshot, lastActive: lane3) + == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) + } + + @Test("A tombstoned selection never anchors creation — it behaves as nothing selected") + func aTombstonedSelectionNeverAnchors() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // Settled in 04 ▸ The map, on the ⌘N rule's own wording: "a **tombstoned** selection, which + // never anchors creation". The trash-side lane is a real lane on disk with a live sibling + // list — the rule must not let its identity leak in as a target. + let trashed = ItemReferenceSet(ids: [lane3], liveness: .trashed) + #expect(resolve(snapshot, selection: trashed, lastActive: lane2) + == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) + #expect(resolve(snapshot, selection: trashed) + == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) + } + + @Test("A zero-lane board has no target at all — the menu item's disabled condition") + func aZeroLaneBoardHasNoTarget() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + let empty = try BoardLoader.load(boardRoot: fixture.root).model + + // "Zero-lane board (hand-made, or every lane deleted): card creation and card paste have no + // target — New Card, Return-creation, and Paste with a card payload disable via menu + // validation until a lane exists." + #expect(resolve(empty) == nil) + #expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], liveness: .live)) == nil) + #expect(resolve(empty, lastActive: lane1) == nil) + } + + @Test("A board whose every lane is tombstoned is a zero-lane board") + func everyLaneTombstonedIsAlsoZeroLane() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // The lanes are still in the snapshot — the trash renders them — but none is on the board, + // and "every lane deleted" is the design's own second reading of the zero-lane case. + #expect(snapshot.lanes.count == 1) + #expect(resolve(snapshot) == nil) + } + + @Test("A multi-selection and a stale one both fall through rather than guessing") + func pluralAndStaleSelectionsFallThrough() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // The rule speaks of "a card"/"a lane", singular; a multi-selection has no "it" to be + // immediately after, so it gets the same answer as no selection at all. + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], liveness: .live), lastActive: lane2) + == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) + + // A selection naming something the board does not render — the reload that drops it has not + // landed yet — must not refuse the creation the user just asked for. + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live)) + == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) + } +} diff --git a/KanbanTests/RanksTests.swift b/KanbanTests/RanksTests.swift index 544d4a1..0f11f00 100644 --- a/KanbanTests/RanksTests.swift +++ b/KanbanTests/RanksTests.swift @@ -126,6 +126,45 @@ struct RanksTests { #expect(Ranks.insertAtHead(ofVisible: items) == 1024) } + // MARK: - Insertion at a display position + + @Test func insertionRankDispatchesOnPosition() throws { + let orders = [1024.0, 2048.0, 3072.0] + + // The three cases every insertion gesture has, behind one call. + #expect(Ranks.insertionRank(amongVisible: orders, at: 0) == 0, "head: min − 1024") + #expect(Ranks.insertionRank(amongVisible: orders, at: 1) == 1536, "between: the midpoint") + #expect(Ranks.insertionRank(amongVisible: orders, at: 2) == 2560) + #expect(Ranks.insertionRank(amongVisible: orders, at: 3) == 4096, "end: max + 1024") + + // The result is always strictly inside the gap it names, which is what makes the display + // order the caller asked for the one it gets. + let placed = try #require(Ranks.insertionRank(amongVisible: orders, at: 1)) + #expect(placed > orders[0] && placed < orders[1]) + } + + @Test func insertionRankIsTotalOnEdgeInputs() { + // An empty parent's first child lands at the board convention, whatever index is asked for. + #expect(Ranks.insertionRank(amongVisible: [], at: 0) == 1024) + #expect(Ranks.insertionRank(amongVisible: [], at: 7) == 1024) + + // Out-of-range indices clamp to the two ends rather than trapping: an index arrives from a + // drag's geometry, and geometry can outrun a snapshot. + #expect(Ranks.insertionRank(amongVisible: [1024], at: -3) == 0) + #expect(Ranks.insertionRank(amongVisible: [1024], at: 99) == 2048) + } + + @Test func insertionRankReportsAnExhaustedGapRatherThanInventingOne() { + // `nil` is the renumber trigger, not a refusal — and it must fire for the duplicate-order + // tie as well as for adjacent Doubles, since neither admits a rank between. + #expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 1) == nil) + #expect(Ranks.insertionRank(amongVisible: [1024, 1024.0000000000002], at: 1) == nil) + + // The ends never exhaust: append and head-insert always have room. + #expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 0) == 0) + #expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 2) == 2048) + } + // MARK: - Precision exhaustion → renumber, deterministically @Test func precisionExhaustionThenRenumberIsDeterministic() { diff --git a/KanbanTests/TransientBoardStateTests.swift b/KanbanTests/TransientBoardStateTests.swift index 5e100d8..73a4dc6 100644 --- a/KanbanTests/TransientBoardStateTests.swift +++ b/KanbanTests/TransientBoardStateTests.swift @@ -262,6 +262,187 @@ struct TransientBoardStateTests { ) } + // MARK: The rename editor + + @Test("The rename editor seeds from the current title and records what is typed") + func renameEditorSeedsAndTracks() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginRename(of: card1, currentTitle: "First") + #expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "First")) + #expect(store.transient.isEditingInline) + + store.transient.updateRenameDraft("First, renamed") + #expect(store.transient.renameEditor?.draftTitle == "First, renamed") + + // An untitled item seeds *empty*, never with the word the face renders: "Untitled" is a + // rendering, not a value (03-board-ui.md § Card face), and typing it into the file would + // turn a missing key into a real title. + store.transient.beginRename(of: lane2, currentTitle: nil) + #expect(store.transient.renameEditor?.draftTitle.isEmpty == true) + + store.transient.discardRename() + #expect(store.transient.renameEditor == nil) + #expect(!store.transient.isEditingInline) + } + + @Test("One focus, one editor — beginning either kind ends the other") + func theTwoEditorsAreMutuallyExclusive() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginPlaceholder(inLane: lane1) + store.transient.updateDraft("half typed") + store.transient.beginRename(of: card1, currentTitle: "First") + + // The draft is discarded per the placeholder's own click-away rule — 02-architecture.md's + // "starting a new creation while a placeholder is open is a click-away for the draft", + // read in the other direction. + #expect(store.transient.newCardPlaceholder == nil) + #expect(store.transient.renameEditor?.targetID == card1) + + store.transient.beginPlaceholder(inLane: lane2) + #expect(store.transient.renameEditor == nil) + #expect(store.transient.newCardPlaceholder?.laneID == lane2) + } + + @Test("A rename whose target is deleted from the tree is discarded") + func renameDiscardedWhenItsTargetIsRemoved() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("Never lands") + + try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) + await reload(store) + + // "A target that is tombstoned, deleted, or gone at commit time discards the editor and its + // keystrokes silently" (04-interactions.md ▸ Grammar). + #expect(store.transient.renameEditor == nil) + } + + @Test("A rename whose target is tombstoned is discarded — a liveness flip is a vanish") + func renameDiscardedWhenItsTargetIsTombstoned() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginRename(of: card1, currentTitle: "First") + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First")) + await reload(store) + + #expect(store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }?.isDeleted == true) + #expect(store.transient.renameEditor == nil) + } + + @Test("A rename under a tombstoned lane is discarded too — liveness is effective") + func renameDiscardedWhenItsLaneIsTombstoned() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginRename(of: card1, currentTitle: "First") + + try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) + await reload(store) + + // The card's own flag never changed; its lane's did. The ancestor walk is absolute — the + // card renders nowhere, so the editor sitting on it has no target + // (`CardWindowHost.cardWindowFate`'s rule, applied to the third inline editor). + let card = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 } + #expect(card?.isDeleted == false) + #expect(store.transient.renameEditor == nil) + } + + @Test("A rename survives a foreign move — the editor follows the UUID, not the position") + func renameSurvivesAForeignMove() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("Still mine") + + // An agent files the card into the other lane mid-typing. "A foreign *move* mid-rename is + // invisible — the editor follows the UUID and the commit writes the title wherever the card + // now lives." + try FileManager.default.moveItem( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + to: fixture.url("\(Ident.lane2)/\(Ident.card1)") + ) + await reload(store) + + #expect(store.snapshot.lanes.first { $0.id == lane2 }?.cards.contains { $0.id == card1 } == true) + #expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "Still mine")) + } + + @Test("A rename of a lane survives an unrelated reload, draft intact") + func laneRenameSurvivesAnUnrelatedReload() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginRename(of: lane1, currentTitle: "Todo") + store.transient.updateRenameDraft("To d") + + try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed by an agent")) + await reload(store) + + #expect(store.transient.renameEditor == RenameEditor(targetID: lane1, draftTitle: "To d")) + } + + // MARK: The last-active lane + + @Test("Selecting a lane or one of its cards marks it active; clearing the selection does not forget it") + func selectionMarksTheActiveLane() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + #expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember") + + store.select([lane2], liveness: .live) + #expect(store.transient.lastActiveLaneID == lane2) + + // A *card* selection is its lane holding selection too — 04's "the lane that most recently + // held selection or a creation". + store.select([card1], liveness: .live) + #expect(store.transient.lastActiveLaneID == lane1) + + // A cross-lane selection names no single lane, so it leaves the memory alone rather than + // guessing at one of the two. + store.select([card1, card3], liveness: .live) + #expect(store.transient.lastActiveLaneID == lane1) + + // Deselecting does not un-happen where the user was working: ⌘N with nothing selected is + // exactly the case the memory exists to answer. + store.clearSelection() + #expect(store.transient.lastActiveLaneID == lane1) + } + + @Test("Creating into a lane marks it active, and a vanished lane is forgotten on reload") + func creationMarksTheActiveLaneAndAVanishClearsIt() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginPlaceholder(inLane: lane2) + #expect(store.transient.lastActiveLaneID == lane2) + + try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing")) + await reload(store) + + // A lane that renders nowhere is no target at all; `NewCardTarget` then falls through to + // the first lane rather than proposing the trash. + #expect(store.transient.lastActiveLaneID == nil) + } + // MARK: Per-open values @Test("Trash visibility and the search query default per-open and pass through a reload untouched") diff --git a/README.md b/README.md index f4191dc..e162cc5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Lanework is in early development. This list tracks what has actually shipped and - **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. - **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 (branding, failed-open reporting), 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; 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. +- **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 reorder drag surface, a plain click selecting the lane and movement carrying it above its siblings while they show the would-be order. +- **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. ## Development