Collapsible lanes — frontmatter-backed slim strips outside the width division

A lane folds to a fixed slim vertical strip carrying its glyph, its card-count
badge and its title turned on its side, and the strip is deliberately not part
of the window's division: the expanded lanes' units divide what is left once
each folded strip's fixed width has come off the top, so folding a lane is a
re-divide trigger of the Show/Hide Trash family — the window never moves and
the siblings grow into what the lane gave up.

The state is a first-class lane frontmatter key, `collapsed: true`, and
document state exactly as `width` is: the files are the board, so an agent
folds a lane by writing one key. Absent means expanded, expanding removes the
key rather than writing `false` (the remove-at-default family beside a
one-unit `width`, the empty rename's `title` and the None well's
`background`), and the lane's `width` rides along untouched so expanding
restores the lane the user had. The read is `width`'s leniency one type over —
a boolean scalar or a quoted boolean word reads as itself, everything else has
no reading at all and renders as expanded, bytes preserved either way.

Toggling is the header's always-visible collapse chevron, the lane context
menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on
the strip; a modified click on the strip stays the ordinary selection grammar,
so a folded lane is still selectable by pointer. The title reads bottom-up and
is justified to the top of the room below the strip's chrome (owner ruling
2026-08-08), truncating against the strip's own height.

While folded the lane draws no cards at all, which is what makes every
exclusion true by construction rather than by a guard per gesture: no card
face means no marquee target and no navigation frame, and no registered grid
means the masonry's drop zones have nothing to resolve against. What did need
code is the half that names absolute destinations — the option-arrow jumps and
the arrow seed scan past a folded lane, the lane domain's down-arrow is inert
on one, and New Card skips it (a selection inside one falls through to the
last-active lane, the stale selection's rule). A drop on the strip appends at
the lane's end, cards and Finder files alike, with an accent edge standing in
for the shadow the strip has no masonry to open; there is no hover-to-auto-
expand yet. Lane reorder works on the strip, and a dragged folded lane carries
its fold, so its shadow and its replica are the strip rather than its units.

The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket,
the same stamp behaviour, the same three do-nothing paths — with two new
`WriteOperation` cases and two new undo verbs rather than one of each, because
a banner or an Edit-menu row that said "resize" after Collapse Lane would name
a control the user never touched.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 22:53:10 -04:00
parent 57542177c1
commit bab456c08d
33 changed files with 1811 additions and 133 deletions
+122 -7
View File
@@ -42,9 +42,27 @@ enum LaneLayoutMath {
/// 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 {
///
/// **Collapsed lanes are taken off the top, never divided** (03-board-ui.md § Lane Collapsed
/// lanes): each one consumes a fixed `collapsedWidth` plus the gap that follows it, and the
/// remainder is what the expanded lanes' `totalUnits` divide. So a strip of `T` units and `C`
/// collapsed strips still fills exactly `C·collapsedWidth + T·standard + (T + C + 1)·gap` and
/// `collapsedCount * (collapsedWidth + gap)` is that identity rearranged, which is why the
/// subtraction carries a gap with it.
///
/// Both extra arguments default to nothing, so a board with no folded lane reads exactly as it did
/// before they existed and so does every call site that has no collapse question to ask (the
/// resize drag's two regimes, whose `startTotalUnits` is already the expanded total).
static func standardWidth(
stripWidth: CGFloat,
totalUnits: Int,
gap: CGFloat,
collapsedCount: Int = 0,
collapsedWidth: CGFloat = 0
) -> CGFloat {
let count = CGFloat(max(1, totalUnits))
return max(1, (stripWidth - gap * (count + 1)) / count)
let folded = CGFloat(max(0, collapsedCount)) * (collapsedWidth + gap)
return max(1, (stripWidth - folded - gap * (count + 1)) / count)
}
/// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1`
@@ -70,8 +88,81 @@ enum LaneLayoutMath {
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.
/// **Whether the lane is drawn as a slim strip** (03-board-ui.md § Lane Collapsed lanes) the
/// one place the rest of the app asks, so the leniency below is stated once.
///
/// `collapsed` is a **lenient** field exactly like `width` (01-storage-format.md § Frontmatter): a
/// missing key, an explicit `false`, and a value with no boolean reading at all (`.malformed`) are
/// one answer here **expanded** with the author's bytes left alone either way. Only a real
/// `true` folds a lane, which is what makes an unreadable value harmless rather than surprising.
///
/// A collapsed lane keeps its `width` untouched (`BoardStore.setLaneCollapsed` writes one key and
/// only one), so `displayUnits` still answers for it and is deliberately still asked, by the
/// interior masonry the lane will draw again the moment it expands.
static func isCollapsed(_ lane: Lane) -> Bool {
lane.collapsed.value == true
}
/// How many of `lanes` are drawn as slim strips the count `standardWidth` takes off the top.
static func collapsedCount(of lanes: [Lane]) -> Int {
lanes.count { isCollapsed($0) }
}
/// A lane's **drawn** width: its slot width when expanded, the fixed strip when collapsed. The one
/// answer every hit test and every zone list is built from, so the arithmetic can never disagree
/// with what the strip laid out (`BoardView.laneSlot` frames each lane with this).
static func drawnWidth(
units: Int,
isCollapsed: Bool,
standard: CGFloat,
gap: CGFloat,
collapsedWidth: CGFloat
) -> CGFloat {
isCollapsed ? collapsedWidth : slotWidth(units: units, standard: standard, gap: gap)
}
/// `drawnWidth` over a run of lanes, in the order given the widths list the strip's zones and
/// hit tests walk (`DropSlotMath.laneExtents`, `laneIndex(atX:widths:gap:)`).
static func drawnWidths(
of lanes: [Lane],
standard: CGFloat,
gap: CGFloat,
collapsedWidth: CGFloat
) -> [CGFloat] {
lanes.map {
drawnWidth(
units: displayUnits(of: $0),
isCollapsed: isCollapsed($0),
standard: standard,
gap: gap,
collapsedWidth: collapsedWidth)
}
}
/// `drawnWidth` over a **dragged** run, whose fold state travels beside its unit counts rather than
/// inside a `Lane` (`DragSession.laneUnits` / `laneCollapsed`, both frozen at pickup).
///
/// A `collapsed` array shorter than `units` reads as expanded past its end, which is what a
/// `beginLanes` caller that passed none means and the trashed-lane restore is exactly that caller.
static func drawnWidths(
units: [Int],
collapsed: [Bool],
standard: CGFloat,
gap: CGFloat,
collapsedWidth: CGFloat
) -> [CGFloat] {
units.enumerated().map { index, units in
drawnWidth(
units: units,
isCollapsed: index < collapsed.count && collapsed[index],
standard: standard,
gap: gap,
collapsedWidth: collapsedWidth)
}
}
/// The unit total a strip of `lanes` divides across the sum of the **expanded** lanes' 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 snapshot's, in order. There is no
/// liveness question left to ask "Cards only. Lanes are never trashed" (03-board-ui.md §
@@ -86,8 +177,20 @@ enum LaneLayoutMath {
/// Show/Hide Trash is therefore a **re-divide trigger** and nothing more the window is
/// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a
/// lane add does (§ Layout full visibility).
///
/// **A collapsed lane contributes nothing** (03-board-ui.md § Lane Collapsed lanes: "the strip
/// is not part of the width re-division"): its fixed width is `standardWidth`'s subtraction, not a
/// share of the division, so folding a lane away is a re-divide trigger of the same family the
/// window is untouched and the siblings grow into the space the lane gave up.
///
/// The `max(1,)` therefore covers one more shape than it used to: a board whose **every** lane is
/// collapsed has no expanded unit at all, and the 1 it answers is a divisor guard rather than a
/// description of anything on screen. Nothing draws with that standard the strips take their
/// fixed width and the leftover is empty board except a shown trash column, which is a real unit
/// in the total and correctly gets the whole remainder.
static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int {
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) } + max(0, trashUnits))
let units = lanes.reduce(0) { $0 + (isCollapsed($1) ? 0 : displayUnits(of: $1)) }
return max(1, units + max(0, trashUnits))
}
// MARK: - Hit testing
@@ -104,9 +207,21 @@ enum LaneLayoutMath {
/// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
/// into logic").
static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
laneIndex(
atX: x,
widths: unitCounts.map { slotWidth(units: $0, standard: standard, gap: gap) },
gap: gap)
}
/// The same hit test over **drawn** widths the shape a strip with collapsed lanes in it has to
/// ask, since a slim strip's width is a fixed figure rather than a multiple of the standard
/// (`drawnWidths(of:standard:gap:collapsedWidth:)`).
///
/// This is the primitive and the unit-count version above is its wrapper: one walk, one origin
/// convention, so a board with no folded lane cannot answer differently from one with.
static func laneIndex(atX x: CGFloat, widths: [CGFloat], gap: CGFloat) -> Int? {
var left = gap
for (index, units) in unitCounts.enumerated() {
let width = slotWidth(units: units, standard: standard, gap: gap)
for (index, width) in widths.enumerated() {
if x >= left, x < left + width { return index }
left += width + gap
}