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
+101
View File
@@ -0,0 +1,101 @@
import CoreGraphics
/// The lane-reorder drag's geometry, as pure arithmetic no view, no session, no snapshot
/// (`LaneReorderMathTests`). `LaneLayoutMath`'s sibling: that one owns the resting layout and the
/// right-edge resize, this one owns "where would the lane land if I let go now".
///
/// **Geometry-based, so the proposal is stable rather than jittery** (04-interactions.md Drag and
/// drop): the answer is a function of analytically computed resting positions and one pointer
/// coordinate never of measured mid-flight frames, which are garbage precisely during the reflow
/// they trigger (03-board-ui.md § Motion, "Motion never feeds back into logic").
///
/// **Width-aware by construction.** The design asks for "no reflow until the cursor reaches where
/// the dragged lane would actually land"; comparing against each remaining lane's *centre* is
/// exactly that a 3× lane's centre is three units along, so the drag has to travel most of that
/// lane's width before the board proposes stepping past it, and a 1× lane yields quickly.
///
/// ### What this deliberately is not
///
/// The full drag model the shadow's hold-until-a-new-candidate rule, multi-drag's N contiguous
/// shadows, cross-board locality with its copy/move badge, and the mid-drag re-grounding rules is
/// **m5's drag card**, which replaces this file's callers with the real `DropSlot`
/// (02-architecture.md § Layering Components). What is here is the within-board single-lane case
/// and nothing else, deliberately small enough to be obviously correct.
enum LaneReorderMath {
/// Where the dragged lane would land: an index into the ordered live lanes **with the dragged
/// lane removed**, so the result is in `0...(unitCounts.count - 1)` and `draggedIndex` itself
/// means "back where it started".
///
/// - Parameters:
/// - unitCounts: the ordered live lanes' display units (`LaneLayoutMath.displayUnits`), the
/// board as it currently is recomputed against each snapshot rather than frozen at drag
/// start, so a foreign lane add or tombstone mid-drag just moves the zones and the next
/// proposal targets the board as it now is (04 Drag and drop, rule 1).
/// - draggedIndex: the dragged lane's position in `unitCounts`.
/// - dragCentreX: the dragged lane's centre under the cursor, in strip coordinates (0 at the
/// strip's leading edge, outer margin included).
/// - standard: the 1× lane width (`LaneLayoutMath.standardWidth`).
/// - gap: the inter-lane gap, which is also the strip's outer margin.
///
/// Out-of-range `draggedIndex` yields `0` rather than trapping: the lane vanished under the
/// drag, and the caller's release-with-no-valid-proposal rule cancels anyway.
static func proposedIndex(
unitCounts: [Int],
draggedIndex: Int,
dragCentreX: CGFloat,
standard: CGFloat,
gap: CGFloat
) -> Int {
guard unitCounts.indices.contains(draggedIndex) else { return 0 }
var remaining = unitCounts
remaining.remove(at: draggedIndex)
// The remaining lanes' resting centres, left to right, in the layout they would have with
// the dragged lane gone which is the layout the siblings are already showing.
// Monotonically increasing, so "how many centres has the cursor passed" is both the answer
// and the reason it never oscillates: one threshold per slot, crossed once.
var index = 0
var x = gap
for units in remaining {
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
guard dragCentreX > x + width / 2 else { break }
index += 1
x += width + gap
}
return index
}
/// The resting centre of the lane at `index` in a strip of `unitCounts`, in the same strip
/// coordinates `proposedIndex` reads.
///
/// Two callers, and they are the two halves of the drag: the gesture freezes this at drag start
/// as the origin its translation is measured from (the *physical pointer* being the only live
/// input 03 § Motion), and the view offsets the travelling lane from the centre it would rest
/// at under the current proposal, which is what makes the replica track the cursor while the
/// siblings sit in their would-be order.
static func centre(ofLaneAt index: Int, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
var x = gap
for (position, units) in unitCounts.enumerated() {
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
if position == index { return x + width / 2 }
x += width + gap
}
return x
}
/// `unitCounts` (or any per-lane values) with the item at `from` moved to `to`, where `to` is
/// counted **with the item already removed** the ordering `proposedIndex` returns, applied.
///
/// Shared by the view (which reorders the lanes it lays out, so the siblings show the would-be
/// order) and by the drag's own centre arithmetic, so the two can never disagree about what the
/// proposal means.
static func reordered<T>(_ items: [T], from: Int, to: Int) -> [T] {
guard items.indices.contains(from) else { return items }
var result = items
let item = result.remove(at: from)
result.insert(item, at: min(max(0, to), result.count))
return result
}
}