Files
lanework/Kanban/UI/Board/NewCardTarget.swift
T
rzen b35566e0fe 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
2026-07-27 08:52:24 -04:00

83 lines
4.8 KiB
Swift

/// 04-interactions.md's **⌘N target rule** (settled), as a pure function of the three things it
/// reads — the selection, the last-active lane, and the snapshot (`NewCardTargetTests`).
///
/// The rule verbatim, and each clause's branch below:
///
/// > with a card selected, the new card is created in that card's lane, immediately after it
/// > (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency);
/// > … with nothing selected — or a **tombstoned** selection, which never anchors creation — the
/// > **last-active lane** — the lane that most recently held selection or a creation in this window
/// > session — falling back to the first lane. … **Zero-lane board**: card creation … disable[s] via
/// > menu validation until a lane exists.
///
/// **A pure function rather than a method on the store** for the reason every rule in this codebase
/// that can be one is: the five branches are five lines of test rather than five UI states to drive,
/// and the menu item's `disabled` and its action then read the *same* answer instead of two
/// hand-kept-in-sync conditions.
///
/// ### What it deliberately does not decide
///
/// - **The lane header's new-card button overrides this rule entirely** (11-command-nexus.md ▸
/// Pointer grammar, settled): "the click names its target lane, selection notwithstanding". That
/// call site passes its own lane and never comes here.
/// - **Return on a selected lane** is the same target as this rule's lane branch, but it is reached
/// by grammar rather than by the menu; it also passes its lane directly.
/// - **Multi-selections.** The rule speaks of "a card"/"a lane", singular, and a multi-selection has
/// no "it" to be immediately after. Anything but a sole selection falls through to the
/// last-active lane, which is the same answer an empty selection gets — the honest reading, and
/// the one m5's selection-model card can refine if the design ever grows a plural case.
enum NewCardTarget {
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
/// lane's bottom). Exactly `NewCardPlaceholder`'s two anchoring fields, because that is what
/// this resolves *into*.
struct Resolution: Equatable {
let laneID: ItemID
let anchorCardID: ItemID?
}
/// The target, or `nil` when there is none — **the zero-lane board**, where "New Card,
/// Return-creation, and Paste with a card payload disable via menu validation until a lane
/// exists". `nil` is therefore the menu item's `disabled` condition as well as its refusal, so
/// the two can never disagree.
///
/// - Parameters:
/// - selection: the board's current selection, liveness side included. A `.trashed` selection
/// "never anchors creation" and is treated exactly as an empty one — the settled precedent
/// 04 ▸ Clipboard cites for paste, applied here to its source rule ("a trashed card's live
/// disk-lane never leaks in as 'the selected card's lane'").
/// - lastActiveLaneID: `TransientBoardState.lastActiveLaneID`, already cleared by the reload
/// rule if its lane vanished — but re-checked here anyway, because a caller need not have
/// reloaded since the lane went.
static func resolve(
selection: ItemReferenceSet,
lastActiveLaneID: ItemID?,
snapshot: BoardModel
) -> Resolution? {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard !lanes.isEmpty else { return nil }
if selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first {
for lane in lanes {
// A lane selected: appended at its bottom, Return consistency.
if lane.id == id { return Resolution(laneID: lane.id, anchorCardID: nil) }
// A card selected: its lane, immediately after it — paste-anchor consistency.
if lane.cards.contains(where: { $0.id == id && !$0.isDeleted }) {
return Resolution(laneID: lane.id, anchorCardID: id)
}
}
// The id names nothing the board renders — a selection the next reload will drop.
// Falls through to the last-active lane rather than refusing: the user pressed ⌘N and
// the board has lanes.
}
// Nothing selected, a tombstoned selection, a multi-selection, or a stale one: the lane that
// most recently held selection or a creation, and the first lane when there is no such lane
// (or it has since gone).
if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) {
return Resolution(laneID: lane.id, anchorCardID: nil)
}
return lanes.first.map { Resolution(laneID: $0.id, anchorCardID: nil) }
}
}