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
+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")