Build lane chrome — title bar, badge, inline rename

The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 08:52:24 -04:00
parent ff3ba298f0
commit b35566e0fe
21 changed files with 2855 additions and 79 deletions
+12 -1
View File
@@ -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)
+15 -3
View File
@@ -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()
}
+5
View File
@@ -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:
+271 -3
View File
@@ -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<ItemID>, 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<ItemID>, 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.
+187 -10
View File
@@ -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.
+10
View File
@@ -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"
+23
View File
@@ -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,
+105 -7
View File
@@ -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 }
+195 -10
View File
@@ -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,15 +75,37 @@ 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.
///
@@ -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)
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
+101
View File
@@ -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<T>(_ 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
}
}
+69
View File
@@ -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
}
}
+493 -37
View File
@@ -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<String> {
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 {
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<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
// MARK: - The new-card placeholder
/// The card being created, drawn as a pseudo-card in the masonry flow at standard card width
/// 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<String> {
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()
}
}
}
+82
View File
@@ -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) }
}
}
+44
View File
@@ -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<String>, 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
}
}
+16
View File
@@ -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")))
+658
View File
@@ -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<String> {
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)
}
}
+162
View File
@@ -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)
}
}
}
+177
View File
@@ -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))
}
}
+39
View File
@@ -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() {
+181
View File
@@ -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")
+2
View File
@@ -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