Build the full-visibility lane layout

The lane strip replaces the placeholder board: window width divides
across the lanes' width units (no horizontal scroll, no minimum width,
degenerate compression accepted), a lane of n units flowing its cards
into n round-robin masonry columns via a measurement-cached Layout.
Width has two deliberately opposite controls, both landing here: the
right-edge drag (ported verbatim from the pathfinder's ColumnResize)
freezes the 1x standard at drag start, snaps between integer widths
with the asymmetric shadow-leads tick and 10pt re-entry, grows the
window one standard width per snap so siblings keep their exact
pixels, and rubber-bands at the screen's visible frame — uncapped
otherwise; the Increase/Decrease Lane Width items (new Board menu,
Cmd-Opt-arrows) are the stepper's keyboard face and re-divide the
existing window width instead, never touching the window. Width
changes write through the new .resize WriteOperation ("Couldn't
resize…" in the banner vocabulary, which grows with the surfaces by
design); malformed width values render as one unit and stay untouched
on disk. 27 new tests port the pathfinder's resize-math suite onto
the uncapped range and pin the write path's fidelity.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 08:16:14 -04:00
parent fccdf56cf4
commit ff3ba298f0
15 changed files with 1427 additions and 56 deletions
+9 -55
View File
@@ -11,9 +11,9 @@ import os
///
/// Almost everything here is about beginning and ending: acquiring the shared store, stamping the
/// registry, holding the board's security-scoped access, remembering the window's frame, and running
/// the close flush before any of it is let go. The board *itself* lanes, cards, drag, the whole of
/// 03-board-ui.md is a placeholder below, deliberately throwaway and confined to one small view so
/// the milestone that builds the real thing replaces exactly that and nothing else.
/// the close flush before any of it is let go. The board *itself* the lane strip and everything in
/// it is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out
/// of the way.
///
/// ### Failure opens welcome
///
@@ -63,8 +63,13 @@ struct BoardWindowHost: View {
case let .open(store):
VStack(spacing: 0) {
BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) }
PlaceholderBoardView(store: store)
// The window is handed to the board as a closure, not a value: `WindowAccessor`
// attaches after this body first runs, and the lane-resize drag needs the *live*
// window to grow at its right edge (03-board-ui.md § Lane).
BoardView(store: store, window: { windowController.window })
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`).
.focusedSceneValue(\.boardStore, store)
}
}
@@ -162,54 +167,3 @@ struct BoardWindowHost: View {
}
}
}
// MARK: - The placeholder board
/// Stand-in for the board (03-board-ui.md): the title and a list of lane titles, and nothing else.
///
/// **Deliberately throwaway.** The next milestone builds the full-visibility lane layout masonry
/// cards, drag, the trash quasi-lane, the toolbar and replaces this view wholesale. It is kept in
/// one small view with no state of its own so that replacement is a deletion rather than an
/// untangling. What it does prove today is that the window renders the *store's* snapshot: an
/// external edit shows up here through the watcher like it will in the real thing.
private struct PlaceholderBoardView: View {
let store: BoardStore
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(AppModel.displayName(of: store))
.font(.largeTitle)
if liveLanes.isEmpty {
Text("No lanes yet")
.foregroundStyle(.secondary)
} else {
ForEach(liveLanes) { lane in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(lane.title.value ?? "Untitled")
.font(.headline)
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
Text("\(liveCardCount(in: lane))")
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
}
}
/// Tombstoned lanes render nowhere on the board (03-board-ui.md collapses them into the trash
/// quasi-lane) true of the placeholder as much as of the real layout.
private var liveLanes: [Lane] {
store.snapshot.lanes.filter { !$0.isDeleted }
}
private func liveCardCount(in lane: Lane) -> Int {
lane.cards.filter { !$0.isDeleted }.count
}
}
+8 -1
View File
@@ -99,7 +99,7 @@ struct KanbanApp: App {
}
}
/// The two menu items this milestone owns.
/// The menu items the app has built so far.
///
/// **The titles are API** (04-interactions.md Configurable bindings): macOS's App Shortcuts
/// mechanism remaps menu items *by title*, so these strings are the keys a user's custom binding
@@ -114,6 +114,13 @@ struct KanbanApp: App {
.keyboardShortcut("o", modifiers: .command)
}
// The Board menu (11-command-nexus.md). Its items act on the frontmost board window, which
// they reach through the focus system rather than through the app model see
// `LaneWidthCommands`, which also owns their validation.
CommandMenu("Board") {
LaneWidthCommands()
}
CommandGroup(after: .windowList) {
// No default chord " (no default)" in the Nexus is deliberate, not a gap; it remaps
// like any other item.
+2
View File
@@ -447,6 +447,8 @@ public final class BannerCenter {
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
case let .style(title):
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
case let .resize(title):
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
case let .importAttachment(filename):
"Couldn't import '\(filename)'"
case .listAttachments:
+40
View File
@@ -605,6 +605,46 @@ public final class BoardStore {
}
}
// MARK: - Lane width
/// Writes a lane's width the one commit point both width mechanisms share (03-board-ui.md §
/// Lane): the right-edge drag's release and the stepper's / both land here, and they differ
/// only in what they did to the *window* on the way (the drag grew it, the stepper did not).
///
/// **The value written is an integer, replacing whatever was there.** `width` is a lenient field
/// on the read side missing, malformed, zero and negative all render as one unit
/// (`LaneLayoutMath.displayUnits`) with the author's bytes left alone but an explicit width
/// change is the user overwriting that value, so the Writer puts a plain integer in its place
/// (01-storage-format.md § Frontmatter).
///
/// Three ways this does nothing, all deliberate: a count below 1 clamps to 1 (a lane spans at
/// least one unit), an id that is not in the snapshot is ignored (the lane vanished under the
/// gesture the reload that removed it is the authority), and a count already equal to what the
/// lane displays writes nothing (a drag that ends where it started must not stamp `modified` or
/// mint a git commit).
///
/// Failures are already the banner's: `performWrite` posts every `BoardWriteError` before it
/// rethrows, so the rethrow is swallowed here rather than propagated to a gesture that has no
/// second thing to do about it. The lane stays at its old width, which is the truth nothing was
/// written.
public func setLaneWidth(_ id: ItemID, units: Int) {
let clamped = max(1, units)
guard let lane = snapshot.lanes.first(where: { $0.id == id }),
LaneLayoutMath.displayUnits(of: lane) != clamped
else { return }
let folder = rootURL.appendingPathComponent(id.rawValue)
// The closure's signature is spelled out because of `try?`: with the error discarded at the
// call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any
// Error`, which `performWrite` will not take. Same wart as the value-returning call sites
// `performWrite`'s doc comment records, arriving from the other direction.
try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in
document.set(FrontmatterKeys.width, to: .int(clamped))
}
}
}
// MARK: - Selection (delegated)
// The three thin pass-throughs to `transient`, and the only ones.
+3
View File
@@ -1192,6 +1192,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case restore(title: String?)
case purge(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
case resize(title: String?) // a lane's `width` the edge drag and the stepper alike (03-board-ui.md § Lane)
case importAttachment(filename: String)
case listAttachments
case renumberChildren // order-maintenance sweep (compaction)
@@ -1216,6 +1217,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .restore: .restore(title: title)
case .purge: .purge(title: title)
case .style: .style(title: title)
case .resize: .resize(title: title)
}
}
@@ -1237,6 +1239,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .restore(title): Self.phrase("restore", title)
case let .purge(title): Self.phrase("purge", title)
case let .style(title): Self.phrase("style", title)
case let .resize(title): Self.phrase("resize", title)
case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments"
case .renumberChildren: "renumber children"
+78
View File
@@ -0,0 +1,78 @@
import SwiftUI
// MARK: - The focused board
/// The frontmost board window's store, published into the focus system by `BoardWindowHost` so menu
/// items can act on "the board in front" without the app model keeping a which-window-is-key
/// register of its own.
///
/// `focusedSceneValue` rather than `focusedValue`: the value is the *window's*, not any particular
/// control's, so it stays available whatever inside the board has keyboard focus which is what a
/// menu item validating against the selection needs.
struct FocusedBoardStoreKey: FocusedValueKey {
typealias Value = BoardStore
}
extension FocusedValues {
var boardStore: BoardStore? {
get { self[FocusedBoardStoreKey.self] }
set { self[FocusedBoardStoreKey.self] = newValue }
}
}
// MARK: - Lane width items
/// Increase / Decrease Lane Width **the width stepper's keyboard face** (03-board-ui.md § Lane,
/// 11-command-nexus.md), and therefore the *re-divide* mechanism: each step re-divides the existing
/// window width across the new unit total, compressing the siblings. They never touch the window's
/// size window-growing behaviour belongs to the right-edge drag alone and they are uncapped, so
/// widths beyond what the screen can fit stay reachable here even though the drag hard-stops.
///
/// **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.
struct LaneWidthCommands: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Increase Lane Width") {
step(by: 1)
}
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
.disabled(selectedLane == nil)
Button("Decrease Lane Width") {
step(by: -1)
}
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
.disabled(!canDecrease)
}
/// 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.
private var selectedLane: Lane? {
guard let store, !store.isReadOnly 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 }
}
/// A one-unit lane cannot shrink: `width` is 1 (03-board-ui.md § Lane), and an item whose only
/// outcome is a no-op reads better disabled than dead.
private var canDecrease: Bool {
guard let lane = selectedLane else { return false }
return LaneLayoutMath.displayUnits(of: lane) > 1
}
private func step(by delta: Int) {
guard let store, let lane = selectedLane else { return }
store.setLaneWidth(lane.id, units: LaneLayoutMath.displayUnits(of: lane) + delta)
}
}
+140
View File
@@ -0,0 +1,140 @@
import AppKit
import SwiftUI
/// The lane strip the board itself (03-board-ui.md § Layout full visibility).
///
/// **Every lane is always on screen.** There is no horizontal scroll and no minimum lane width: the
/// window's width divides across the lanes' width units, a lane of n units taking n whole units of
/// that division, and resizing the window is the width control. A board with more units than the
/// window comfortably fits compresses every lane; that degenerate case is accepted, not floored
/// (the remedy is the user's fewer units or a bigger window).
///
/// Two mechanisms change a lane's width and they are deliberately opposites (03-board-ui.md § Lane):
/// the **right-edge drag** grows or shrinks the *window* one standard width per snap so the other
/// lanes keep their exact pixels (`LaneResizeSession`), while the **stepper** and its /
/// keyboard face re-divides the existing window width across the new unit total, compressing the
/// siblings and never touching the window (`BoardStore.setLaneWidth`).
///
/// ### What is deliberately not here yet
///
/// Selection, drag and drop, the trash quasi-lane, the toolbar, search, styling and the lane context
/// menu all belong to later milestone cards. This view is the layout and the resize interaction, and
/// the chrome inside `LaneView` is a placeholder those cards replace.
struct BoardView: View {
let store: BoardStore
/// How the resize session reaches the host window it grows and shrinks. Injected by
/// `BoardWindowHost`, which owns the window controller; a closure because the window attaches
/// after the first body evaluation.
let window: @MainActor () -> NSWindow?
/// One resize at a time, per window. `@State` so it lives exactly as long as this board window's
/// view does, which is the interaction's whole lifetime.
@State private var resize = LaneResizeSession()
/// The inter-lane gap, and the strip's outer margin one number, because the standard-width
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
private let spacing: CGFloat = 12
var body: some View {
GeometryReader { viewport in
let lanes = liveLanes
// During a resize session the standard is FROZEN at its drag-start value: the window is
// animating mid-resize, so deriving the standard from the live viewport width would feed
// that animation back into every lane and pulse the whole strip. The window is sized on
// each tick so this frozen value equals what the viewport formula yields once the
// session ends the handoff is seamless (see `LaneResizeSession`).
let standard = resize.isActive
? resize.standard
: LaneLayoutMath.standardWidth(
stripWidth: viewport.size.width,
totalUnits: LaneLayoutMath.totalUnits(of: lanes),
gap: spacing)
HStack(alignment: .top, spacing: spacing) {
ForEach(lanes) { lane in
laneSlot(lane, standard: standard)
}
}
.padding(spacing)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
/// One lane's strip slot, plus its trailing grab strip.
///
/// Normally a plain `LaneView` sized to its unit count's slot width (a wide lane swallows the
/// interior gaps it spans). While THIS lane is being resized the slot holds two layers, kept
/// structurally stable so the `LaneView` never loses identity its scroll position, its
/// masonry cache across the drag:
///
/// a shadow at the SNAPPED slot width, full strip height, behind the lane the resting
/// footprint the siblings and the window are already aligned to;
/// the live `LaneView` in front at `liveWidth`, which tracks the cursor and so overflows
/// (drawing over the right neighbour, hence the slot's `zIndex(1)`) or underfills the shadow
/// between ticks.
///
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
/// off the tidy snapped layout regardless of the live overflow.
@ViewBuilder
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
let resizing = resize.isResizing(lane.id)
let units = resizing ? resize.units : LaneLayoutMath.displayUnits(of: lane)
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
ZStack(alignment: .topLeading) {
if resizing {
LaneResizeShadow()
.frame(width: slotWidth)
.frame(maxHeight: .infinity)
.allowsHitTesting(false)
}
// Interior columns follow the SNAPPED unit count while this lane is being resized a
// column count is integral, so it tracks k (which ticks and animates), not the live
// continuous width and not the not-yet-committed `lane.width`. The live width still
// narrows and widens the columns continuously, so the cards reflow under the cursor
// between ticks (free via `MasonryLayout`).
LaneView(lane: lane, columns: units)
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
}
.frame(width: slotWidth, alignment: .topLeading)
.zIndex(resizing ? 1 : 0)
.overlay(alignment: .trailing) {
LaneResizeHandle(
store: store,
session: resize,
laneID: lane.id,
committedUnits: LaneLayoutMath.displayUnits(of: lane),
standard: standard,
gap: spacing,
window: window
)
// The read-only lock disables every mutating gesture, not just the menu items
// (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag
// resizes the *window* on the way, so a refused commit would leave the window grown
// around a lane that snapped back and the lock's row is already saying why nothing
// can be written.
.disabled(store.isReadOnly)
}
}
/// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here**
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane
/// (a later card), and a lane that is not on the board consumes none of the window's width.
private var liveLanes: [Lane] {
store.snapshot.lanes.filter { !$0.isDeleted }
}
}
// MARK: - Resize shadow
/// The resting footprint a lane snaps back to, drawn behind the live lane during a resize.
///
/// Minimal on purpose: 03-board-ui.md's placeholder/drag vocabulary lands with the drag milestone,
/// and this is the same shape that card and lane drops will want. Kept here rather than invented
/// twice.
private struct LaneResizeShadow: View {
var body: some View {
RoundedRectangle(cornerRadius: 10)
.fill(.quaternary.opacity(0.5))
}
}
+164
View File
@@ -0,0 +1,164 @@
import CoreGraphics
/// The board strip's geometry, as pure arithmetic no view, no window, no state
/// (`LaneLayoutMathTests`).
///
/// Two rules from 03-board-ui.md meet here, and they are deliberately *different* mechanisms
/// sharing one set of numbers:
///
/// - **Full visibility** (§ Layout full visibility): the window's width divides across the lanes'
/// width units, so `standardWidth` is the whole of the resting layout. There is no horizontal
/// scroll and no minimum lane width to honour enough units in a small window compress every
/// lane, and that is accepted rather than floored.
/// - **The right-edge drag** (§ Lane): a snap between whole unit counts that grows or shrinks the
/// *window* by one standard width per tick, so the other lanes keep their exact pixels.
/// `slotWidth`, `snappedUnits`, `resistedWidth` and `maxUnits` are that interaction's arithmetic,
/// ported from the pathfinder's proven `ColumnResizeMath` (its reasoning is reproduced below,
/// since the behaviour is what was proven, not the code).
///
/// The one behavioural difference from the pathfinder: **Lanework has no upper width cap.** A lane
/// spans any whole number of units 1, so `allowedRange`'s ceiling is only ever the on-screen fit
/// (`maxUnits`) there is no `Column.widthRange` equivalent to fold in, and shrinking is always
/// allowed.
enum LaneLayoutMath {
// MARK: - The resting layout
/// The 1× (one unit) lane width for a strip `stripWidth` points wide laying out `totalUnits`
/// whole units with `gap` between lanes **and `gap` again outside the first and the last**
/// hence `totalUnits + 1` gaps: the `totalUnits - 1` interior ones plus the strip's two outer
/// margins. The strip therefore always exactly fills, which is what "every lane is always on
/// screen" means arithmetically (03-board-ui.md § Layout full visibility).
///
/// **Floored at 1pt, and at nothing else.** The design is explicit that the degenerate case is
/// accepted, not floored: a minimum lane width would reintroduce horizontal scroll, which was
/// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame
/// is never zero or negative the pathological input (a strip narrower than its own gaps) must
/// not produce a negative size for SwiftUI to complain about.
static func standardWidth(stripWidth: CGFloat, totalUnits: Int, gap: CGFloat) -> CGFloat {
let count = CGFloat(max(1, totalUnits))
return max(1, (stripWidth - gap * (count + 1)) / count)
}
/// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1`
/// interior gaps it swallows. `BoardView`'s per-lane frame is this exact expression, so a
/// snapped slot and a committed lane are the same pixels.
static func slotWidth(units: Int, standard: CGFloat, gap: CGFloat) -> CGFloat {
standard * CGFloat(units) + gap * CGFloat(units - 1)
}
/// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1
/// otherwise.
///
/// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key, a
/// non-numeric value, a fraction, a zero or a negative all arrive here as `.missing` or
/// `.malformed` and render as one unit the bytes on disk are left exactly as the author wrote
/// them until the user actually changes the width, at which point the Writer replaces them with
/// an integer (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side
/// already refuses anything below 1, and this function is the single place the rest of the UI
/// asks "how many units does this lane span".
static func displayUnits(of lane: Lane) -> Int {
max(1, lane.width.value ?? 1)
}
/// The unit total a strip of `lanes` divides across the sum of their display units, never
/// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
///
/// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because
/// a tombstoned lane renders nowhere on the board (03-board-ui.md § Trash collapses it to a
/// single trash entry) and so consumes none of the window's width. When the trash quasi-lane
/// arrives it joins this total as one fixed unit "Show/Hide Trash is a re-divide trigger".
static func totalUnits(of lanes: [Lane]) -> Int {
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) })
}
// MARK: - The drag's snap
/// The snapped unit count after a live-width change: `currentUnits` unless the live width has
/// moved far enough into "shadow leads" territory for an adjacent slot, in which case it ticks
/// by exactly one.
///
/// This is an ASYMMETRIC snap, not a midpoint-±-band around a boundary: the shadow (the snapped
/// count, which sizes the visible slot) leads the live edge going up and deliberately lags it
/// coming back down.
///
/// Tick UP fires the instant the live edge clears the FAR side of the gap that trails slot
/// `k` `liveWidth > slotWidth(k) + gap` which is exactly where the next lane's content
/// would start. The moment the cursor has eaten the whole gap, the bigger slot is already
/// the honest read of what is under it, so the shadow jumps there right away: it never
/// lags, and the live edge can only ever overhang the shadow by at most one gap width,
/// transiently, in the instant just before a tick.
/// Tick DOWN fires only once the live edge has retreated `reentry` (10pt, fixed) back INTO
/// that same gap `liveWidth < slotWidth(k - 1) + gap - reentry` rather than at the
/// mirror image of the tick-up threshold. Shrinking back the instant the edge re-enters the
/// gap it just cleared would flap the window on the smallest jitter right at the crossing;
/// requiring a real 10pt of retreat means the cursor has to mean it. (03-board-ui.md § Lane
/// names exactly this: "shadow snaps at the inter-column gap with 10pt release hysteresis".)
///
/// `reentry` is the ONLY hysteresis in this design it exists to give the tick-down threshold
/// room, not to make the two thresholds symmetric. Stability follows from the two thresholds
/// never meeting: for shadow `k` the hold band is `(slotWidth(k - 1) + gap - reentry,
/// slotWidth(k) + gap]`, which stays non-empty as long as `standard` is many times larger than
/// 10pt (true of every lane width a real window produces), so calling this on every drag event
/// never oscillates.
///
/// Ticks are capped to `allowedRange`, which in Lanework folds in **only** the on-screen fit
/// (`maxUnits`) there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, no
/// cap"), and the uncapped widths beyond the screen's capacity are the stepper's business, not
/// the drag's.
static func snappedUnits(
liveWidth: CGFloat,
currentUnits: Int,
standard: CGFloat,
gap: CGFloat,
allowedRange: ClosedRange<Int>,
reentry: CGFloat
) -> Int {
let currentSlot = slotWidth(units: currentUnits, standard: standard, gap: gap)
if currentUnits < allowedRange.upperBound, liveWidth > currentSlot + gap {
return currentUnits + 1
}
if currentUnits > allowedRange.lowerBound {
let previousSlot = slotWidth(units: currentUnits - 1, standard: standard, gap: gap)
if liveWidth < previousSlot + gap - reentry {
return currentUnits - 1
}
}
return currentUnits
}
/// The live width rubber-banded to stay near the allowed slot range: inside `[minSlot,
/// maxSlot]` the proposed width passes through untouched; beyond either end only `resistance`
/// (0.25) of the overshoot is applied, so the edge visibly resists but still gives, signalling
/// the bound without a hard stop (03-board-ui.md § Lane: "Growth hard-stops at the screen's
/// visible frame, with rubber-band feedback"). The snap tick never follows the width past the
/// bound (see `snappedUnits`' clamp), so this is purely cosmetic give.
static func resistedWidth(
proposed: CGFloat,
minSlot: CGFloat,
maxSlot: CGFloat,
resistance: CGFloat
) -> CGFloat {
if proposed < minSlot { return minSlot - (minSlot - proposed) * resistance }
if proposed > maxSlot { return maxSlot + (proposed - maxSlot) * resistance }
return proposed
}
/// The largest unit count that fits on screen: `currentUnits` plus as many whole `step`
/// (= standard + gap) growths as the window has room to expand into before its right edge would
/// pass the screen's visible frame. **Never less than `currentUnits`** shrinking is always
/// allowed regardless of screen room, including from a window already hanging off the edge
/// (negative headroom).
///
/// Unlike the pathfinder's twin there is no width ceiling to `min` against: the drag's only
/// bound is the screen, because it is the mechanism that grows the window. Larger widths are
/// reachable through the stepper, which re-divides instead (03-board-ui.md § Lane).
///
/// Pure so it can be unit-tested; the session computes `headroom` from the live window and its
/// screen and defers the arithmetic here.
static func maxUnits(currentUnits: Int, headroom: CGFloat, step: CGFloat) -> Int {
guard step > 0, headroom.isFinite else { return currentUnits }
let extra = Int(floor(max(0, min(headroom, CGFloat(Int.max) / 2)) / step))
return max(currentUnits, currentUnits + extra)
}
}
+89
View File
@@ -0,0 +1,89 @@
import AppKit
import SwiftUI
/// The invisible 12pt grab strip overlaid at a lane's trailing edge that drives a
/// `LaneResizeSession` from a `DragGesture` (03-board-ui.md § Lane, right-edge drag-to-resize).
///
/// Overlaid so ~8pt hangs into the inter-lane gap and only ~4pt sits over the lane itself (clear of
/// the lane's own vertical scrollbar, which lives at that inner edge). **Every lane gets one, the
/// last included** the rightmost lane's drag is the one that grows the window into free screen
/// space, which is the interaction's headline case.
///
/// It carries no drop target of its own, so it never participates in card/lane/file drops, and as an
/// overlay it hit-tests above the lane body's own gestures.
struct LaneResizeHandle: View {
let store: BoardStore
let session: LaneResizeSession
let laneID: ItemID
/// The lane's committed unit count the k the session starts from.
let committedUnits: Int
/// The strip's standard (1×) width THIS render; captured as the frozen standard the instant the
/// drag begins.
let standard: CGFloat
let gap: CGFloat
/// How the session reaches the host window it resizes. A closure rather than a stored
/// `NSWindow?` because the window attaches asynchronously (`WindowAccessor`), and a value
/// captured in an early body evaluation would be `nil` for the window's whole life.
let window: @MainActor () -> NSWindow?
/// Guards `NSCursor` push/pop balance a fast cursor can leave the strip mid-drag, and the drag
/// can end on or off it, so pushes and pops must be idempotent to never leave a stuck resize
/// cursor.
@State private var cursorPushed = false
private let handleWidth: CGFloat = 12
/// Rightward shift: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
/// into the gap (clear of the scrollbar).
private let overhang: CGFloat = 8
var body: some View {
Color.clear
.frame(width: handleWidth)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.offset(x: overhang)
.onHover { inside in
if inside { pushCursor() } else { popCursor() }
}
// `.global` (window-fixed) space, NOT the handle's own: a tick moves the handle with its
// lane, but the window's top-left is pinned (right-edge growth), so a window-fixed
// translation stays a faithful physical-cursor delta throughout the drag.
.gesture(
DragGesture(minimumDistance: 2, coordinateSpace: .global)
.onChanged { value in
if !session.isResizing(laneID) {
// m5-drag: a resize and a card/lane move must not run at once they
// would both mutate the same strip layout. The board's drag state does
// not exist yet; when it does, this is where the `isDragging` guard goes.
pushCursor()
session.begin(laneID: laneID, units: committedUnits,
standard: standard, gap: gap, window: window())
}
session.update(translation: value.translation.width)
}
.onEnded { _ in
guard session.isResizing(laneID) else { return }
session.end { id, units in store.setLaneWidth(id, units: units) }
popCursor()
}
)
}
private func pushCursor() {
guard !cursorPushed else { return }
NSCursor.resizeLeftRight.push()
cursorPushed = true
}
private func popCursor() {
guard cursorPushed else { return }
NSCursor.pop()
cursorPushed = false
}
}
+188
View File
@@ -0,0 +1,188 @@
import AppKit
import QuartzCore
import SwiftUI
/// Window-local state for an in-flight lane resize the right-edge drag of 03-board-ui.md § Lane.
/// At most one runs per board window at a time; `BoardView` owns it as `@State` and hands it to the
/// lanes and their grab strips.
///
/// The interaction has three collaborating pieces: `LaneLayoutMath` (the pure geometry), this
/// session (the live state plus the per-tick window resize), and `LaneResizeHandle` (the invisible
/// grab strip that drives it from a `DragGesture`). All three are ported from the pathfinder's
/// proven `ColumnResize.swift`, which is what 03-board-ui.md's "pathfinder behavior, proven" refers
/// to.
///
/// ### The invariant that makes it feel solid
///
/// **While a session is active, every OTHER lane keeps its exact pixel width.** That is achieved by
/// freezing the strip's standard (1×) width at drag start and sizing the *window* so that after
/// each snap tick the ordinary viewport-derived formula reproduces that frozen standard exactly
/// so releasing the drag hands back to the resting layout with no pixel jump. This is the opposite
/// mechanism from the stepper (and its / keyboard face), which never touches the window and
/// re-divides the existing width across the new unit total; the design is explicit that
/// window-growing behaviour belongs to the drag alone.
///
/// The session owns the two things that must move together on each tick: the SwiftUI unit count
/// (`units`, which drives the shadow slot, the siblings' positions, and the resizing lane's masonry
/// column count) and the host window's width. They animate on matching 0.2s curves the lane-resize
/// entry in 03-board-ui.md § Motion's snappy-spring vocabulary so the window edge and the lanes to
/// its right travel as one.
@MainActor
@Observable
final class LaneResizeSession {
/// The lane being resized; `nil` when idle. Observed flipping it drives `BoardView`'s
/// frozen-standard override and the shadow slot on and off, and `LaneView`'s column count.
private(set) var laneID: ItemID?
/// The dragged lane's live rendered width tracks the cursor continuously (rubber-banded at
/// the ends), so its masonry reflows live between ticks.
private(set) var liveWidth: CGFloat = 0
/// The snapped unit count k. Drives the shadow slot width, the layout slot the siblings
/// position off, and the resizing lane's masonry column count. Ticks by ±1 and animates.
private(set) var units: Int = 1
/// The strip's standard (1×) width, frozen at drag start. Used for ALL lane widths in
/// `BoardView` while a session is active the window is animating mid-session, so recomputing
/// the standard from the live viewport width would feed the animation back into the layout and
/// pulse every lane. Read within renders already triggered by the observed properties above, so
/// it need not itself be observed.
@ObservationIgnored private(set) var standard: CGFloat = 1
/// The strip's inter-lane gap (== `BoardView.spacing`), captured at begin.
@ObservationIgnored private var gap: CGFloat = 12
/// The committed unit count at drag start the anchor the drag translation is measured from.
@ObservationIgnored private var startUnits: Int = 1
/// The largest unit count that fits on screen. The drag's only ceiling: Lanework's `width` has
/// no cap (03-board-ui.md § Lane), so nothing else bounds growth.
@ObservationIgnored private var fittingUnits: Int = 1
/// The host window, resized by ±(standard + gap) on each tick. Weak a window can close,
/// though a resize cannot outlive the gesture that drives it.
@ObservationIgnored private weak var window: NSWindow?
var isActive: Bool { laneID != nil }
func isResizing(_ id: ItemID) -> Bool { laneID == id }
/// The tick-down re-entry distance: how far the live edge must retreat back into a gap it has
/// already crossed before the shadow shrinks (03-board-ui.md § Lane's "10pt release
/// hysteresis" see `LaneLayoutMath.snappedUnits`). Fixed in points, not proportional to
/// `standard`: it only needs to be comfortably larger than cursor jitter, which 10pt is
/// regardless of lane size.
private let reentry: CGFloat = 10
/// The rubber-band overshoot fraction past the end slots.
private let resistance: CGFloat = 0.25
/// Unit counts the tick may reach: one up to the on-screen fit. The floor is 1 because a lane
/// spans at least one unit; there is no ceiling but the screen.
private var allowedRange: ClosedRange<Int> {
1...max(1, fittingUnits)
}
private var minSlot: CGFloat {
LaneLayoutMath.slotWidth(units: allowedRange.lowerBound, standard: standard, gap: gap)
}
private var maxSlot: CGFloat {
LaneLayoutMath.slotWidth(units: allowedRange.upperBound, standard: standard, gap: gap)
}
// MARK: - Lifecycle
/// Starts a resize of `laneID`, freezing the standard width and the gap and measuring how far
/// the window can grow on its current screen.
func begin(laneID: ItemID, units: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) {
self.laneID = laneID
self.startUnits = units
self.units = units
self.standard = standard
self.gap = gap
self.window = window
self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
self.fittingUnits = Self.fittingMaxUnits(currentUnits: units, standard: standard, gap: gap, window: window)
}
/// Applies a drag translation (points, measured from the gesture's start): tracks the live width
/// to the cursor with end-resistance, then ticks the snapped unit count to its fixed point and
/// applies the result in one animated step.
///
/// A single call to `snappedUnits` only ever steps by ±1, but a fast flick can carry the live
/// width across two (or more) thresholds between consecutive gesture events, so it is iterated
/// here until it stops moving bounded by `allowedRange`'s width, so this never loops more than
/// a couple of times in practice. `tick(to:)` already computes a correct multi-step window-size
/// delta from `units` to the target, so only the FINAL target gets one `tick` call, not one per
/// intermediate step.
func update(translation: CGFloat) {
guard isActive else { return }
let startSlot = LaneLayoutMath.slotWidth(units: startUnits, standard: standard, gap: gap)
liveWidth = LaneLayoutMath.resistedWidth(
proposed: startSlot + translation,
minSlot: minSlot, maxSlot: maxSlot, resistance: resistance)
var target = units
while true {
let next = LaneLayoutMath.snappedUnits(
liveWidth: liveWidth, currentUnits: target,
standard: standard, gap: gap, allowedRange: allowedRange, reentry: reentry)
if next == target { break }
target = next
}
if target != units { tick(to: target) }
}
/// Commits the snapped unit count and dismisses the session. Order matters for a flash-free
/// handoff: write the model FIRST (the session is still active, so the frozen standard still
/// governs and the shadow slot does not budge), THEN clear the session inside the snap animation
/// at which point `BoardView` reverts to the viewport-derived standard, which the window
/// sizing has kept equal to the frozen one, so the resting layout reproduces the same pixels
/// while the live width animates the last sub-tick of overflow/underfill away. The window and
/// the siblings are already in place; neither is touched here.
///
/// The commit is a *write*, not a snapshot mutation: it goes to disk through the Writer and
/// comes back as an ordinary reload (02-architecture.md § Layering's one-way flow), so the lane
/// briefly renders at its pre-drag width if the write fails which is exactly the honesty the
/// banner then explains.
func end(commit: (ItemID, Int) -> Void) {
guard let laneID else { return }
commit(laneID, units)
withAnimation(.snappy(duration: 0.2)) {
self.laneID = nil
self.liveWidth = 0
}
}
// MARK: - Tick
/// A single snapped step: animate the unit count (which resizes the shadow slot, translates the
/// lanes to the right, and reflows the resizing lane's interior columns) and the window's width
/// on matching 0.2s curves. The window grows and shrinks at its RIGHT edge width changes by
/// ±step with `origin.x` and height held so everything to the left, including this lane's own
/// left edge and the drag's coordinate origin, stays put.
private func tick(to newUnits: Int) {
let delta = CGFloat(newUnits - units) * (standard + gap)
withAnimation(.snappy(duration: 0.2)) { units = newUnits }
guard let window else { return }
var frame = window.frame
frame.size.width += delta // right-edge growth: origin and height unchanged
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.2
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
context.allowsImplicitAnimation = true
window.setFrame(frame, display: true)
}
}
/// The on-screen fit, from the window's headroom to its screen's visible frame the hard stop
/// 03-board-ui.md § Lane requires ("Growth hard-stops at the screen's visible frame"). Defers
/// the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current count is
/// the honest answer (growth needs a window to grow).
private static func fittingMaxUnits(currentUnits: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) -> Int {
guard let window, let screen = window.screen ?? NSScreen.main else { return currentUnits }
let headroom = screen.visibleFrame.maxX - window.frame.maxX
return LaneLayoutMath.maxUnits(currentUnits: currentUnits, headroom: headroom, step: standard + gap)
}
}
+93
View File
@@ -0,0 +1,93 @@
import SwiftUI
/// One lane: a header and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
///
/// **The chrome here is deliberately minimal**, and the design's real lane is a later card: the
/// leading SF Symbol, the new-card button, the drag surface, the context menu (Rename, Style, the
/// quick-style recents row, the Width stepper, Delete), the colour accent band, inline rename and
/// the search-aware count all arrive with the lane-chrome milestone. What this view owes the
/// full-visibility layout card is the shape a header, a body that scrolls vertically only, and a
/// masonry whose interior column count is the lane's width and that is all it does.
struct LaneView: View {
let lane: Lane
/// Interior masonry columns the lane's width units, or the resize session's snapped count
/// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can
/// override it (see `BoardView.laneSlot`).
let columns: Int
/// Spacing between cards, and between the interior columns.
private let cardSpacing: CGFloat = 8
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
ScrollView(.vertical) {
// Cards stay standard width whatever the lane spans: at a slot width of
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
// `units` columns of `standard` (03-board-ui.md § Layout full visibility).
MasonryLayout(columns: columns, spacing: cardSpacing) {
ForEach(liveCards) { card in
CardStubView(card: card)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
}
/// The header title, or a quiet "Untitled" where there is none, plus the live card count.
///
/// Titles are optional at every level (03-board-ui.md § Card face): a missing `title` renders as
/// a secondary-styled placeholder rather than as an empty row. **Replaced wholesale by the
/// lane-chrome card**, which brings the icon, the count badge's real styling, the new-card
/// button, the drag surface and the context menu.
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(lane.title.value ?? "Untitled")
.font(.headline)
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.tail)
Text("\(liveCards.count)")
.font(.callout)
.foregroundStyle(.secondary)
.monospacedDigit()
Spacer(minLength: 0)
}
.padding(.horizontal, 4)
}
/// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane the
/// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
/// tombstoned lane at all.
private var liveCards: [Card] {
lane.cards.filter { !$0.isDeleted }
}
}
// MARK: - Card stub
/// A card, as a rounded plate with its title **a stand-in, replaced by the card-face card**, which
/// brings the leading icon, the attachment chip, the selection and cut treatments, inline rename and
/// the sole-selected card's attachment carousel (03-board-ui.md § Card face).
///
/// It takes whatever width `MasonryLayout` proposes (one interior column = one standard width) and
/// sizes its own height to its content, which is what makes the masonry masonry: a taller card only
/// pushes the cards below it in its own column.
private struct CardStubView: View {
let card: Card
var body: some View {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
}
}
+102
View File
@@ -0,0 +1,102 @@
import SwiftUI
/// Masonry layout for a lane's interior card columns (03-board-ui.md § Layout full visibility:
/// "a wide lane flows them into as many interior masonry columns as it has units"; § Lane: "masonry
/// grid when wide settled, the pathfinder's masonry works").
///
/// Children are assigned round-robin to `columns` equal-width vertical columns (child `i` column
/// `i % columns`), and each column stacks its children top-aligned and independently there is
/// **no row alignment across columns**. With uniform card heights this renders exactly like a
/// row-major grid, but when one card grows taller (the sole selected card's attachment carousel,
/// 03-board-ui.md § Card face) it only pushes the cards below it in its *own* column; the
/// neighbouring columns do not move.
///
/// A `Layout` rather than an `HStack` of per-column `VStack`s so the caller keeps a single
/// `ForEach` reflowing cards across columns preserves view identity and animates as positional
/// moves, not as remove/insert transitions. That is what lets the interior reflow *during* a resize
/// drag read as cards sliding rather than blinking.
///
/// **Vocabulary note.** In Lanework a "lane" is the kanban column; this layout's own interior
/// tracks are "columns". The pathfinder called them lanes, which is why the ported reasoning below
/// reads the way it does.
struct MasonryLayout: Layout {
/// Number of interior columns (the lane's width units); clamped to 1.
var columns: Int
/// Spacing between columns and between stacked cards within a column.
var spacing: CGFloat
private var columnCount: Int { max(1, columns) }
private func columnWidth(for totalWidth: CGFloat) -> CGFloat {
max(0, (totalWidth - spacing * CGFloat(columnCount - 1)) / CGFloat(columnCount))
}
// MARK: - Measurement cache
//
// A lane with several hundred cards is measured a LOT: SwiftUI probes a layout's `sizeThatFits`
// more than once per pass (different proposals), and `placeSubviews` needs every height again
// right after. Unmemoized that is `subviews.count` full subtree measurements per call.
//
// The cache holds one height per subview, keyed by the column width they were measured at:
// column width is the only thing this layout ever proposes (height is always `nil`, so a card's
// height is a pure function of its width and its content). A different column width a window
// resize, a width change discards the whole table, which is correct and cheap: it is exactly
// the case where every height really did change.
//
// Staleness is handled by SwiftUI itself: `updateCache` runs whenever the layout's subviews
// change, which is the only way a card's measured height can change at a fixed column width
// (its content changed its view tree was rebuilt the layout's content is new). Clearing
// there means the cache never outlives the content it measured.
struct Cache {
var columnWidth: CGFloat = .nan
var heights: [CGFloat] = []
}
func makeCache(subviews: Subviews) -> Cache { Cache() }
func updateCache(_ cache: inout Cache, subviews: Subviews) {
cache = Cache()
}
/// `subviews[index]`'s height at `column` width, measured once per (content, column width)
/// generation and reused for every later probe and for the placement pass.
private func height(of subviews: Subviews, at index: Int, column: CGFloat, cache: inout Cache) -> CGFloat {
if cache.columnWidth != column || cache.heights.count != subviews.count {
cache.columnWidth = column
cache.heights = [CGFloat](repeating: .nan, count: subviews.count)
}
if !cache.heights[index].isNaN {
return cache.heights[index]
}
let height = subviews[index].sizeThatFits(ProposedViewSize(width: column, height: nil)).height
cache.heights[index] = height
return height
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
let width = proposal.width ?? 0
let column = columnWidth(for: width)
var heights = [CGFloat](repeating: 0, count: columnCount)
for index in subviews.indices {
let height = height(of: subviews, at: index, column: column, cache: &cache)
let target = index % columnCount
heights[target] += height + (heights[target] > 0 ? spacing : 0)
}
return CGSize(width: width, height: heights.max() ?? 0)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
let column = columnWidth(for: bounds.width)
var y = [CGFloat](repeating: bounds.minY, count: columnCount)
for index in subviews.indices {
let target = index % columnCount
let x = bounds.minX + CGFloat(target) * (column + spacing)
let height = height(of: subviews, at: index, column: column, cache: &cache)
subviews[index].place(at: CGPoint(x: x, y: y[target]),
proposal: ProposedViewSize(width: column, height: height))
y[target] += height + spacing
}
}
}
+2
View File
@@ -50,6 +50,7 @@ private let everyOperation: [WriteOperation] = [
.restore(title: "Fix login"),
.purge(title: "Fix login"),
.style(title: "Fix login"),
.resize(title: "Fix login"),
.importAttachment(filename: "photo.png"),
.listAttachments,
.renumberChildren,
@@ -64,6 +65,7 @@ private let titledOperations: [(with: WriteOperation, without: WriteOperation)]
(.restore(title: "Fix login"), .restore(title: nil)),
(.purge(title: "Fix login"), .purge(title: nil)),
(.style(title: "Fix login"), .style(title: nil)),
(.resize(title: "Fix login"), .resize(title: nil)),
]
// MARK: - Ordering
+305
View File
@@ -0,0 +1,305 @@
import CoreGraphics
import Foundation
import Testing
@testable import Kanban
/// Unit tests for the board strip's pure geometry (03-board-ui.md § Layout full visibility and
/// § Lane): the resting division of the window across width units, and the right-edge drag's
/// asymmetric "shadow leads" snap.
///
/// Fixture throughout the snap suites: a frozen standard of 100 and a gap of 12 so a `step`
/// (`standard + gap`) is 112, and the slot widths are:
/// 1× = 100 · 2× = 212 · 3× = 324
/// The tick-up threshold for shadow `k` is `slotWidth(k) + gap` the far side of the gap trailing
/// that slot so 12 ticks up at 112 (100 + 12) and 23 at 224 (212 + 12). The tick-down threshold
/// back to `k - 1` is `slotWidth(k - 1) + gap - reentry`, 10pt shy of that same boundary so 21
/// ticks down at 102 (112 10) and 32 at 214 (224 10).
///
/// Where the pathfinder's twin suite pinned a hard 13 width cap, these pin the *screen fit*: in
/// Lanework `allowedRange`'s ceiling is only ever how far the window can grow (`maxUnits`), because
/// the width field itself has no cap.
private let standard: CGFloat = 100
private let gap: CGFloat = 12
private let step: CGFloat = standard + gap // 112
private let reentry: CGFloat = 10
/// A screen that fits three units the drag's ceiling in most tests below.
private let fitsThree = 1...3
private func slot(_ units: Int) -> CGFloat {
LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
}
private func snapped(_ liveWidth: CGFloat, current: Int, range: ClosedRange<Int>? = nil) -> Int {
LaneLayoutMath.snappedUnits(liveWidth: liveWidth, currentUnits: current,
standard: standard, gap: gap,
allowedRange: range ?? fitsThree, reentry: reentry)
}
// MARK: - Lane fixtures
/// Loads a board whose lanes carry the given `width:` frontmatter values (`nil` writes no key), in
/// the order given `order` keys make the display order the argument order.
///
/// Built through `BoardLoader` rather than by constructing `Lane` values by hand: `width`'s
/// leniency is a *read-side* rule (01-storage-format.md § Frontmatter), so the only honest way to
/// ask "what does a malformed width display as" is to put the malformed bytes on disk and load
/// them.
private func lanes(widths: [String?]) throws -> [Lane] {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("LaneLayoutMathTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try write("---\nschema: 1\ntitle: Board\n---\n", to: root)
for (index, width) in widths.enumerated() {
var frontmatter = "---\nschema: 1\ntitle: Lane \(index)\norder: \(1024 * (index + 1))\n"
if let width {
frontmatter += "width: \(width)\n"
}
frontmatter += "---\n"
let folder = root.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
try write(frontmatter, to: folder)
}
return try BoardLoader.load(boardRoot: root).model.lanes
}
private func write(_ text: String, to folder: URL) throws {
try Data(text.utf8).write(to: folder.appendingPathComponent("index.md"))
}
private func lane(width: String?) throws -> Lane {
let loaded = try lanes(widths: [width])
return try #require(loaded.first)
}
// MARK: - The resting layout
@Suite("LaneLayoutMath ▸ the resting division")
struct LaneLayoutStandardWidthTests {
@Test("The strip divides its width across the units, counting a gap outside each end")
func standardWidthCountsOuterMargins() {
// 1000 = 1 lane + 2 outer gaps: (1000 24) / 1.
#expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 1, gap: 12) == 976)
// Three units: 4 gaps (2 interior + 2 outer) (1000 48) / 3.
#expect(abs(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: 12) - 952.0 / 3.0) < 0.0001)
// A wide lane consumes several units of the same division, and the strip still fills
// exactly: 4 units of standard plus the gaps is the whole strip.
let standard = LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 4, gap: 12)
let filled = 4 * standard + 12 * 5
#expect(abs(filled - 1000) < 0.0001)
}
@Test("A slot swallows the interior gaps it spans, so lanes and slots are the same pixels")
func slotWidthSwallowsInteriorGaps() {
#expect(slot(1) == 100)
#expect(slot(2) == 212) // 2·100 + 1·12
#expect(slot(3) == 324) // 3·100 + 2·12
}
@Test("Compression is accepted, not floored — only a 1pt floor keeps frames positive")
func degenerateStripsCompressWithoutAMinimum() {
// Twenty units in a small window: each lane is a sliver, and that is the design's answer
// ("the degenerate case is accepted, not floored"), not a scroll bar.
let squeezed = LaneLayoutMath.standardWidth(stripWidth: 400, totalUnits: 20, gap: 12)
#expect(squeezed > 0)
#expect(squeezed < 10)
// Narrower than its own gaps: still positive, because a zero or negative frame is a
// rendering bug rather than a design outcome.
#expect(LaneLayoutMath.standardWidth(stripWidth: 10, totalUnits: 4, gap: 12) == 1)
#expect(LaneLayoutMath.standardWidth(stripWidth: 0, totalUnits: 1, gap: 12) == 1)
}
@Test("An empty strip still has a divisor")
func zeroUnitsIsTreatedAsOne() {
#expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 0, gap: 12) == 976)
#expect(LaneLayoutMath.totalUnits(of: []) == 1)
}
}
// MARK: - Reading a lane's units
@Suite("LaneLayoutMath ▸ display units")
struct LaneDisplayUnitsTests {
@Test("A valid width spans that many units")
func validWidthSpansItsUnits() throws {
#expect(LaneLayoutMath.displayUnits(of: try lane(width: "3")) == 3)
#expect(LaneLayoutMath.displayUnits(of: try lane(width: "1")) == 1)
// The read side coerces where a sensible reading exists, and the layout follows it.
#expect(LaneLayoutMath.displayUnits(of: try lane(width: "\"2\"")) == 2)
}
@Test("A missing, malformed, zero or negative width renders as one unit")
func leniencyRendersAsOne() throws {
let missing = try lane(width: nil)
#expect(missing.width.isMissing)
#expect(LaneLayoutMath.displayUnits(of: missing) == 1)
// Each of these stays `.malformed` on the model the bytes are preserved, not corrected
// and renders as 1 (01-storage-format.md § Frontmatter's lenient-field rule).
for raw in ["wide", "0", "-3", "1.5"] {
let lane = try lane(width: raw)
#expect(lane.width.isMalformed, "width: \(raw) should stay malformed rather than coerce")
#expect(LaneLayoutMath.displayUnits(of: lane) == 1, "width: \(raw) should render as one unit")
}
}
@Test("The strip's unit total is the sum over the lanes it is given")
func totalUnitsSumsDisplayUnits() throws {
let loaded = try lanes(widths: ["2", nil, "3", "banana"])
#expect(loaded.map(LaneLayoutMath.displayUnits(of:)) == [2, 1, 3, 1])
#expect(LaneLayoutMath.totalUnits(of: loaded) == 7)
#expect(LaneLayoutMath.totalUnits(of: Array(loaded.prefix(2))) == 3)
}
@Test("There is no upper cap on a lane's width")
func widthIsUncapped() throws {
// 03-board-ui.md § Lane: "1×, 2×, 3×, no cap". A wide lane simply takes more of the
// division; nothing clamps it on the way in.
let wide = try lane(width: "40")
#expect(LaneLayoutMath.displayUnits(of: wide) == 40)
#expect(LaneLayoutMath.totalUnits(of: [wide]) == 40)
}
}
// MARK: - The snap
@Suite("LaneLayoutMath ▸ the drag's snap")
struct LaneSnapTests {
// MARK: Tick up fires just past the far side of the gap, not inside it
@Test("Ticks up only past the far side of the trailing gap")
func ticksUpOnlyPastTheFarSideOfTheGap() {
// Slot 1 is 100; its trailing gap runs to 112 (100 + 12). The shadow must not lead the live
// edge while the edge is still IN the gap.
#expect(snapped(111.9, current: 1) == 1) // still inside the gap holds
#expect(snapped(112, current: 1) == 1) // exactly at the far edge holds (strict >)
#expect(snapped(112.1, current: 1) == 2) // clearing the gap ticks up immediately
}
@Test("Ticks up by exactly one per call")
func ticksUpByExactlyOne() {
// A live width well into 3×'s slot still steps only one unit per call (the continuous drag
// calls this on every event, and the session iterates it to a fixed point).
#expect(snapped(1000, current: 1) == 2)
#expect(snapped(1000, current: 2) == 3)
}
// MARK: Tick down fires only 10pt back inside the gap just crossed
@Test("Ticks down only below the 10pt re-entry point")
func ticksDownOnlyBelowTheReentryPoint() {
// Coming back from 2×, the boundary is slot(1) + gap = 112; tick-down requires retreating a
// further 10pt to 102.
#expect(snapped(102.1, current: 2) == 2) // just above the re-entry point holds
#expect(snapped(102, current: 2) == 2) // exactly at the re-entry point holds (strict <)
#expect(snapped(101.9, current: 2) == 1) // clearing the re-entry point ticks down
}
@Test("Re-entering the gap is not enough to tick down")
func ticksDownDoesNotFireWhileStillInTheGap() {
// Immediately after re-entering the gap (e.g. 110), the edge has NOT yet retreated the full
// 10pt, so the shadow must still hold at 2× this is the asymmetry: growing was instant,
// shrinking is not.
#expect(snapped(110, current: 2) == 2)
#expect(snapped(103, current: 2) == 2)
}
// MARK: Hold-band stability from both directions
@Test("The hold band is stable from both directions")
func holdBandIsStable() {
// The hold band for shadow 2× is (102, 224] everything strictly between the 12 re-entry
// point and the 23 tick-up threshold holds at 2× with no oscillation.
for width in stride(from: CGFloat(103), through: 223, by: 20) {
#expect(snapped(width, current: 2) == 2, "2× holds at \(width)")
}
}
@Test("A value that just ticked up does not immediately tick back down")
func noImmediateReversalAfterTickingUp() {
// Crossing 112.1 ticks 1× 2×. Re-evaluating at that same live width with the NEW unit
// count must hold, not bounce back 112.1 is comfortably above the 21 re-entry point.
let justTicked = snapped(112.1, current: 1)
#expect(justTicked == 2)
#expect(snapped(112.1, current: justTicked) == 2)
}
@Test("A value that just ticked down does not immediately tick back up")
func noImmediateReversalAfterTickingDown() {
let justTicked = snapped(101.9, current: 2)
#expect(justTicked == 1)
#expect(snapped(101.9, current: justTicked) == 1)
}
// MARK: Range clamping
@Test("The snap never steps past the allowed range")
func neverTicksPastTheAllowedRange() {
#expect(snapped(5000, current: 3) == 3, "the on-screen fit is the ceiling")
#expect(snapped(0, current: 1) == 1, "one unit is the floor")
#expect(snapped(-500, current: 1) == 1)
}
@Test("The ceiling is the screen fit, and it is the only ceiling")
func snapRespectsTheOnScreenFit() {
// With the fit capping the range at 2×, no live width ticks to 3×.
let fitsTwo = 1...2
#expect(snapped(1000, current: 2, range: fitsTwo) == 2)
#expect(snapped(5000, current: 2, range: fitsTwo) == 2)
// Below the cap it still ticks normally.
#expect(snapped(200, current: 1, range: fitsTwo) == 2)
// A roomier screen keeps ticking well past the pathfinder's old 3× ceiling Lanework's
// width has no cap of its own (03-board-ui.md § Lane).
#expect(snapped(5000, current: 3, range: 1...9) == 4)
#expect(snapped(5000, current: 8, range: 1...9) == 9)
}
// MARK: maxUnits
@Test("maxUnits turns window headroom into whole growable units")
func maxUnitsFromHeadroom() {
// Two whole steps of headroom (2 × 112 = 224) grow up to +2.
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 250, step: step) == 3)
// Just over one step +1.
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 120, step: step) == 2)
// Less than a step no growth room, but shrinking stays allowed.
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 50, step: step) == 1)
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 0, step: step) == 2)
// Never below currentUnits even with a negative headroom (a window already past the visible
// frame) shrinking is always allowed.
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: -300, step: step) == 2)
// No width ceiling to clamp against: a huge screen means a huge fit. The pathfinder capped
// this at 3.
#expect(LaneLayoutMath.maxUnits(currentUnits: 1, headroom: 10_000, step: step) == 90)
// A degenerate step is not divided by.
#expect(LaneLayoutMath.maxUnits(currentUnits: 2, headroom: 500, step: 0) == 2)
}
// MARK: Rubber-band resistance
@Test("Inside the bounds the live width passes through untouched")
func widthPassesThroughInsideBounds() {
#expect(LaneLayoutMath.resistedWidth(proposed: 200, minSlot: slot(1),
maxSlot: slot(3), resistance: 0.25) == 200)
#expect(LaneLayoutMath.resistedWidth(proposed: slot(1), minSlot: slot(1),
maxSlot: slot(3), resistance: 0.25) == slot(1))
#expect(LaneLayoutMath.resistedWidth(proposed: slot(3), minSlot: slot(1),
maxSlot: slot(3), resistance: 0.25) == slot(3))
}
@Test("Past either bound the edge gives only a quarter of the overshoot")
func widthResistsPastEitherBound() {
// 20 below the 100 floor 100 20·0.25 = 95.
#expect(LaneLayoutMath.resistedWidth(proposed: 80, minSlot: slot(1),
maxSlot: slot(3), resistance: 0.25) == 95)
// 76 above the 324 ceiling 324 + 76·0.25 = 343.
#expect(LaneLayoutMath.resistedWidth(proposed: 400, minSlot: slot(1),
maxSlot: slot(3), resistance: 0.25) == 343)
}
}
+204
View File
@@ -0,0 +1,204 @@
import Foundation
import Testing
@testable import Kanban
/// `BoardStore.setLaneWidth` the one commit point both width mechanisms share (03-board-ui.md §
/// Lane): the right-edge drag's release and the / stepper.
///
/// These drive a real store over a real temp board and then read the **raw bytes** back, never the
/// app's own read path, because the interesting claims are about the file: the integer that lands,
/// the stamps that follow it, and everything else surviving byte-for-byte. `WriterFixture`, `Ident`
/// and `Item` come from `WriterTestSupport.swift`, as they do for every other write suite.
// MARK: - Fixtures
/// A lane index carrying an explicit `width:` `Item.rich` deliberately has none, and half of what
/// is under test here is what happens to a value that is already there (valid or not).
private func laneText(order: String, title: String, width: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
width: \(width)
project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
modified-by: claude
---
\(title) body.
"""
}
/// A board with one plain lane (no `width`), one already at 3×, and one whose frontmatter is
/// readable but uneditable.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3"))
try fixture.item(Ident.lane3, Item.uneditable)
return fixture
}
/// The file's lines minus the ones an app-mediated write is *supposed* to change what must come
/// through a resize byte-for-byte, in order.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
!$0.hasPrefix("modified") && !$0.hasPrefix("width:")
}
}
private func loadedLane(_ id: String, in fixture: WriterFixture) throws -> Lane {
let model = try BoardLoader.load(boardRoot: fixture.root).model
return try #require(model.lanes.first { $0.id.rawValue == id })
}
// MARK: - Tests
@MainActor
@Suite("BoardStore ▸ lane width")
struct LaneWidthWriteTests {
@Test("A width change writes the integer, stamps modified, clears modified-by, and touches nothing else")
func writesTheIntegerAndStamps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText(Ident.lane1)
store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 3)
let after = try fixture.indexText(Ident.lane1)
#expect(after.contains("width: 3"))
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
#expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh")
// Everything the write does not own survives exactly, in order: the unknown key with its
// inline comment, the reserved `labels`, the original `created`, and the body.
#expect(untouchedLines(after) == untouchedLines(before))
let lane = try loadedLane(Ident.lane1, in: fixture)
#expect(lane.width == .valid(3))
#expect(lane.modifiedBy.isMissing)
let modified = try #require(lane.modified.value)
#expect(abs(modified.timeIntervalSinceNow) < 60, "modified is stamped with the time of the write")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A width change replaces whatever was there — a malformed value included")
func replacesAMalformedValue() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "wide"))
let store = try BoardStore(rootURL: fixture.root)
// `width: wide` is lenient on the read side it renders as one unit with the bytes left
// alone (01-storage-format.md § Frontmatter). An explicit change is the user overwriting
// it, so the Writer puts a plain integer in its place.
#expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.width == .malformed(raw: "wide"))
store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 2)
let after = try fixture.indexText(Ident.lane1)
#expect(after.contains("width: 2"))
#expect(!after.contains("wide"))
#expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2))
}
@Test("A count below one clamps to one rather than writing a value the read side would reject")
func clampsBelowOne() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Lane two is at 3×, so a clamped 1 is a real change and actually reaches disk.
store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 0)
#expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(1))
try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3"))
let reopened = try BoardStore(rootURL: fixture.root)
reopened.setLaneWidth(ItemID(rawValue: Ident.lane2), units: -7)
#expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(1))
}
@Test("A lane that is not in the snapshot is a no-op")
func unknownLaneIsANoOp() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(Ident.lane1)
// The lane vanished under the gesture (or never existed): the reload that removed it is the
// authority, and inventing a file here would be the app disagreeing with disk.
store.setLaneWidth(ItemID(rawValue: Ident.indexless), units: 3)
#expect(!fixture.exists(Ident.indexless))
#expect(try fixture.indexData(Ident.lane1) == before)
#expect(store.banners.oneShots.isEmpty)
}
@Test("Setting the width a lane already displays writes nothing at all")
func unchangedWidthIsANoOp() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let atThree = try fixture.indexData(Ident.lane2)
let atOne = try fixture.indexData(Ident.lane1)
// A drag that ends where it started, and a stepper pressed against its floor: neither may
// stamp `modified` or (on a git board) mint a commit.
store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 3)
#expect(try fixture.indexData(Ident.lane2) == atThree)
// Lane one has no `width` key at all, so it *displays* one unit setting one unit is the
// same no-op, and must not materialize the key.
store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 1)
#expect(try fixture.indexData(Ident.lane1) == atOne)
#expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either")
}
@Test("A readable-but-uneditable lane refuses the write, banners it, and keeps its bytes")
func uneditableLaneBannersAndChangesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(Ident.lane3)
store.setLaneWidth(ItemID(rawValue: Ident.lane3), units: 2)
// The refusal is the settled readable-but-uneditable rule: the file loads and renders, but
// a surgical edit of it cannot be expressed, so nothing is written.
#expect(try fixture.indexData(Ident.lane3) == before)
#expect(try fixture.indexText(Ident.lane3) == Item.uneditable)
// `performWrite` posts before it rethrows, and `setLaneWidth` swallows the rethrow the
// banner is the only thing that says the gesture did not happen, so it must be there.
#expect(store.banners.oneShots.count == 1)
let posted = try #require(store.banners.oneShots.first)
#expect(posted.error.operation == .resize(title: "Odd"),
"the title is enriched off the document the write refused")
#expect(posted.error.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't resize 'Odd' — "))
#expect(store.bannerRows.contains { $0.id == "one-shot:\(posted.id.uuidString)" })
}
@Test("A read-only board refuses the write without a second banner")
func readOnlyBoardRefusesQuietly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
let before = try fixture.indexData(Ident.lane1)
store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 4)
// The lock row is already standing; a refusal per gesture would bury it under echoes of
// itself (02-architecture.md § Write-failure surfacing).
#expect(try fixture.indexData(Ident.lane1) == before)
#expect(store.banners.oneShots.isEmpty)
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
}
}