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
+40 -7
View File
@@ -55,6 +55,11 @@ final class CardWindowSession: CardSessionFlushing {
/// rather than pretending to have written it.
let body: CardBodyEditSession
/// The window header's title field `body`'s sibling, driven by the exact same begin/end/flush
/// calls (`CardTitleEditSession`'s own doc comment). Created here for `body`'s reason: it needs a
/// save target once the window has joined its board.
let title = CardTitleEditSession()
/// **This window's own undo stack** 13-native-undo.md Rules' second level (re-ruled
/// 2026-07-31): "a card window owns its own stack for the session it represents ... and
/// `window.undoManager` answers with it".
@@ -129,7 +134,7 @@ final class CardWindowSession: CardSessionFlushing {
/// place rather than unsaved work. An inline comment edit *is* the ordinary kind, so it counts
/// exactly as the body's does (`CardComments.holdsUnsavedContent`).
var holdsUnsavedContent: Bool {
body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
body.isDirty || title.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
}
/// **What a restore or a branch switch asks this window to settle** (06-history-undo.md Rules
@@ -150,11 +155,13 @@ final class CardWindowSession: CardSessionFlushing {
var settlement: CardSessionSettlement? {
CardSessionSettlement(
needsSettling: { [self] in
body.isEditing || body.isDirty || rawSourceIsActive?() == true
body.isEditing || body.isDirty || title.isDirty || rawSourceIsActive?() == true
},
saveAll: { [self] in
// The Edit session ends with its normal commit "each card's EditPreview flip".
// The Edit session ends with its normal commit "each card's EditPreview flip"
// title alongside body, the same door either way.
body.endEditSession()
title.endEditSession()
// Apply validates; a refusal is the whole operation's cancellation, and the alert it
// raised is already on the offending window.
guard rawSourceIsActive?() == true else { return true }
@@ -165,6 +172,7 @@ final class CardWindowSession: CardSessionFlushing {
// part of the wholesale operation itself, which is the only party that knows what it
// is reconciling this card's folder towards (`SessionSettleGate`).
body.discardBuffer()
title.discardBuffer()
rawSourceCancel?()
}
)
@@ -182,12 +190,21 @@ final class CardWindowSession: CardSessionFlushing {
init() {
let body = CardBodyEditSession()
self.body = body
// Already default-initialized (`let title = CardTitleEditSession()`, above): captured into a
// local here for `body`'s own reason the closures below must not close over `self`, which
// would retain this object through `bufferGuard` right back at itself.
let title = self.title
bufferGuard = DirtyBufferGuard(
attemptSave: { () throws(BoardWriteError) -> Void in try body.flushOrThrow() },
attemptSave: { () throws(BoardWriteError) -> Void in
try body.flushOrThrow()
try title.flushOrThrow()
},
// "Save a copy elsewhere" writes the *buffer*, not the card: the destination is
// somewhere outside the board the user picked in a panel, so what lands there is the
// text they were typing, as a file, and nothing about frontmatter or identity travels
// with it.
// with it. The title rides along with the body here only in spirit the copy is the
// Markdown text, unchanged, since the panel's own file name already carries the title
// (`CardWindowHost.copyDestination`).
writeCopy: { url in try Data(body.text.utf8).write(to: url) }
)
}
@@ -203,6 +220,9 @@ final class CardWindowSession: CardSessionFlushing {
// Rules) once the session is known to be over. It is also where the body's *last* fine step
// joins this window's stack, which is why it has to precede the fold below.
body.endEditSession()
// The title field's own session, ended the same door see `CardTitleEditSession`'s doc
// comment on why there is no session boundary of its own to end.
title.endEditSession()
// **The saves, in the order the comments build fixed**: the inline session's flush, then the
// draft's (`CardComments.endSession`). Both may register their own last fine step, so both
// land before the fold.
@@ -475,6 +495,7 @@ struct CardWindowHost: View {
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
bodyPresentation: bodyPresentation,
bodySession: session.body,
titleSession: session.title,
rawSource: rawSource,
// "Under the read-only lock the controls disable in place an in-content mutation
// menu validation can't reach" (05 Preview). The checkbox is that control, and
@@ -708,12 +729,18 @@ struct CardWindowHost: View {
guard let store else { return .vanished }
return store.writeCardBody(inCard: cardID, body: text)
}
session.title.save = { [weak store] title in
guard let store else { return .vanished }
return store.commitCardTitle(inCard: cardID, title: title)
}
Self.configureUndo(session, store: store, cardID: cardID)
bodyPresentation.flushEdits = { [session] in
session.body.endEditSession()
session.title.endEditSession()
}
bodyPresentation.beginEdits = { [session] in
session.body.beginEditSession()
session.title.beginEditSession()
}
// **No stage-around wire here any more** (06-history-undo.md Rules Auto-commit, widened
// 2026-07-31 recorded because its absence is the change): the EditPreview flip used to open
@@ -724,6 +751,7 @@ struct CardWindowHost: View {
Self.configureRawSource(
rawSource,
body: session.body,
title: session.title,
presentation: bodyPresentation,
store: store,
cardID: cardID
@@ -780,6 +808,9 @@ struct CardWindowHost: View {
session.body.registerUndo = { [weak store, undo = session.undo] priorBody, newBody in
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody, on: undo)
}
session.title.registerUndo = { [weak store, undo = session.undo] priorTitle, newTitle in
store?.registerTitleEdit(inCard: cardID, priorTitle: priorTitle, newTitle: newTitle, on: undo)
}
session.undo.isReadOnly = { [weak store] in store?.isReadOnly ?? false }
session.registerSessionStep = { [weak store] undo, purge in
store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false
@@ -936,13 +967,15 @@ struct CardWindowHost: View {
static func configureRawSource(
_ rawSource: CardRawSourceSession,
body: CardBodyEditSession,
title: CardTitleEditSession,
presentation: CardBodyPresentation,
store: BoardStore,
cardID: ItemID
) {
rawSource.flushPendingEdits = { [body, presentation] in
rawSource.flushPendingEdits = { [body, title, presentation] in
presentation.setMode(.preview)
body.flush()
title.flush()
}
rawSource.read = { [weak store] in
guard let store else { return .vanished }
@@ -1081,7 +1114,7 @@ struct CardWindowHost: View {
/// land and both were already visible to the user as the standing lock row or a card that left
/// the board (`CardBodyEditSession.flushOrThrow`).
private func closeAfterFlushing() {
guard session.body.isDirty else {
guard session.body.isDirty || session.title.isDirty else {
windowController.closeAfterFlush()
return
}
+83
View File
@@ -2965,6 +2965,89 @@ public final class BoardStore: HealHost {
}
}
// MARK: - Card window title field
/// Saves the card window header's title field `writeCardBody`'s own shape, aimed at the
/// `.rename` vocabulary instead of the body span (05-card-window.md Edit, extended to the title;
/// `CardTitleEditSession.save`).
///
/// **Validation mirrors `commitRename` exactly, deliberately reusing its write** (`Self.setTitle`):
/// the caller hands over an already-normalized title trimmed, empty turned to `nil` and this
/// compares it against the card's *current* title before writing anything, so an unchanged commit
/// stamps nothing (`commitRename`'s "an unchanged title writes nothing at all"). The comparison is
/// against the store's own idea of the current title, freshly read off the snapshot, which is the
/// same belt-and-braces `CardBodyEditSession`'s doc comment describes for the body: the session's
/// own `isDirty` already gated the call, and this re-checks anyway.
///
/// **Resolved by `cardBodyTarget`, not `boardItem`** the one deliberate difference from
/// `commitRename`, and for `writeCardBody`'s own reason: the card window's title field stays live
/// through the same dismissal-into-trash flush the body does (05 Deletion & lifecycle), so the
/// write has to reach a card that was just moved into `.trash/` out from under the buffer. A card
/// resolving nowhere at all purged, or moved to another board is `.vanished`, "nowhere left to
/// write" exactly as the body's own outcome reads it.
///
/// Outcomes are `CardBodyWriteOutcome` rather than a title-shaped enum of its own: the five cases
/// mean the same things for a title as they do for a body (the bytes landed, nothing needed to,
/// the lock suspended the save, the card is gone, or the write failed and the banner already named
/// it), and a second enum with the same five doors would only be a second name to keep in step.
public func commitCardTitle(inCard cardID: ItemID, title: String?) -> CardBodyWriteOutcome {
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return .vanished }
let currentTitle = Self.cardTitle(at: target, in: snapshot)
guard title != currentTitle else { return .unchanged }
let folder = target.folder(under: rootURL)
do {
try performWrite { () throws(BoardWriteError) -> Void in
try Self.setTitle(title, at: folder)
}
return .written
} catch let refusal as BoardStoreWriteRefusal {
guard case let .readOnlyLocked(reason) = refusal else { return .unchanged }
return .suspended(reason)
} catch let error as BoardWriteError {
return .failed(error)
} catch {
// `performWrite`'s `throws` is untyped only because its two error types have not been
// unified yet (`BoardStoreWriteRefusal`); there is no third thing it can throw.
Self.logger.error("unexpected error saving a card title: \(String(describing: error), privacy: .public)")
return .unchanged
}
}
/// Registers **one title-edit session** as one undo step `registerBodyEdit`'s own reason,
/// applied to the header's title field instead of the body span (13-native-undo.md Rules).
///
/// The step's after-value is `ExpectedField.title`, the same field kind `commitRename`'s own step
/// declares reserved in `CardWindowUndo.Fold.fieldOrder` since before this milestone existed
/// so a title edit made in the window folds against a foreign board-side rename of the same card
/// exactly the way two board-side renames would stale each other: last write, field-level.
///
/// Anchored by card identity (`HistoryAnchor.card`), never by path, for `registerBodyEdit`'s own
/// reason: a lane move mid-session must not stale a step about the card's *name* any more than one
/// about its body.
public func registerTitleEdit(
inCard cardID: ItemID,
priorTitle: String?,
newTitle: String?,
on window: CardWindowUndo? = nil
) {
guard priorTitle != newTitle, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let card = HistoryAnchor.card(cardID)
let operation = WriteOperation.rename(title: Self.cardTitle(at: target, in: snapshot))
registerStep(
HistoryPhrase.name(.rename, kind: .card),
subject: newTitle ?? priorTitle,
on: window,
undoExpects: [.present(card, .title(newTitle))],
redoExpects: [.present(card, .title(priorTitle))]
) { store in
try Self.setTitle(priorTitle, at: try store.requiredFolder(for: card, operation))
} redo: { store in
try Self.setTitle(newTitle, at: try store.requiredFolder(for: card, operation))
}
}
/// Registers **one Edit session** as one undo step 13-native-undo.md Rules' coalescing
/// sentence, stated where the session ends rather than where the bytes land.
///
+257
View File
@@ -0,0 +1,257 @@
import Foundation
import Observation
// MARK: - CardTitleEditSession
/// The card window header's title field **a body-edit session in miniature**
/// (`CardBodyEditSession`'s own phrase for `CommentEditSession`, applied one level up), scoped to the
/// single line the header shows in Edit mode.
///
/// ### What it borrows from `CardBodyEditSession`, verbatim
///
/// The buffer/disk pair, the single write predicate (*write if and only if the buffer differs from
/// disk*), dirty-buffer-wins on `adopt(diskTitle:)`, the ~700 ms trailing debounce, `flush()` /
/// `flushOrThrow()`, and the begin/end pair whose end registers the session's one undo step. **There
/// is no session boundary of this type's own** the card's title is only ever editable while the
/// body column is in Edit mode (m6-card-body's rule, stated on `CardWindowView`'s header), so this
/// session's `beginEditSession()` / `endEditSession()` are called from the exact places
/// `CardBodyEditSession`'s are (`CardWindowHost.configureSession`'s `bodyPresentation.flushEdits` /
/// `.beginEdits`, and `CardWindowSession.endSession()`), never on a schedule of their own. That is the
/// literal reading of "title dirtiness rides the same gates/flush points as body".
///
/// ### What differs, and why it is a sibling rather than a merge
///
/// The write target is a different `WriteOperation` a title commits through the same `.rename`
/// vocabulary the board's inline rename uses (`BoardStore.commitCardTitle`, `BoardStore.setTitle`),
/// never `.editBody`. Folding the two into one session would mean one buffer standing for two
/// unrelated frontmatter keys with two different validation rules (a title trims and empty-removes;
/// a body does neither), so a sibling object driven by the same triggers, writing through its own
/// seam is the shape that keeps each write's rules in one place.
///
/// ### The value is `String?`, so "not yet captured" needs its own bit
///
/// `CardBodyEditSession.sessionOriginBody` uses `nil` for "no save has landed this session" because a
/// body is never itself optional. A title's on-disk value legitimately *is* `nil` a card can be
/// untitled so this type keeps `sessionHasOrigin` beside `sessionOriginTitle` rather than reusing
/// `nil` for two different meanings.
///
/// ### Single-line, by construction and by filter
///
/// A plain `TextField` already turns Return into a submit rather than a newline (`CardWindowView`'s
/// header wires `.onSubmit` to `flush()`), which is the whole of "Return ends editing rather than
/// inserting" on the keystroke that matters. `edited(_:)` additionally strips `\n`/`\r` from
/// whatever arrives typing cannot produce either, but a paste can so the buffer this type shows
/// and writes is single-line even when the text came in some other way. Trimming leading/trailing
/// whitespace and turning an empty result into `nil` happen at commit (`normalize(_:)`), mirroring
/// the board's inline rename exactly (`BoardStore.commitRename`): a title of three spaces reads as
/// untouched, never as a real (if blank) name.
@MainActor
@Observable
public final class CardTitleEditSession {
// MARK: State
/// What the field is showing raw, as typed, never trimmed. Preview's header reads this too
/// (`CardWindowView.bodyColumn`), for `CardBodyEditSession.text`'s own reason: a flushed buffer is
/// ahead of the snapshot by a reload, so the buffer is the truer of the two in both modes.
public private(set) var text: String = ""
/// What the last snapshot said the card's `title` key holds. `nil` is untitled, a legitimate
/// resting state rather than a missing reading.
public private(set) var disk: String?
/// Whether the buffer, once normalized the way a commit would, differs from disk. Comparing the
/// *normalized* text rather than the raw one is what keeps typing spaces around an untouched title
/// from reading as dirty `commitRename`'s own "whitespace commits as empty" rule, read one level
/// earlier.
public var isDirty: Bool { Self.normalize(text) != disk }
/// Whether an Edit session is open right now `CardBodyEditSession.isEditing`'s own bookkeeping,
/// kept here too because this type's undo step coalesces the same way body's does: one step per
/// session, captured from the first landed save to the session's end.
public private(set) var isEditing = false
// MARK: Seams
/// The debounce interval body's own ~700 ms default, and settable for the same reason
/// (`DragSession.holdTimeout`'s precedent: production default on the property, a test dials it
/// down).
@ObservationIgnored
public var debounceInterval: Duration = .milliseconds(700)
/// Where a save goes `BoardStore.commitCardTitle(inCard:title:)`, filled in by the window once it
/// has joined its board. Takes the *normalized* title (trimmed, empty turned to `nil`), which is
/// what the store's own write gate compares against the card's current title.
@ObservationIgnored
public var save: ((String?) -> CardBodyWriteOutcome)?
/// Where this session's one undo step goes, called at `endEditSession()` with the title disk held
/// when the session's first save landed and the title it holds now
/// `CardBodyEditSession.registerUndo`'s seam, one field over.
@ObservationIgnored
public var registerUndo: ((_ priorTitle: String?, _ newTitle: String?) -> Void)?
/// Whether a save has landed this session `CardBodyEditSession.sessionOriginBody`'s `nil` check,
/// spelled as its own flag because `String?` is a real value here, not a "not captured" marker.
@ObservationIgnored
private var sessionHasOrigin = false
/// What disk said before this session's first landed save, when `sessionHasOrigin` is `true`.
@ObservationIgnored
private var sessionOriginTitle: String?
@ObservationIgnored
private var pending: Task<Void, Never>?
/// How many saves have actually been attempted through `save`.
@ObservationIgnored
public private(set) var saveAttempts = 0
public init() {}
// MARK: - Disk buffer
/// A snapshot arrived. Dirty-buffer-wins, `CardBodyEditSession.adopt(diskBody:)`'s exact shape:
/// `disk` always follows it; `text` follows it only when the buffer had nothing unsaved.
public func adopt(diskTitle: String?) {
let wasDirty = isDirty
disk = diskTitle
guard !wasDirty else { return }
let shown = diskTitle ?? ""
if text != shown { text = shown }
}
// MARK: - Buffer disk
/// The field changed. Strips embedded newlines first (paste's own hazard typing a single Return
/// never reaches here at all, since a plain `TextField` treats it as a submit), then restarts the
/// debounce, or cancels it when the change brought the buffer back to what a commit of it would
/// leave on disk.
public func edited(_ newText: String) {
let sanitized = Self.stripNewlines(newText)
guard text != sanitized else { return }
text = sanitized
guard isDirty else {
cancelPending()
return
}
scheduleSave()
}
/// Saves now if there is anything to save, cancelling the pending debounce first the mode-exit
/// and window-close flush, `CardBodyEditSession.flush()`'s own reason.
@discardableResult
public func flush() -> CardBodyWriteOutcome {
cancelPending()
return saveNow()
}
/// The start of one Edit session, called alongside `CardBodyEditSession.beginEditSession()` from
/// the same door (`CardBodyPresentation.beginEdits`). Idempotent for the same reason: a
/// re-published focus value or a redundant menu validation must not renotify observers over an
/// already-open session.
public func beginEditSession() {
guard !isEditing else { return }
isEditing = true
}
/// The end of one Edit session, called alongside `CardBodyEditSession.endEditSession()` from the
/// same door. Flushes, then registers the session's one undo step when something of this session's
/// actually landed and left a net change.
@discardableResult
public func endEditSession() -> CardBodyWriteOutcome {
let outcome = flush()
if sessionHasOrigin, sessionOriginTitle != disk {
registerUndo?(sessionOriginTitle, disk)
}
sessionHasOrigin = false
sessionOriginTitle = nil
if isEditing { isEditing = false }
return outcome
}
/// Throws the buffer away and takes disk's word for it the Discard branch of the save-or-discard
/// step, `CardBodyEditSession.discardBuffer()`'s exact shape.
public func discardBuffer() {
cancelPending()
text = disk ?? ""
sessionHasOrigin = false
sessionOriginTitle = nil
if isEditing { isEditing = false }
}
/// `DirtyBufferGuard`'s `attemptSave`, folded together with the body's own in
/// `CardWindowSession.init()`: the same flush, with a real failure raised instead of reported. The
/// three non-failures are `CardBodyEditSession.flushOrThrow()`'s own written/unchanged land the
/// text or need not, vanished has nowhere to write, suspended is the standing read-only lock row
/// rather than a failure and none of them is a reason to block the close.
public func flushOrThrow() throws(BoardWriteError) {
switch flush() {
case .written, .unchanged, .vanished, .suspended:
return
case let .failed(error):
throw error
}
}
// MARK: - Private
private func scheduleSave() {
cancelPending()
let interval = debounceInterval
pending = Task { [weak self] in
try? await Task.sleep(for: interval)
guard !Task.isCancelled, let self else { return }
self.pending = nil
_ = self.saveNow()
}
}
private func cancelPending() {
pending?.cancel()
pending = nil
}
/// The gate, and the one place `save` is called. A landing moves `disk` up to the normalized text
/// that landed, so the echo arriving a reload later finds the buffer already clean.
private func saveNow() -> CardBodyWriteOutcome {
guard isDirty else { return .unchanged }
guard let save else { return .vanished }
saveAttempts += 1
let priorTitle = disk
let normalized = Self.normalize(text)
let outcome = save(normalized)
switch outcome {
case .written:
if !sessionHasOrigin {
sessionHasOrigin = true
sessionOriginTitle = priorTitle
}
disk = normalized
case .unchanged:
disk = normalized
case .suspended, .vanished, .failed:
break
}
return outcome
}
/// Strips `\r\n`, `\n` and `\r` outright the single-line field's own hard rule, applied to
/// whatever arrives rather than only to what a commit would write, so the on-screen text never
/// shows a line break a paste tried to bring in.
private static func stripNewlines(_ raw: String) -> String {
raw
.replacingOccurrences(of: "\r\n", with: "")
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
}
/// Trimmed, empty turned to `nil` `BoardStore.commitRename`'s own reading of a typed title,
/// applied here so `isDirty` and the value handed to `save` agree with what the board's inline
/// rename would do with the same keystrokes.
private static func normalize(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
+64 -15
View File
@@ -32,16 +32,18 @@ import UniformTypeIdentifiers
///
/// ### What this milestone builds, and what it deliberately does not
///
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the header's
/// renderings of what the loader already knows the card's title and its created/modified line
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`), with the
/// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that
/// reads or writes beyond that is later work and is marked where it lands:
/// raw-source outlet swapping the pair of columns out entirely when it is active.
///
/// - the title as an editable field (commit on Return / focus loss, Escape abandons).
/// **The title is an editable field in Edit mode, a static rendering in Preview** (m6-card-body,
/// widened): it rides the body column's own mode rather than opening a second editor of its own, so
/// "Edit mode" means both surfaces at once, and its own session (`titleSession`, `CardTitleEditSession`)
/// begins and ends with the body's see that type's doc comment for the whole of what that buys.
///
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
/// settled (05 The attributes sidebar), so the shell states them and the sections fill in
/// The placeholders that remain are structural rather than apologetic: the sidebar's inventory and its
/// order are settled (05 The attributes sidebar), so the shell states them and the sections fill in
/// underneath without the composition moving.
///
/// ### The width rule, in one line
@@ -91,6 +93,11 @@ struct CardWindowView: View {
/// it, Preview renders it, and `adopt(diskBody:)` below is where the snapshot gets a say
/// which is exactly the point at which dirty-buffer-wins is decided.
let bodySession: CardBodyEditSession
/// This window's title field `bodySession`'s sibling, over the header's single line rather than
/// the body span (m6-card-body, `CardTitleEditSession`). Read in both modes for `bodySession`'s
/// own reason: a flushed buffer is ahead of the snapshot by a reload, so it is the truer of the
/// two whether the header is showing static text or the editable field.
let titleSession: CardTitleEditSession
/// This window's raw-source outlet. While it is active the two columns are gone entirely see
/// `body`.
let rawSource: CardRawSourceSession
@@ -325,15 +332,7 @@ struct CardWindowView: View {
private var bodyColumn: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
// m6-card-body: the title *field* large and borderless, committing to frontmatter
// on Return or focus loss, clearing to remove the `title` key, Escape abandoning to
// the on-disk title. Read-only here; the placeholder rendering is already final.
Text(card.title.value ?? "Untitled")
.font(.largeTitle)
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) the
// same secondary treatment the face gives it.
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.textSelection(.enabled)
titleRow
if let dateLine {
Text(dateLine)
@@ -368,12 +367,62 @@ struct CardWindowView: View {
.onChange(of: card.body, initial: true) { _, body in
bodySession.adopt(diskBody: body)
}
// The title field's own dirty-buffer-wins, `bodySession`'s exact rule one property over see
// `titleSession`'s doc comment.
.onChange(of: card.title.value, initial: true) { _, title in
titleSession.adopt(diskTitle: title)
}
// **The opening rule, applied once** (05 Mode grammar): a card opens in Preview unless its
// body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
// "once" a later reload that empties the file must not drag a reader into Edit.
.task { bodyPresentation.openIfNeeded(body: card.body) }
}
/// The header's title large and borderless in both readings (m6-card-body). **Edit mode makes it
/// an editable field; Preview keeps it static text** the same surfaces `CardBodySurface` gives
/// the body one column down, over the smaller session one property to the side.
///
/// Both branches read `titleSession.text`, never `card.title.value` directly, for the reason
/// `titleSession`'s doc comment gives: the buffer outranks the snapshot while it is dirty, and
/// outruns it by exactly one reload the instant a save lands, so reading the snapshot in Preview
/// would show a stale title for that reload's length precisely what a card window must never do
/// (05-card-window.md Window).
@ViewBuilder
private var titleRow: some View {
if bodyPresentation.mode == .edit {
TextField(
"Card title",
text: Binding(
get: { titleSession.text },
set: { titleSession.edited($0) }
)
)
.textFieldStyle(.plain)
.lineLimit(1)
.font(.largeTitle)
// Return commits the field rather than exiting Edit mode the title's own session ends
// with the body's (mode exit, window close), not with this key. `.onSubmit` never sees a
// literal newline in the first place: a plain `TextField` treats Return as a submit, which
// is the other half of "no newlines" beside `CardTitleEditSession.edited(_:)`'s paste
// filter.
.onSubmit { titleSession.flush() }
} else {
// Trimmed for the read-only rendering only the field above shows `titleSession.text`
// raw, so a mid-typed run of padding never has its cursor position disturbed
// (`CardTitleEditSession.saveNow()` never rewrites the live buffer either, for the same
// reason). Preview only ever renders while nothing is being typed, so trimming purely for
// display is free of that hazard and is what keeps a title committed down to nothing
// (spaces alone) reading as "Untitled" here rather than as a run of blank space.
let displayed = titleSession.text.trimmingCharacters(in: .whitespacesAndNewlines)
Text(displayed.isEmpty ? "Untitled" : displayed)
.font(.largeTitle)
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) the same
// secondary treatment the face gives it.
.foregroundStyle(displayed.isEmpty ? .secondary : .primary)
.textSelection(.enabled)
}
}
/// "Created date · Modified date · by modified-by", **omitting whichever keys are absent**
/// (05 Composition) the whole line disappears when the card carries none of the three.
///