Discard empty text cards when editing is abandoned - #124
Conversation
📝 WalkthroughWalkthrough
ChangesBoard state management
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant BoardCardView
participant BoardViewModel
participant BoardCardLayer
participant BoardExporter
BoardCardView->>BoardViewModel: report live fitted frame
BoardViewModel->>BoardCardLayer: provide rendering snapshot
BoardCardLayer->>BoardViewModel: recalculate bound-arrow geometry
BoardExporter->>BoardViewModel: request rendering snapshot
BoardViewModel-->>BoardExporter: return snapshot cards
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/ComposerApp/Views/BoardViewModel.swift`:
- Around line 277-281: Update discardIfAbandoned(_:) to use a cleanup-specific
deletion path that can leave the board empty instead of calling delete(id),
which recreates CardState.firstCard(). Preserve delete(id)’s existing
starter-card recreation behavior for ordinary user deletions, and ensure
flushSave() does not persist a replacement card when an abandoned text card is
removed.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: ba4d42ca-fc36-4ddf-b14b-bdaba5ed550a
📒 Files selected for processing (1)
Sources/ComposerApp/Views/BoardViewModel.swift
There was a problem hiding this comment.
All reported issues were addressed across 1 file
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Clicking away from a freshly placed text card during its mount/focus delay never fired the editor's focus-loss callback (resignFocus no-ops when the editor never took first responder), so the empty card survived as a ghost placeholder. Board switches and teardown likewise persisted an in-flight empty card via flushSave. Extract the empty-text-card discard into discardIfAbandoned(_:), call it from both endEditing and stopEditing, and have flushSave end any in-flight edit before snapshotting. Non-text elements are kept; deletion stays undoable through the shared delete path. Fixes #107
d82c74c to
4fb680f
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
Sources/ComposerApp/Views/BoardViewModel.swift (1)
277-281: 🗄️ Data Integrity & Integration | 🟠 MajorDo not use
delete(id)for abandonment cleanup.When the abandoned card is the last card,
delete(id)recreatesCardState.firstCard()in Lines 1558-1564.flushSave()then snapshots and commits that replacement, so abandoning a blank text card still persists a blank text card.Use a cleanup-specific removal path that does not recreate the starter card. Preserve undo registration, interaction cleanup,
refreshBoundArrows(), and the existing starter-card behavior for ordinary user deletions. Add a regression test for a board containing only an abandoned text card.Also applies to: 365-368
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/ComposerApp/Views/BoardViewModel.swift` around lines 277 - 281, The abandonment cleanup in discardIfAbandoned must not call delete(id), because deleting the sole card recreates CardState.firstCard() and persists it. Add or reuse a cleanup-specific removal path that removes the abandoned card without creating a starter card while preserving undo registration, interaction cleanup, and refreshBoundArrows(); keep delete(id)’s existing starter-card behavior for ordinary deletions, and add a regression test for a board containing only an abandoned text card.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CHANGELOG.md`:
- Around line 14-15: Insert a blank line between the “### Fixed” heading and its
first list item in the changelog.
---
Duplicate comments:
In `@Sources/ComposerApp/Views/BoardViewModel.swift`:
- Around line 277-281: The abandonment cleanup in discardIfAbandoned must not
call delete(id), because deleting the sole card recreates CardState.firstCard()
and persists it. Add or reuse a cleanup-specific removal path that removes the
abandoned card without creating a starter card while preserving undo
registration, interaction cleanup, and refreshBoundArrows(); keep delete(id)’s
existing starter-card behavior for ordinary deletions, and add a regression test
for a board containing only an abandoned text card.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 217e3bdf-9e6f-4e1d-b37a-9985972e4f44
📒 Files selected for processing (2)
CHANGELOG.mdSources/ComposerApp/Views/BoardViewModel.swift
# Conflicts: # CHANGELOG.md
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Sources/ComposerApp/Views/BoardViewModel.swift (1)
2165-2180: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoffBuild an id lookup once per refresh pass.
updateBoundArrowGeometryresolves each binding withcards.first { $0.id == id }.refreshBoundArrows(in:)calls it for every bound arrow, so the pass is O(arrows × cards).
renderingSnapshotnow runs this same pass whenever a live text frame is applied. The save debounce fires during typing, so the scan repeats on the main actor for every mid-edit save on a large board.Pass a precomputed
[UUID: CGRect]frame map (or[UUID: Int]index map) into the geometry helper.♻️ Proposed refactor sketch
private static func refreshBoundArrows(in cards: inout [CardState]) { - let existing = Set(cards.map(\.id)) + var framesByID: [UUID: CGRect] = [:] + framesByID.reserveCapacity(cards.count) + for card in cards { framesByID[card.id] = card.frame } for i in cards.indices where cards[i].elementKind == .arrow { - if let start = cards[i].startBindingID, !existing.contains(start) { + if let start = cards[i].startBindingID, framesByID[start] == nil { cards[i].startBindingID = nil cards[i].startBindingAnchor = nil } - if let end = cards[i].endBindingID, !existing.contains(end) { + if let end = cards[i].endBindingID, framesByID[end] == nil { cards[i].endBindingID = nil cards[i].endBindingAnchor = nil } if cards[i].startBindingID != nil || cards[i].endBindingID != nil { - updateBoundArrowGeometry(at: i, in: &cards) + updateBoundArrowGeometry(at: i, in: &cards, frames: framesByID) } } }
updateBoundArrowGeometrythen readsframes[id]instead of scanning, and keeps aframes:default ofnilfor the single-arrow callers inbindArrowIfPossibleandconnectCards.Also applies to: 2223-2242
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/ComposerApp/Views/BoardViewModel.swift` around lines 2165 - 2180, Update refreshBoundArrows and updateBoundArrowGeometry to build one UUID-to-card frame or index lookup per refresh pass and reuse it for every bound arrow instead of scanning cards with first. Preserve a default nil lookup for single-arrow callers such as bindArrowIfPossible and connectCards, while ensuring geometry resolution uses the precomputed map when provided.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/ComposerApp/Views/BoardCardView.swift`:
- Line 78: Update the editing branch in BoardCardView to preserve the current
card.frame origin while applying only the size from liveTextFrame. Do not return
liveTextFrame directly; construct the effective frame with card.frame’s origin
and liveTextFrame’s live size so it matches renderingSnapshot when the card
moves during editing.
In `@Sources/ComposerApp/Views/ComposerCanvas.swift`:
- Around line 1570-1586: Update equation-card creation and the transition
handlers gotoOlder, gotoNewer, newBoard, and pickBoard so equation editing is
established synchronously before any navigation or board-selection flush can
occur. If delayed activation must remain, make the transition cleanup invoked by
board.flushSave remove the still-blank equation rather than relying solely on
EditingStage.
In `@Tests/ComposerAppTests/BoardPersistenceTests.swift`:
- Around line 7-18: Resolve the empty-board contract in DumpStore.cards(for:):
after flushSave() persists an empty snapshot, ensure reloading currentCards
remains empty rather than synthesizing CardState.firstCard(text: dump.text).
Keep the test’s empty-board assertions intact and update the cards(for:) logic
to distinguish genuinely empty boards from boards requiring the initial card.
In `@Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift`:
- Around line 26-30: Update the revision assertions in the test around
board.insertText and board.delete: capture board.boardTextContext.revision
immediately after the insertion assertion, then assert deletion advances beyond
that captured post-insert revision instead of comparing again with
afterDefinition.
- Around line 118-122: Update the undo assertion in the board render-budget test
to fetch the card again from board.cards after board.undo(), then pass that
refreshed card to plainText(for:). Preserve the existing expectation that the
restored text is not "active", while avoiding the stale activeCard snapshot
captured before setText.
---
Outside diff comments:
In `@Sources/ComposerApp/Views/BoardViewModel.swift`:
- Around line 2165-2180: Update refreshBoundArrows and updateBoundArrowGeometry
to build one UUID-to-card frame or index lookup per refresh pass and reuse it
for every bound arrow instead of scanning cards with first. Preserve a default
nil lookup for single-arrow callers such as bindArrowIfPossible and
connectCards, while ensuring geometry resolution uses the precomputed map when
provided.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 2f769019-374a-4fd0-8be0-5a6bc3aeb908
📒 Files selected for processing (11)
CHANGELOG.mdSources/ComposerApp/Services/BoardExporter.swiftSources/ComposerApp/Services/CanvasBridge.swiftSources/ComposerApp/Views/BoardCardView.swiftSources/ComposerApp/Views/BoardViewModel.swiftSources/ComposerApp/Views/ComposerCanvas.swiftSources/ComposerApp/Views/EditingStage.swiftTests/ComposerAppTests/ArrowBindingTests.swiftTests/ComposerAppTests/BoardPersistenceTests.swiftTests/ComposerAppTests/BoardViewModelRenderBudgetTests.swiftTests/ComposerAppTests/TextHugTests.swift
There was a problem hiding this comment.
Caution
Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Sources/ComposerApp/Views/BoardViewModel.swift (1)
2165-2180: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoffBuild an id lookup once per refresh pass.
updateBoundArrowGeometryresolves each binding withcards.first { $0.id == id }.refreshBoundArrows(in:)calls it for every bound arrow, so the pass is O(arrows × cards).
renderingSnapshotnow runs this same pass whenever a live text frame is applied. The save debounce fires during typing, so the scan repeats on the main actor for every mid-edit save on a large board.Pass a precomputed
[UUID: CGRect]frame map (or[UUID: Int]index map) into the geometry helper.♻️ Proposed refactor sketch
private static func refreshBoundArrows(in cards: inout [CardState]) { - let existing = Set(cards.map(\.id)) + var framesByID: [UUID: CGRect] = [:] + framesByID.reserveCapacity(cards.count) + for card in cards { framesByID[card.id] = card.frame } for i in cards.indices where cards[i].elementKind == .arrow { - if let start = cards[i].startBindingID, !existing.contains(start) { + if let start = cards[i].startBindingID, framesByID[start] == nil { cards[i].startBindingID = nil cards[i].startBindingAnchor = nil } - if let end = cards[i].endBindingID, !existing.contains(end) { + if let end = cards[i].endBindingID, framesByID[end] == nil { cards[i].endBindingID = nil cards[i].endBindingAnchor = nil } if cards[i].startBindingID != nil || cards[i].endBindingID != nil { - updateBoundArrowGeometry(at: i, in: &cards) + updateBoundArrowGeometry(at: i, in: &cards, frames: framesByID) } } }
updateBoundArrowGeometrythen readsframes[id]instead of scanning, and keeps aframes:default ofnilfor the single-arrow callers inbindArrowIfPossibleandconnectCards.Also applies to: 2223-2242
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/ComposerApp/Views/BoardViewModel.swift` around lines 2165 - 2180, Update refreshBoundArrows and updateBoundArrowGeometry to build one UUID-to-card frame or index lookup per refresh pass and reuse it for every bound arrow instead of scanning cards with first. Preserve a default nil lookup for single-arrow callers such as bindArrowIfPossible and connectCards, while ensuring geometry resolution uses the precomputed map when provided.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/ComposerApp/Views/BoardCardView.swift`:
- Line 78: Update the editing branch in BoardCardView to preserve the current
card.frame origin while applying only the size from liveTextFrame. Do not return
liveTextFrame directly; construct the effective frame with card.frame’s origin
and liveTextFrame’s live size so it matches renderingSnapshot when the card
moves during editing.
In `@Sources/ComposerApp/Views/ComposerCanvas.swift`:
- Around line 1570-1586: Update equation-card creation and the transition
handlers gotoOlder, gotoNewer, newBoard, and pickBoard so equation editing is
established synchronously before any navigation or board-selection flush can
occur. If delayed activation must remain, make the transition cleanup invoked by
board.flushSave remove the still-blank equation rather than relying solely on
EditingStage.
In `@Tests/ComposerAppTests/BoardPersistenceTests.swift`:
- Around line 7-18: Resolve the empty-board contract in DumpStore.cards(for:):
after flushSave() persists an empty snapshot, ensure reloading currentCards
remains empty rather than synthesizing CardState.firstCard(text: dump.text).
Keep the test’s empty-board assertions intact and update the cards(for:) logic
to distinguish genuinely empty boards from boards requiring the initial card.
In `@Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift`:
- Around line 26-30: Update the revision assertions in the test around
board.insertText and board.delete: capture board.boardTextContext.revision
immediately after the insertion assertion, then assert deletion advances beyond
that captured post-insert revision instead of comparing again with
afterDefinition.
- Around line 118-122: Update the undo assertion in the board render-budget test
to fetch the card again from board.cards after board.undo(), then pass that
refreshed card to plainText(for:). Preserve the existing expectation that the
restored text is not "active", while avoiding the stale activeCard snapshot
captured before setText.
---
Outside diff comments:
In `@Sources/ComposerApp/Views/BoardViewModel.swift`:
- Around line 2165-2180: Update refreshBoundArrows and updateBoundArrowGeometry
to build one UUID-to-card frame or index lookup per refresh pass and reuse it
for every bound arrow instead of scanning cards with first. Preserve a default
nil lookup for single-arrow callers such as bindArrowIfPossible and
connectCards, while ensuring geometry resolution uses the precomputed map when
provided.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 2f769019-374a-4fd0-8be0-5a6bc3aeb908
📒 Files selected for processing (11)
CHANGELOG.mdSources/ComposerApp/Services/BoardExporter.swiftSources/ComposerApp/Services/CanvasBridge.swiftSources/ComposerApp/Views/BoardCardView.swiftSources/ComposerApp/Views/BoardViewModel.swiftSources/ComposerApp/Views/ComposerCanvas.swiftSources/ComposerApp/Views/EditingStage.swiftTests/ComposerAppTests/ArrowBindingTests.swiftTests/ComposerAppTests/BoardPersistenceTests.swiftTests/ComposerAppTests/BoardViewModelRenderBudgetTests.swiftTests/ComposerAppTests/TextHugTests.swift
🛑 Comments failed to post (3)
Sources/ComposerApp/Views/BoardCardView.swift (1)
78-78: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Use only the live size, not the live origin.
liveTextFramecarries the origin captured whenfitTextEditinglast ran.renderingSnapshotdeliberately keeps the committed origin and applies only the live size. The view takes the whole rect, so the two paths disagree.If the card moves while the edit stays active, the view renders it at the stale origin until the next editor layout callback. Apply the live size over the current
card.frameorigin to match the model.🐛 Proposed fix
- if isEditing, let liveTextFrame { return liveTextFrame } + if isEditing, let liveTextFrame { + return CGRect(origin: card.frame.origin, size: liveTextFrame.size) + }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.if isEditing, let liveTextFrame { return CGRect(origin: card.frame.origin, size: liveTextFrame.size) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/ComposerApp/Views/BoardCardView.swift` at line 78, Update the editing branch in BoardCardView to preserve the current card.frame origin while applying only the size from liveTextFrame. Do not return liveTextFrame directly; construct the effective frame with card.frame’s origin and liveTextFrame’s live size so it matches renderingSnapshot when the card moves during editing.Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift (2)
26-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Tighten the delete-invalidation assertion.
Line 28 already proved the revision exceeds
afterDefinition. Line 30 repeats the same comparison, so it passes even ifdeletenever invalidates the context. Capture the revision after the insert and compare against that value.💚 Proposed fix
let afterDefinition = board.boardTextContext.revision let inserted = board.insertText("$name", at: .zero) XCTAssertGreaterThan(board.boardTextContext.revision, afterDefinition) + let afterInsert = board.boardTextContext.revision board.delete(inserted) - XCTAssertGreaterThan(board.boardTextContext.revision, afterDefinition) + XCTAssertGreaterThan(board.boardTextContext.revision, afterInsert)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.let afterDefinition = board.boardTextContext.revision let inserted = board.insertText("$name", at: .zero) XCTAssertGreaterThan(board.boardTextContext.revision, afterDefinition) let afterInsert = board.boardTextContext.revision board.delete(inserted) XCTAssertGreaterThan(board.boardTextContext.revision, afterInsert)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift` around lines 26 - 30, Update the revision assertions in the test around board.insertText and board.delete: capture board.boardTextContext.revision immediately after the insertion assertion, then assert deletion advances beyond that captured post-insert revision instead of comparing again with afterDefinition.
118-122: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The final assertion cannot fail.
activeCardis aCardStatevalue captured beforesetText.plainText(for:)resolves a text card throughinteractions[card.id]?.plainText ?? card.text.restoreclearsinteractions, so afterundo()the lookup misses and the call returnsactiveCard.text, the stale pre-setTextcopy. That value is never"active", so the assertion passes even if the active board lost its undo stack.Re-read the card from
board.cardsafter the undo.💚 Proposed fix
// The current board owns its live stacks, not an evictable cache entry. - let activeCard = try XCTUnwrap(board.cards.first) - board.setText(activeCard.id, "active") + let activeID = try XCTUnwrap(board.cards.first?.id) + board.setText(activeID, "active") + XCTAssertEqual(board.plainText(for: try XCTUnwrap(board.cards.first { $0.id == activeID })), "active") board.undo() - XCTAssertNotEqual(board.plainText(for: activeCard), "active") + XCTAssertNotEqual( + board.plainText(for: try XCTUnwrap(board.cards.first { $0.id == activeID })), "active")📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.// The current board owns its live stacks, not an evictable cache entry. let activeID = try XCTUnwrap(board.cards.first?.id) board.setText(activeID, "active") XCTAssertEqual(board.plainText(for: try XCTUnwrap(board.cards.first { $0.id == activeID })), "active") board.undo() XCTAssertNotEqual( board.plainText(for: try XCTUnwrap(board.cards.first { $0.id == activeID })), "active")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift` around lines 118 - 122, Update the undo assertion in the board render-budget test to fetch the card again from board.cards after board.undo(), then pass that refreshed card to plainText(for:). Preserve the existing expectation that the restored text is not "active", while avoiding the stale activeCard snapshot captured before setText.
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
# Conflicts: # Sources/ComposerApp/Views/ComposerCanvas.swift
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Fixes #107.
Problem
Clicking an empty area creates a text card with the "Brain dump…" placeholder; abandoning it left a ghost card on the board. The discard logic existed only in
endEditing(the editor's focus-loss callback), but two abandonment paths never reached it:stopEditing()(click empty canvas, click another card, marquee select) relied onresignFocus()→ focus-loss →endEditing, butresignFocus()no-ops when the editor never actually became first responder — exactly the case for a freshly placed card clicked away from during its 0.06s mount/focus delay.flushSave) persisted the in-flight empty card before anything ended the edit.Fix
All in
BoardViewModel.swift:discardIfAbandoned(_:)and call it from bothendEditingandstopEditing. Text kind only — empty shapes/stickies remain intentional placements. Deletion goes through the shareddeletepath, so it's undoable and bound arrows refresh.flushSave()ends any in-flight edit first, so switching boards or quitting mid-edit discards the ghost instead of saving it.Verification
swift buildclean, all 216 tests pass.Summary
endEditingandstopEditing.flushSave()creates a persistence snapshot.swift buildpasses, with 216 tests passing.Note
Discard empty text cards when editing is abandoned or board navigation occurs
endEditing,stopEditing, orflushSave(abandoningActiveEdit: true)) without creating a starter card, including cases where the editor never gained focus.flushSave(abandoningActiveEdit: true)viacheckpointBeforeLeavingCurrentBoard, ensuring blank cards are pruned before the board is persisted.beginEditingguards against resurrecting selection state for a card that no longer exists after a discard.stopEditingand no longer trigger starter-card creation when they empty the board.Macroscope summarized 1fd1e2b.