The card window's title learns Edit mode — a sibling session rides the body's own doors

CardWindowView's header is now the title, live: a static Text in Preview, an editable
single-line TextField in Edit, both reading a new CardTitleEditSession's buffer rather
than the card's own snapshot value — the same "buffer outranks the snapshot" reason the
body surface already reads bodySession.text instead of card.body.

CardTitleEditSession is CardBodyEditSession's shape one field over: the one-isDirty write
gate, dirty-buffer-wins on adopt(diskTitle:), the ~700ms injectable debounce, flush() /
flushOrThrow() for DirtyBufferGuard, and beginEditSession()/endEditSession() with a
session-coalesced undo step (one per session, never per debounced tick). It rides the
body's own begin/end/flush doors rather than opening a second session boundary — title is
only ever editable while the body column is in Edit mode — because the two write through
different WriteOperations with different validation and merging them would conflate two
unrelated frontmatter keys behind one buffer.

BoardStore.commitCardTitle(inCard:title:) reuses the same private setTitle helper and the
same .rename WriteOperation the board's own inline rename commits through, so trimming,
empty-removes-the-key, unchanged-writes-nothing and banner enrichment are one code path,
not a re-implementation. It resolves through cardBodyTarget (spans lanes and the trash),
not boardItem (board only), because a card window's title field stays live through the
same dismissal-into-trash flush the body already gets — the one deliberate divergence
from the board's own rename, which treats a trashed target as vanished.
registerTitleEdit(inCard:priorTitle:newTitle🔛) mirrors registerBodyEdit, anchored by
card identity and the already-reserved ExpectedField.title, folding into the same
one-coarse-step-per-window-close undo model.

Every place the body's Edit buffer flushes, the title's now does too: mode exit
(bodyPresentation.flushEdits/beginEdits), window close and the dismissal path
(CardWindowSession.endSession()), raw-source entry (configureRawSource), the close-time
DirtyBufferGuard modal (attemptSave tries body then title), and the fast-path close gate
(closeAfterFlushing() now checks title.isDirty alongside body.isDirty).
holdsUnsavedContent and settlement carry the title too.

Tests: CardTitleEditSessionTests.swift mirrors CardBodyEditSessionTests.swift (write
gates, normalization and newline-stripping, dirty-buffer-wins, debounce, undo-step
coalescing). CardTitleWriteTests.swift covers commitCardTitle/registerTitleEdit:
byte-identity no-op, empty-removes-key, vanished, the trashed-card-is-still-writable
divergence, a readable-but-uneditable target refusing and bannering, and a read-only
board suspending quietly. CardSessionUndoTests.swift gains coverage that a title edit
folds into the coarse close step alongside a body edit and registers on the window's own
stack, never the board's. RawSourceTests.swift's hand-wired rig picks up the title
session configureRawSource now also flushes.

3148 KanbanTests pass, 0 failures.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 10:37:55 -04:00
parent aa8c54fc7a
commit c27cc93ec1
8 changed files with 1164 additions and 24 deletions
+77
View File
@@ -87,8 +87,15 @@ private func openWindow(_ fixture: WriterFixture, store: BoardStore, board: Nati
session.body.save = { [weak store] text in
store?.writeCardBody(inCard: cardID, body: text) ?? .vanished
}
// The title field's own write target `configureSession`'s seam, spelled the same way as the
// body's just above it.
session.title.save = { [weak store] title in
store?.commitCardTitle(inCard: cardID, title: title) ?? .vanished
}
session.comments.open()
session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
// `makeCommentBoard`'s fixture card is always titled "Fix login" `coarseStep`'s own constant.
session.title.adopt(diskTitle: "Fix login")
return Window(store: store, board: board, session: session)
}
@@ -98,6 +105,12 @@ private func editBody(_ window: Window, to text: String) {
window.body.endEditSession()
}
@MainActor
private func editTitle(_ window: Window, to text: String) {
window.session.title.edited(text)
window.session.title.endEditSession()
}
@MainActor
private func postComment(_ window: Window, body: String) -> ItemID? {
window.comments.composer.edited(body)
@@ -1012,3 +1025,67 @@ struct CardSessionFoldTests {
#expect(restored.expectations == [.present(live, .body("original\n"))])
}
}
// MARK: - The title field
/// `registerTitleEdit`'s own routing and fold, over the production wiring `CardWindowHost.configureUndo`
/// installs `CardSessionRoutingTests`' and `CardSessionCloseTests`' own claims, restated for the
/// header's title field rather than the body (`CardTitleWriteTests.swift` pins the write itself).
@MainActor
@Suite("Card session undo ▸ the title field")
struct CardSessionTitleTests {
@Test("A title edit registers on the window's own stack, never on the board's")
func windowGesturesStayOffTheBoardStack() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
editTitle(window, to: "Fix login, take two")
#expect(!window.board.canUndo, "board ⌘Z never sees mid-session card steps")
#expect(window.window.stack.canUndo)
#expect(window.window.stack.undoActionName == "Rename Card")
}
@Test("A title edit and a body edit in one session fold into one coarse step")
func foldsIntoTheCoarseStepAlongsideTheBody() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
let originalBody = try body(fixture, cardPath)
editBody(window, to: "Edited in the window.\n")
editTitle(window, to: "Fix login, take two")
await window.session.endSession()
#expect(window.board.undoActionName == coarseStep, "named for the card, not for either fine gesture")
window.board.undo()
let model = try BoardLoader.load(boardRoot: fixture.root).model
let restored = model.lanes.flatMap(\.cards).first { $0.id == cardID }
#expect(restored?.title == .valid("Fix login"), "the title is back too")
#expect(try body(fixture, cardPath) == originalBody)
window.board.redo()
let after = try BoardLoader.load(boardRoot: fixture.root).model
#expect(after.lanes.flatMap(\.cards).first { $0.id == cardID }?.title == .valid("Fix login, take two"))
#expect(try body(fixture, cardPath) == "Edited in the window.\n")
}
@Test("A title typed back to its starting value registers nothing")
func aRoundTripRegistersNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
window.session.title.edited("Detour")
_ = window.session.title.flush()
editTitle(window, to: "Fix login")
#expect(!window.window.stack.canUndo, "the session's net effect on the title is nothing")
}
}