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
+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.