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:
@@ -20,6 +20,107 @@ extension FocusedValues {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared validation
|
||||
|
||||
/// The two conditions **every** board-mutating menu item disables on, in one place.
|
||||
///
|
||||
/// - **The read-only lock** (02-architecture.md § The lock's scope): "every mutating command
|
||||
/// disables via menu validation" across every window sharing the store. An item that is going to
|
||||
/// be refused should not look available.
|
||||
/// - **The focused-editor rule** (04-interactions.md ▸ Grammar, settled): "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" and the keyboard belongs to the
|
||||
/// text domain. The one carve-out the design names is Open Card ⌘↩, which stays enabled to commit
|
||||
/// the edit and open the window — it is not a menu item yet (m5), and when it is, it is the one
|
||||
/// item that must *not* read this property.
|
||||
///
|
||||
/// Stated once rather than repeated per item, because the interesting failure mode is an item that
|
||||
/// quietly forgets half of it.
|
||||
extension BoardStore {
|
||||
var acceptsBoardMutations: Bool {
|
||||
!isReadOnly && !isEditingInline
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Creation items
|
||||
|
||||
/// File ▸ New Card (⌘N) and File ▸ New Lane (⇧⌘N) — 11-command-nexus.md's two creation rows.
|
||||
///
|
||||
/// **New Card resolves its target through `NewCardTarget`**, the ⌘N target rule as a pure function,
|
||||
/// and uses the *same* answer for its `disabled` state as for its action: a `nil` resolution is the
|
||||
/// zero-lane board, where "card creation and card paste have no target — New Card, Return-creation,
|
||||
/// and Paste with a card payload disable via menu validation until a lane exists"
|
||||
/// (04-interactions.md ▸ The map). Two derivations of that condition would be two chances to
|
||||
/// disagree.
|
||||
///
|
||||
/// **New Lane is enabled whenever the board accepts writes.** It is the way *out* of a zero-lane
|
||||
/// board — "New Lane (⇧⌘N) is one way in" — so it can have no selection precondition at all. The
|
||||
/// lane it creates is untitled and no editor opens on it; see `BoardStore.createLane`.
|
||||
struct BoardCreationCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
|
||||
var body: some View {
|
||||
Button("New Card") {
|
||||
guard let store, let target = newCardTarget else { return }
|
||||
store.transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
.disabled(newCardTarget == nil)
|
||||
|
||||
Button("New Lane") {
|
||||
store?.createLane()
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: [.shift, .command])
|
||||
.disabled(store?.acceptsBoardMutations != true)
|
||||
}
|
||||
|
||||
/// Where ⌘N would file a card, or `nil` when it cannot — no focused board, a board that refuses
|
||||
/// writes, an inline editor holding the keyboard, or a board with no lanes.
|
||||
private var newCardTarget: NewCardTarget.Resolution? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
return NewCardTarget.resolve(
|
||||
selection: store.selection,
|
||||
lastActiveLaneID: store.transient.lastActiveLaneID,
|
||||
snapshot: store.snapshot
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rename
|
||||
|
||||
/// Board ▸ Rename — no default chord, deliberately (11-command-nexus.md: "— (cards: Return in
|
||||
/// place)"), and remappable like any other item.
|
||||
///
|
||||
/// It "exists for completeness and remapping" for cards, whose real path is Return, and it is a
|
||||
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
|
||||
/// never be renamed at all (04-interactions.md ▸ Selection).
|
||||
///
|
||||
/// Validation is the sole-selected-live-item rule — card or lane, either kind, exactly one. A
|
||||
/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned
|
||||
/// selections" (04 ▸ The trash), which `ItemReferenceSet`'s liveness side answers directly.
|
||||
struct BoardRenameCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
|
||||
var body: some View {
|
||||
Button("Rename") {
|
||||
guard let store, let target = renameTarget else { return }
|
||||
store.transient.beginRename(of: target.id, currentTitle: target.title)
|
||||
}
|
||||
.disabled(renameTarget == nil)
|
||||
}
|
||||
|
||||
private var renameTarget: (id: ItemID, title: String?)? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
|
||||
let item = BoardStore.liveItem(id, in: store.snapshot)
|
||||
else { return nil }
|
||||
return (id: id, title: item.title)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lane width items
|
||||
|
||||
/// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane,
|
||||
@@ -31,9 +132,7 @@ extension FocusedValues {
|
||||
/// **Validation is the sole-selected-lane rule.** Both items are enabled only when the focused
|
||||
/// board's selection resolves to exactly one live lane; a card selection, a multi-selection, a
|
||||
/// trash-side selection and an empty one all disable them. Decrease additionally disables at one
|
||||
/// unit, which is the floor. Nothing selects a lane yet — the lane-chrome card wires the header
|
||||
/// click (04-interactions.md ▸ Selection) — so these validate-disable in today's build, which is
|
||||
/// expected rather than broken.
|
||||
/// unit, which is the floor.
|
||||
struct LaneWidthCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@@ -54,11 +153,10 @@ struct LaneWidthCommands: View {
|
||||
|
||||
/// The sole selected live lane, or `nil` — the whole of these items' validation.
|
||||
///
|
||||
/// A read-only board disables every mutating command (02-architecture.md § The lock's scope), so
|
||||
/// the lock is folded in here rather than left for the write to refuse: an item that is going to
|
||||
/// fail should not look available.
|
||||
/// The lock and the open-editor rule are folded in through `acceptsBoardMutations` rather than
|
||||
/// left for the write to refuse: an item that is going to fail should not look available.
|
||||
private var selectedLane: Lane? {
|
||||
guard let store, !store.isReadOnly else { return nil }
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
|
||||
return store.snapshot.lanes.first { $0.id == id && !$0.isDeleted }
|
||||
|
||||
Reference in New Issue
Block a user