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
+162
View File
@@ -0,0 +1,162 @@
import CoreGraphics
import Testing
@testable import Kanban
/// `LaneReorderMath` the lane drag's proposal, as arithmetic.
///
/// The board these numbers describe: `standard = 100`, `gap = 10`, so a 1× slot is 100pt wide, a 2×
/// slot is 210 (two standards plus the interior gap it swallows) and a 3× is 320. The strip's outer
/// margin is one gap, so the first slot starts at x = 10.
private let standard: CGFloat = 100
private let gap: CGFloat = 10
private func proposal(_ units: [Int], dragging index: Int, centre: CGFloat) -> Int {
LaneReorderMath.proposedIndex(
unitCounts: units,
draggedIndex: index,
dragCentreX: centre,
standard: standard,
gap: gap
)
}
@Suite("LaneReorderMath")
struct LaneReorderMathTests {
// MARK: Resting geometry
@Test("A lane's resting centre is its slot's midpoint, gaps and wide lanes counted")
func restingCentres() {
// Four 1× lanes: slots at [10, 110), [120, 220), [230, 330), [340, 440).
let uniform = [1, 1, 1, 1]
#expect(LaneReorderMath.centre(ofLaneAt: 0, unitCounts: uniform, standard: standard, gap: gap) == 60)
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: uniform, standard: standard, gap: gap) == 170)
#expect(LaneReorderMath.centre(ofLaneAt: 3, unitCounts: uniform, standard: standard, gap: gap) == 390)
// A 3× lane in the middle: slots at [10, 110), [120, 440), [450, 550).
let mixed = [1, 3, 1]
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: mixed, standard: standard, gap: gap) == 280)
#expect(LaneReorderMath.centre(ofLaneAt: 2, unitCounts: mixed, standard: standard, gap: gap) == 500)
// An index past the end yields the position the next slot would start at, rather than
// trapping: the drag's lane can vanish between a render and a gesture callback.
#expect(LaneReorderMath.centre(ofLaneAt: 9, unitCounts: mixed, standard: standard, gap: gap) == 560)
}
// MARK: The proposal
@Test("A lane that has not moved proposes its own index")
func restingDragProposesNoChange() {
// Dragging lane 1 of four: with it removed the remaining centres are 60, 170, 280. Its own
// resting centre is 170, which has passed exactly one of them.
#expect(proposal([1, 1, 1, 1], dragging: 1, centre: 170) == 1)
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0)
#expect(proposal([1, 1, 1, 1], dragging: 3, centre: 390) == 3)
}
@Test("The proposal steps once the cursor passes a remaining lane's centre, and not before")
func theThresholdIsTheNeighboursCentre() {
// Dragging lane 0 out of four. Remaining slots are the other three, laid out from x = 10:
// centres 60, 170, 280.
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 59) == 0)
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0, "the boundary itself does not step — strictly past")
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 61) == 1)
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 171) == 2)
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 281) == 3)
}
@Test("A far drag in either direction clamps to the ends")
func farDragsClampToTheEnds() {
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: -5000) == 0)
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: 5000) == 3, "the last index with the lane itself removed")
#expect(proposal([1, 1, 1], dragging: 0, centre: 5000) == 2)
}
@Test("Width-aware: a wide neighbour has to be crossed, not merely touched")
func wideNeighboursDemandRealTravel() {
// Lanes [1, 3, 1] with the 1× at index 0 dragged. The remaining pair is the 3× then the 1×:
// slots [10, 330) and [340, 440), centres 170 and 390.
//
// "No reflow until the cursor reaches where the dragged lane would actually land": at 200 the
// cursor is well inside the wide lane but has passed its centre, so the step is honest; at
// 150 it has not, and proposing a swap there would reorder the board under a cursor still
// sitting over the lane it started left of.
#expect(proposal([1, 3, 1], dragging: 0, centre: 150) == 0)
#expect(proposal([1, 3, 1], dragging: 0, centre: 200) == 1)
#expect(proposal([1, 3, 1], dragging: 0, centre: 400) == 2)
}
@Test("The proposal is monotone in the cursor — it never oscillates")
func theProposalIsMonotone() {
// One threshold per remaining slot, crossed once, is what makes the shadow stable rather
// than jittery (04-interactions.md Drag and drop). Sweeping the whole strip must therefore
// produce a non-decreasing sequence.
let units = [2, 1, 3, 1, 2]
var last = 0
for x in stride(from: CGFloat(-200), through: 1200, by: 1) {
let next = proposal(units, dragging: 2, centre: x)
#expect(next >= last, "the proposal went backwards as the cursor moved right, at x = \(x)")
last = next
}
#expect(last == units.count - 1)
}
@Test("A single-lane board proposes the only index there is")
func singleLaneBoard() {
#expect(proposal([1], dragging: 0, centre: -900) == 0)
#expect(proposal([1], dragging: 0, centre: 900) == 0)
}
@Test("An out-of-range dragged index yields zero rather than trapping")
func vanishedLaneDoesNotTrap() {
// The lane vanished under the drag; the caller's release-with-no-valid-proposal rule cancels
// anyway, so the only contract here is totality.
#expect(proposal([1, 1], dragging: 7, centre: 100) == 0)
#expect(proposal([], dragging: 0, centre: 100) == 0)
}
// MARK: Applying a proposal
@Test("Reordering applies the proposal's own index convention")
func reorderedAppliesTheConvention() {
let lanes = ["a", "b", "c", "d"]
// `to` counts positions with the item already removed, which is what `proposedIndex`
// returns so `to == from` must be the identity.
#expect(LaneReorderMath.reordered(lanes, from: 1, to: 1) == lanes)
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 0) == lanes)
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 1) == ["b", "a", "c", "d"])
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 3) == ["b", "c", "d", "a"])
#expect(LaneReorderMath.reordered(lanes, from: 3, to: 0) == ["d", "a", "b", "c"])
#expect(LaneReorderMath.reordered(lanes, from: 2, to: 1) == ["a", "c", "b", "d"])
}
@Test("Reordering is total: out-of-range indices clamp or pass through")
func reorderedIsTotal() {
let lanes = ["a", "b", "c"]
#expect(LaneReorderMath.reordered(lanes, from: 9, to: 0) == lanes)
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 99) == ["b", "c", "a"])
#expect(LaneReorderMath.reordered(lanes, from: 2, to: -5) == ["c", "a", "b"])
}
@Test("A dragged lane parked over each slot in turn lands exactly there")
func aRoundTripThroughEverySlot() {
// The end-to-end claim the two halves compose into: park the dragged lane on top of a
// sibling's resting centre and the proposal, applied, puts it in that sibling's place.
let units = [1, 2, 1, 3]
let lanes = ["a", "b", "c", "d"]
let from = 0
var remaining = units
remaining.remove(at: from)
for slot in remaining.indices {
let centre = LaneReorderMath.centre(ofLaneAt: slot, unitCounts: remaining, standard: standard, gap: gap)
// A hair past the centre is what "passed it" means; sitting exactly on it holds.
let landed = proposal(units, dragging: from, centre: centre + 1)
#expect(landed == slot + 1)
#expect(LaneReorderMath.reordered(lanes, from: from, to: landed).firstIndex(of: "a") == slot + 1)
}
}
}